diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index cefa1325631..2c8e6878930 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -441,10 +441,13 @@ jobs: exit 0 fi releases_json="" + npm_versions_json="" if [[ "$REQUESTED_BASELINES" == *"release-history"* ]]; then releases_json=".artifacts/package-candidate-input/openclaw-releases.json" + npm_versions_json=".artifacts/package-candidate-input/openclaw-npm-versions.json" mkdir -p "$(dirname "$releases_json")" gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,publishedAt,isPrerelease > "$releases_json" + npm view openclaw versions --json > "$npm_versions_json" fi args=( --requested "$REQUESTED_BASELINES" @@ -454,6 +457,7 @@ jobs: if [[ -n "$releases_json" ]]; then args+=( --releases-json "$releases_json" + --npm-versions-json "$npm_versions_json" --history-count 6 --include-version 2026.4.23 --pre-date 2026-03-15T00:00:00Z diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a532d9b31..5b600898d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,15 +19,23 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582) +- Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim. +- Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep. +- WhatsApp: stage `qrcode` with the WhatsApp plugin runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001. +- Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. - Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram. +- Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar. +- Plugins/runtime-deps: prune inactive same-package versioned runtime-deps roots after bundled dependency repair, so upgrades do not leave old `openclaw--` package caches behind after doctor runs. Thanks @vincentkoc. - Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc. - Plugins/runtime-deps: keep Gateway startup plugin imports and runtime plugin fallback loads verify-only after startup/config repair planning, so packaged installs no longer spawn package-manager repair from hot paths after readiness. Refs #75283 and #75069. Thanks @brokemac79 and @xiaohuaxi. +- Plugins/runtime-deps: treat package.json runtime-deps manifests as supersets when generated materialization metadata is absent, so bundled plugin activation stops restaging already-installed dependency subsets on every activation. Fixes #75429. (#75431) Thanks @loyur. - Voice Call/realtime: add default-off fast memory/session context for `openclaw_agent_consult`, giving live calls a bounded answer-or-miss path before the full agent consult. Fixes #71849. Thanks @amzzzzzzz. - Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson. - Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc. - Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc. - Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc. - Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP. +- Docs/sandboxing: clarify that sandbox setup scripts (`sandbox-setup.sh`, `sandbox-common-setup.sh`, `sandbox-browser-setup.sh`) are only available from a source checkout, and add inline `docker build` commands for npm-installed users so sandbox image setup works without cloning the repo. Fixes #75485. Thanks @amknight. - Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP. - Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP. - Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22. @@ -48,6 +56,7 @@ Docs: https://docs.openclaw.ai - Security/config-audit: redact CLI argv and execArgv secrets before persisting config audit records, covering write, observe, and recovery paths. Fixes #60826. Thanks @koshaji. - Gateway/models: keep default and configured model-list views responsive when provider catalog discovery stalls, without hiding real catalog load failures, while `--all` still waits for the exact full catalog. Fixes #75297; refs #74404. Thanks @lisandromachado and @najef1979-code. - Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan. +- Plugins/runtime-deps: remove OpenClaw-owned legacy runtime-deps symlinks before replacing staged bundled plugin dependencies, so updates can recover from older symlinked installs instead of failing the symlink safety guard. Thanks @goldmar. - Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord. - TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens. - Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 5b532b8ffcd..d434cae78d5 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -516de8f5049d2c8b7f326cfc1b665cf459609aa491c432d93b8ca8b9463d7243 config-baseline.json -b06e5cd6e7d3a26d99fd4d31d576c49958195451b0b1e9c2db45f038a3c16c44 config-baseline.core.json -da8e055ebba0730498703d209f9e2cfaa1484a83f3240e611dcdd7280e22a525 config-baseline.channel.json +2197c0110a367c9e2adba959ff8529edad7b4d526894eec602e47189d6930d2f config-baseline.json +ac7537ed5b5a2d9e7fa50977aa99f5e0babfbe1a93c7c14b93a184b36bb4f539 config-baseline.core.json +f3326cd9490169afefe93625f63699266b75db93855ed439c9692e3c286a990c config-baseline.channel.json 4d017161b4dc986fdc6cc68167fedbd1d415ddbcd66125a872e18aa1769cd182 config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e0883caa895..ac78012866b 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1048,6 +1048,8 @@ Auto-join example: ], daveEncryption: true, decryptionFailureTolerance: 24, + connectTimeoutMs: 30000, + reconnectGraceMs: 15000, tts: { provider: "openai", openai: { voice: "onyx" }, @@ -1063,11 +1065,14 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model. - STT uses `tools.media.audio`; `voice.model` does not affect transcription. +- Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel. - Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`). - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable voice runtime and the `GuildVoiceStates` gateway intent. - `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow `voice.enabled`. - `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. - `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. +- `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`. +- `voice.reconnectGraceMs` controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: `15000`. - OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. - If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)` after updating, collect a dependency report and logs. The bundled `@discordjs/voice` line includes the upstream padding fix from discord.js PR #11449, which closed discord.js issue #11419. diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 8849e6342e7..8f9aa861099 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -922,13 +922,15 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived Browser sandboxing and `sandbox.docker.binds` are Docker-only. -Build images: +Build images (from a source checkout): ```bash scripts/sandbox-setup.sh # main sandbox image scripts/sandbox-browser-setup.sh # optional browser image ``` +For npm installs without a source checkout, see [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup) for inline `docker build` commands. + ### `agents.list` (per-agent overrides) Use `agents.list[].tts` to give an agent its own TTS provider, voice, model, diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 09248e4ce32..1865f112278 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -297,6 +297,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ], daveEncryption: true, decryptionFailureTolerance: 24, + connectTimeoutMs: 30000, + reconnectGraceMs: 15000, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -339,6 +341,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. - `channels.discord.voice.model` optionally overrides the LLM model used for Discord voice channel responses. - `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default). +- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (`30000` by default). +- `channels.discord.voice.reconnectGraceMs` controls how long a disconnected voice session may take to enter reconnect signalling before OpenClaw destroys it (`15000` by default). - OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index cc3e220eeff..4f3395e63c1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -333,7 +333,7 @@ cannot roll back unrelated user settings. } ``` - Build the image first: `scripts/sandbox-setup.sh` + Build the image first — from a source checkout run `scripts/sandbox-setup.sh`, or from an npm install see the inline `docker build` command in [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup). See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/config-agents#agentsdefaultssandbox) for all options. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index e953635e68a..39ad7a2f3e3 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -363,31 +363,66 @@ Example (read-only source + an extra data directory): Default Docker image: `openclaw-sandbox:bookworm-slim` + +**Source checkout vs npm install** + +The `scripts/sandbox-setup.sh`, `scripts/sandbox-common-setup.sh`, and `scripts/sandbox-browser-setup.sh` helper scripts are only available when running from a [source checkout](https://github.com/openclaw/openclaw). They are not included in the npm package. + +If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker build` commands shown below instead. + + + From a source checkout: + ```bash scripts/sandbox-setup.sh ``` + From an npm install (no source checkout needed): + + ```bash + docker build -t openclaw-sandbox:bookworm-slim - <<'DOCKERFILE' + FROM debian:bookworm-slim + ENV DEBIAN_FRONTEND=noninteractive + RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git jq python3 ripgrep \ + && rm -rf /var/lib/apt/lists/* + RUN useradd --create-home --shell /bin/bash sandbox + USER sandbox + WORKDIR /home/sandbox + CMD ["sleep", "infinity"] + DOCKERFILE + ``` + The default image does **not** include Node. If a skill needs Node (or other runtimes), either bake a custom image or install via `sandbox.docker.setupCommand` (requires network egress + writable root + root user). - OpenClaw does not silently substitute plain `debian:bookworm-slim` when `openclaw-sandbox:bookworm-slim` is missing. Sandbox runs that target the default image fail fast with a build instruction until you run `scripts/sandbox-setup.sh`, because the bundled image carries `python3` for sandbox write/edit helpers. + OpenClaw does not silently substitute plain `debian:bookworm-slim` when `openclaw-sandbox:bookworm-slim` is missing. Sandbox runs that target the default image fail fast with a build instruction until you build it, because the bundled image carries `python3` for sandbox write/edit helpers. For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`): + From a source checkout: + ```bash scripts/sandbox-common-setup.sh ``` + From an npm install, build the default image first (see above), then build the common image on top using the [`Dockerfile.sandbox-common`](https://github.com/openclaw/openclaw/blob/main/Dockerfile.sandbox-common) from the repository. + Then set `agents.defaults.sandbox.docker.image` to `openclaw-sandbox-common:bookworm-slim`. + From a source checkout: + ```bash scripts/sandbox-browser-setup.sh ``` + + From an npm install, build using the [`Dockerfile.sandbox-browser`](https://github.com/openclaw/openclaw/blob/main/Dockerfile.sandbox-browser) from the repository. + diff --git a/docs/install/ansible.md b/docs/install/ansible.md index b9dcc686d52..732d302e322 100644 --- a/docs/install/ansible.md +++ b/docs/install/ansible.md @@ -202,9 +202,11 @@ This is idempotent and safe to run multiple times. # Check sandbox image sudo docker images | grep openclaw-sandbox - # Build sandbox image if missing + # Build sandbox image if missing (requires source checkout) cd /opt/openclaw/openclaw sudo -u openclaw ./scripts/sandbox-setup.sh + # For npm installs without a source checkout, see + # https://docs.openclaw.ai/gateway/sandboxing#images-and-setup ``` diff --git a/docs/install/docker.md b/docs/install/docker.md index d7856be3c8e..d4e0864ca5a 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -452,18 +452,21 @@ For full configuration, images, security notes, and multi-agent profiles, see: } ``` -Build the default sandbox image: +Build the default sandbox image (from a source checkout): ```bash scripts/sandbox-setup.sh ``` +For npm installs without a source checkout, see [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup) for inline `docker build` commands. + ## Troubleshooting Build the sandbox image with [`scripts/sandbox-setup.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/sandbox-setup.sh) + (source checkout) or the inline `docker build` command from [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup) (npm install), or set `agents.defaults.sandbox.docker.image` to your custom image. Containers are auto-created per session on demand. diff --git a/extensions/amazon-bedrock-mantle/discovery.ts b/extensions/amazon-bedrock-mantle/discovery.ts index 40830e95cf4..b222fa30b8c 100644 --- a/extensions/amazon-bedrock-mantle/discovery.ts +++ b/extensions/amazon-bedrock-mantle/discovery.ts @@ -51,8 +51,8 @@ function isSupportedRegion(region: string): boolean { // Bearer token resolution // --------------------------------------------------------------------------- -export type MantleBearerTokenProvider = () => Promise; -export type MantleBearerTokenProviderFactory = (opts?: { +type MantleBearerTokenProvider = () => Promise; +type MantleBearerTokenProviderFactory = (opts?: { region?: string; expiresInSeconds?: number; }) => MantleBearerTokenProvider; diff --git a/extensions/amazon-bedrock/embedding-provider.ts b/extensions/amazon-bedrock/embedding-provider.ts index b2e92db5ca1..143506a94aa 100644 --- a/extensions/amazon-bedrock/embedding-provider.ts +++ b/extensions/amazon-bedrock/embedding-provider.ts @@ -10,7 +10,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim // Types & constants // --------------------------------------------------------------------------- -export type BedrockEmbeddingClient = { +type BedrockEmbeddingClient = { region: string; model: string; dimensions?: number; @@ -162,7 +162,7 @@ async function loadCredentialProviderSdk(): Promise( +function normalizeAnthropicProviderConfig( providerConfig: T, ): T { if ( diff --git a/extensions/anthropic/provider-discovery.ts b/extensions/anthropic/provider-discovery.ts index 01279cc3bf6..49fd584865c 100644 --- a/extensions/anthropic/provider-discovery.ts +++ b/extensions/anthropic/provider-discovery.ts @@ -23,7 +23,7 @@ function resolveClaudeCliSyntheticAuth() { }; } -export const anthropicProviderDiscovery: ProviderPlugin = { +const anthropicProviderDiscovery: ProviderPlugin = { id: CLAUDE_CLI_BACKEND_ID, label: "Claude CLI", docsPath: "/providers/models", diff --git a/extensions/azure-speech/tts.ts b/extensions/azure-speech/tts.ts index 5ac8df3a460..9c98211bdcc 100644 --- a/extensions/azure-speech/tts.ts +++ b/extensions/azure-speech/tts.ts @@ -12,7 +12,7 @@ export const DEFAULT_AZURE_SPEECH_AUDIO_FORMAT = "audio-24khz-48kbitrate-mono-mp export const DEFAULT_AZURE_SPEECH_VOICE_NOTE_FORMAT = "ogg-24khz-16bit-mono-opus"; export const DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT = "raw-8khz-8bit-mono-mulaw"; -export type AzureSpeechVoiceEntry = { +type AzureSpeechVoiceEntry = { ShortName?: string; DisplayName?: string; LocalName?: string; @@ -52,11 +52,11 @@ function azureSpeechUrl(params: { return `${baseUrl}${params.path}`; } -export function escapeXmlText(text: string): string { +function escapeXmlText(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } -export function escapeXmlAttr(value: string): string { +function escapeXmlAttr(value: string): string { return escapeXmlText(value).replace(/"/g, """).replace(/'/g, "'"); } diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts index adad8833b6c..a7c3beabaf4 100644 --- a/extensions/browser/src/browser/server.control-server.test-harness.ts +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -23,7 +23,6 @@ type HarnessState = { attachOnly?: boolean; } >; - createTargetId: string | null; prevGatewayPort: string | undefined; prevGatewayToken: string | undefined; prevGatewayPassword: string | undefined; @@ -37,7 +36,6 @@ const state: HarnessState = { cfgEvaluateEnabled: true, cfgDefaultProfile: "openclaw", cfgProfiles: {}, - createTargetId: null, prevGatewayPort: undefined, prevGatewayToken: undefined, prevGatewayPassword: undefined, @@ -59,14 +57,6 @@ export function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort; } -export function setBrowserControlServerCreateTargetId(targetId: string | null): void { - state.createTargetId = targetId; -} - -export function setBrowserControlServerAttachOnly(attachOnly: boolean): void { - state.cfgAttachOnly = attachOnly; -} - export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void { state.cfgEvaluateEnabled = enabled; } @@ -360,10 +350,6 @@ const chromeMcpMocks = vi.hoisted(() => ({ uploadChromeMcpFile: vi.fn(async () => {}), })); -export function getChromeMcpMocks(): Record { - return chromeMcpMocks as unknown as Record; -} - const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" })); installChromeUserDataDirHooks(chromeUserDataDir); @@ -435,10 +421,6 @@ vi.mock("../config/config.js", async () => { const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>); -export function getLaunchCalls() { - return launchCalls; -} - vi.mock("./chrome.js", () => ({ isChromeCdpReady: vi.fn(async () => state.reachable), isChromeReachable: vi.fn(async () => state.reachable), @@ -535,7 +517,6 @@ export async function resetBrowserControlServerTestContext(): Promise { state.cfgEvaluateEnabled = true; state.cfgDefaultProfile = "openclaw"; state.cfgProfiles = defaultProfilesForState(state.testPort); - state.createTargetId = null; mockClearAll(pwMocks); mockClearAll(cdpMocks); @@ -583,9 +564,6 @@ export function installBrowserControlServerHooks() { beforeEach(async () => { vi.useRealTimers(); cdpMocks.createTargetViaCdp.mockImplementation(async () => { - if (state.createTargetId) { - return { targetId: state.createTargetId }; - } throw new Error("cdp disabled"); }); diff --git a/extensions/byteplus/provider-discovery.ts b/extensions/byteplus/provider-discovery.ts index c761f07d887..dae2e3756e5 100644 --- a/extensions/byteplus/provider-discovery.ts +++ b/extensions/byteplus/provider-discovery.ts @@ -1,7 +1,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; -export const bytePlusProviderDiscovery: ProviderPlugin[] = [ +const bytePlusProviderDiscovery: ProviderPlugin[] = [ { id: "byteplus", label: "BytePlus", diff --git a/extensions/cloudflare-ai-gateway/catalog-provider.ts b/extensions/cloudflare-ai-gateway/catalog-provider.ts index d64981ab0ed..05fd642c08d 100644 --- a/extensions/cloudflare-ai-gateway/catalog-provider.ts +++ b/extensions/cloudflare-ai-gateway/catalog-provider.ts @@ -8,7 +8,7 @@ import { resolveCloudflareAiGatewayBaseUrl, } from "./models.js"; -export type CloudflareAiGatewayCredential = +type CloudflareAiGatewayCredential = | { type?: string; keyRef?: unknown; @@ -20,9 +20,7 @@ export type CloudflareAiGatewayCredential = } | undefined; -export function resolveCloudflareAiGatewayApiKey( - cred: CloudflareAiGatewayCredential, -): string | undefined { +function resolveCloudflareAiGatewayApiKey(cred: CloudflareAiGatewayCredential): string | undefined { if (!cred || cred.type !== "api_key") { return undefined; } @@ -35,7 +33,7 @@ export function resolveCloudflareAiGatewayApiKey( return normalizeOptionalString(cred.key); } -export function resolveCloudflareAiGatewayMetadata(cred: CloudflareAiGatewayCredential): { +function resolveCloudflareAiGatewayMetadata(cred: CloudflareAiGatewayCredential): { accountId?: string; gatewayId?: string; } { diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index bb4791e6f44..900c9759264 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -39,11 +39,11 @@ const DEFAULT_TIMEOUT_MS = 5 * 60_000; export const DEFAULT_COMFY_MODEL = "workflow"; -export type ComfyMode = "local" | "cloud"; -export type ComfyCapability = "image" | "music" | "video"; -export type ComfyOutputKind = "audio" | "gifs" | "images" | "videos"; -export type ComfyWorkflow = Record; -export type ComfyProviderConfig = Record; +type ComfyMode = "local" | "cloud"; +type ComfyCapability = "image" | "music" | "video"; +type ComfyOutputKind = "audio" | "gifs" | "images" | "videos"; +type ComfyWorkflow = Record; +type ComfyProviderConfig = Record; type ComfyFetchGuardParams = Parameters[0]; type ComfyDispatcherPolicy = ComfyFetchGuardParams["dispatcherPolicy"]; type ComfyPromptResponse = { @@ -84,20 +84,20 @@ type ComfyApiKeyResolution = status: "configured_unavailable"; }; -export type ComfySourceImage = { +type ComfySourceImage = { buffer: Buffer; mimeType: string; fileName?: string; }; -export type ComfyGeneratedAsset = { +type ComfyGeneratedAsset = { buffer: Buffer; mimeType: string; fileName: string; nodeId: string; }; -export type ComfyWorkflowResult = { +type ComfyWorkflowResult = { assets: ComfyGeneratedAsset[]; model: string; promptId: string; @@ -137,7 +137,7 @@ function stripNestedCapabilityConfig(config: ComfyProviderConfig): ComfyProvider return next; } -export function getComfyCapabilityConfig( +function getComfyCapabilityConfig( config: ComfyProviderConfig, capability: ComfyCapability, ): ComfyProviderConfig { @@ -149,7 +149,7 @@ export function getComfyCapabilityConfig( return { ...shared, ...nested }; } -export function resolveComfyMode(config: ComfyProviderConfig): ComfyMode { +function resolveComfyMode(config: ComfyProviderConfig): ComfyMode { return normalizeOptionalString(config.mode) === "cloud" ? "cloud" : "local"; } diff --git a/extensions/deepinfra/provider-models.ts b/extensions/deepinfra/provider-models.ts index 35ad6ced284..817f22b848c 100644 --- a/extensions/deepinfra/provider-models.ts +++ b/extensions/deepinfra/provider-models.ts @@ -10,8 +10,8 @@ export const DEEPINFRA_MODELS_URL = `${DEEPINFRA_BASE_URL}/models?sort_by=opencl export const DEEPINFRA_DEFAULT_MODEL_ID = "deepseek-ai/DeepSeek-V3.2"; export const DEEPINFRA_DEFAULT_MODEL_REF = `deepinfra/${DEEPINFRA_DEFAULT_MODEL_ID}`; -export const DEEPINFRA_DEFAULT_CONTEXT_WINDOW = 128000; -export const DEEPINFRA_DEFAULT_MAX_TOKENS = 8192; +const DEEPINFRA_DEFAULT_CONTEXT_WINDOW = 128000; +const DEEPINFRA_DEFAULT_MAX_TOKENS = 8192; export const DEEPINFRA_MODEL_CATALOG: ModelDefinitionConfig[] = [ { diff --git a/extensions/deepseek/onboard.ts b/extensions/deepseek/onboard.ts index f66ac65f527..897d1d1065c 100644 --- a/extensions/deepseek/onboard.ts +++ b/extensions/deepseek/onboard.ts @@ -7,7 +7,7 @@ import { buildDeepSeekModelDefinition, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL_CATALOG export const DEEPSEEK_DEFAULT_MODEL_REF = "deepseek/deepseek-v4-flash"; -export function applyDeepSeekProviderConfig(cfg: OpenClawConfig): OpenClawConfig { +function applyDeepSeekProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[DEEPSEEK_DEFAULT_MODEL_REF] = { ...models[DEEPSEEK_DEFAULT_MODEL_REF], diff --git a/extensions/deepseek/provider-discovery.ts b/extensions/deepseek/provider-discovery.ts index 27b17275089..cc00e65de85 100644 --- a/extensions/deepseek/provider-discovery.ts +++ b/extensions/deepseek/provider-discovery.ts @@ -1,7 +1,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildDeepSeekProvider } from "./provider-catalog.js"; -export const deepSeekProviderDiscovery: ProviderPlugin = { +const deepSeekProviderDiscovery: ProviderPlugin = { id: "deepseek", label: "DeepSeek", docsPath: "/providers/deepseek", diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index c06a2c72585..ce8c62b2517 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -147,6 +147,29 @@ describe("discord config schema", () => { expect(cfg.voice?.model).toBe("openai/gpt-5.4-mini"); }); + it("accepts Discord voice timing overrides", () => { + const cfg = expectValidDiscordConfig({ + voice: { + connectTimeoutMs: 45_000, + reconnectGraceMs: 20_000, + }, + }); + + expect(cfg.voice?.connectTimeoutMs).toBe(45_000); + expect(cfg.voice?.reconnectGraceMs).toBe(20_000); + }); + + it("rejects invalid Discord voice timing overrides", () => { + for (const voice of [ + { connectTimeoutMs: 0 }, + { connectTimeoutMs: 120_001 }, + { reconnectGraceMs: -1 }, + { reconnectGraceMs: 1.5 }, + ]) { + expectInvalidDiscordConfig({ voice }); + } + }); + it("coerces safe-integer numeric allowlist entries to strings", () => { const cfg = expectValidDiscordConfig({ allowFrom: [123], diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 48dd76dc98c..d40a1676240 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -161,6 +161,14 @@ export const discordChannelConfigUiHints = { label: "Discord Voice Decrypt Failure Tolerance", help: "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", }, + "voice.connectTimeoutMs": { + label: "Discord Voice Connect Timeout (ms)", + help: "Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000.", + }, + "voice.reconnectGraceMs": { + label: "Discord Voice Reconnect Grace (ms)", + help: "Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000.", + }, "voice.tts": { label: "Discord Voice Text-to-Speech", help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).", diff --git a/extensions/discord/src/voice/access.test.ts b/extensions/discord/src/voice/access.test.ts index 3250c322309..dbcfb117649 100644 --- a/extensions/discord/src/voice/access.test.ts +++ b/extensions/discord/src/voice/access.test.ts @@ -62,7 +62,8 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); + expect(access.ok && access.channelConfig?.users).toEqual(["discord:u-owner"]); }); it("allows slug-keyed guild configs when manager context only has guild name", async () => { @@ -91,7 +92,7 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); }); it("allows wildcard guild configs when only the guild id is available", async () => { @@ -119,7 +120,7 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); }); it("blocks commands when channel id is unavailable for an allowlisted channel", async () => { @@ -211,6 +212,6 @@ describe("authorizeDiscordVoiceIngress", () => { }, }); - expect(access).toEqual({ ok: true }); + expect(access).toMatchObject({ ok: true }); }); }); diff --git a/extensions/discord/src/voice/access.ts b/extensions/discord/src/voice/access.ts index ad07267ebfd..fa895b5685b 100644 --- a/extensions/discord/src/voice/access.ts +++ b/extensions/discord/src/voice/access.ts @@ -6,6 +6,7 @@ import type { Guild } from "../internal/discord.js"; import { isDiscordGroupAllowedByPolicy, resolveDiscordChannelConfigWithFallback, + type DiscordChannelConfigResolved, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, @@ -30,7 +31,9 @@ export async function authorizeDiscordVoiceIngress(params: { memberRoleIds: string[]; ownerAllowFrom?: string[]; sender: { id: string; name?: string; tag?: string }; -}): Promise<{ ok: true } | { ok: false; message: string }> { +}): Promise< + { ok: true; channelConfig?: DiscordChannelConfigResolved | null } | { ok: false; message: string } +> { const groupPolicy = params.groupPolicy ?? resolveOpenProviderRuntimeGroupPolicy({ @@ -116,6 +119,6 @@ export async function authorizeDiscordVoiceIngress(params: { authorizers, modeWhenAccessGroupsOff: "configured", }) - ? { ok: true } + ? { ok: true, channelConfig } : { ok: false, message: "You are not authorized to use this command." }; } diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 2aeeda93c69..b886cb6c441 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -347,14 +347,63 @@ describe("DiscordVoiceManager", () => { ); }); - it("keeps the shorter timeout for initial voice connection readiness", async () => { + it("uses the default timeout for initial voice connection readiness", async () => { const connection = createConnectionMock(); joinVoiceChannelMock.mockReturnValueOnce(connection); const manager = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); - expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 15_000); + expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 30_000); + }); + + it("uses configured voice connection and reconnect timeouts", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager({ + voice: { + connectTimeoutMs: 45_000, + reconnectGraceMs: 20_000, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 45_000); + + entersStateMock.mockClear(); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + + const disconnected = connection.handlers.get("disconnected"); + expect(disconnected).toBeTypeOf("function"); + await disconnected?.(); + + expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 20_000); + expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 20_000); + expect(connection.destroy).toHaveBeenCalledTimes(1); + expect(manager.status()).toEqual([]); + }); + + it("uses the default reconnect grace before destroying disconnected sessions", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager(); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + entersStateMock.mockClear(); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + + const disconnected = connection.handlers.get("disconnected"); + expect(disconnected).toBeTypeOf("function"); + await disconnected?.(); + + expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 15_000); + expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 15_000); + expect(connection.destroy).toHaveBeenCalledTimes(1); + expect(manager.status()).toEqual([]); }); it("stores guild metadata on joined voice sessions", async () => { @@ -574,6 +623,44 @@ describe("DiscordVoiceManager", () => { ); }); + it("passes per-channel system prompt overrides to voice agent runs", async () => { + const client = createClient(); + client.fetchMember.mockResolvedValue({ + nickname: "Guest Nick", + user: { + id: "u-guest", + username: "guest", + globalName: "Guest", + discriminator: "4321", + }, + }); + const manager = createManager( + { + groupPolicy: "open", + guilds: { + g1: { + channels: { + "1001": { + systemPrompt: " Use short voice replies. ", + }, + }, + }, + }, + }, + client, + { + commands: { useAccessGroups: false }, + }, + ); + await processVoiceSegment(manager, "u-guest"); + + const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as + | { extraSystemPrompt?: string } + | undefined; + + expect(commandArgs?.extraSystemPrompt).toBe("Use short voice replies."); + }); + it("reuses speaker context cache for repeated segments from the same speaker", async () => { const client = createClient(); client.fetchMember.mockResolvedValue({ diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 6bf56a7e5d3..7c9fa679a51 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -35,8 +35,10 @@ import { CAPTURE_FINALIZE_GRACE_MS, isVoiceChannel, logVoiceVerbose, + resolveVoiceTimeoutMs, MIN_SEGMENT_SECONDS, VOICE_CONNECT_READY_TIMEOUT_MS, + VOICE_RECONNECT_GRACE_MS, type VoiceOperationResult, type VoiceSessionEntry, } from "./session.js"; @@ -172,13 +174,22 @@ export class DiscordVoiceManager { return { ok: false, message: "Discord voice plugin is not available." }; } + const voiceConfig = this.params.discordConfig.voice; const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); - const daveEncryption = this.params.discordConfig.voice?.daveEncryption; - const decryptionFailureTolerance = this.params.discordConfig.voice?.decryptionFailureTolerance; + const daveEncryption = voiceConfig?.daveEncryption; + const decryptionFailureTolerance = voiceConfig?.decryptionFailureTolerance; + const connectReadyTimeoutMs = resolveVoiceTimeoutMs( + voiceConfig?.connectTimeoutMs, + VOICE_CONNECT_READY_TIMEOUT_MS, + ); + const reconnectGraceMs = resolveVoiceTimeoutMs( + voiceConfig?.reconnectGraceMs, + VOICE_RECONNECT_GRACE_MS, + ); logVoiceVerbose( `join: DAVE settings encryption=${daveEncryption === false ? "off" : "on"} tolerance=${ decryptionFailureTolerance ?? "default" - }`, + } connectTimeout=${connectReadyTimeoutMs}ms reconnectGrace=${reconnectGraceMs}ms`, ); const voiceSdk = loadDiscordVoiceSdk(); const connection = voiceSdk.joinVoiceChannel({ @@ -195,10 +206,13 @@ export class DiscordVoiceManager { await voiceSdk.entersState( connection, voiceSdk.VoiceConnectionStatus.Ready, - VOICE_CONNECT_READY_TIMEOUT_MS, + connectReadyTimeoutMs, ); logVoiceVerbose(`join: connected to guild ${guildId} channel ${channelId}`); } catch (err) { + logger.warn( + `discord voice: join failed before ready: guild ${guildId} channel ${channelId} timeout=${connectReadyTimeoutMs}ms error=${formatErrorMessage(err)}`, + ); connection.destroy(); return { ok: false, message: `Failed to join voice channel: ${formatErrorMessage(err)}` }; } @@ -289,11 +303,26 @@ export class DiscordVoiceManager { disconnectedHandler = async () => { try { + logVoiceVerbose( + `disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`, + ); await Promise.race([ - voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Signalling, 5_000), - voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Connecting, 5_000), + voiceSdk.entersState( + connection, + voiceSdk.VoiceConnectionStatus.Signalling, + reconnectGraceMs, + ), + voiceSdk.entersState( + connection, + voiceSdk.VoiceConnectionStatus.Connecting, + reconnectGraceMs, + ), ]); - } catch { + logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`); + } catch (err) { + logger.warn( + `discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`, + ); clearSessionIfCurrent(); connection.destroy(); } diff --git a/extensions/discord/src/voice/segment.ts b/extensions/discord/src/voice/segment.ts index 718db850690..68ee775231a 100644 --- a/extensions/discord/src/voice/segment.ts +++ b/extensions/discord/src/voice/segment.ts @@ -6,6 +6,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { formatMention } from "../mentions.js"; import { normalizeDiscordSlug } from "../monitor/allow-list.js"; +import { buildDiscordGroupSystemPrompt } from "../monitor/inbound-context.js"; import { authorizeDiscordVoiceIngress } from "./access.js"; import { formatVoiceIngressPrompt } from "./prompt.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; @@ -82,6 +83,7 @@ export async function processDiscordVoiceSegment(params: { ); const prompt = formatVoiceIngressPrompt(transcript, speaker.label); + const extraSystemPrompt = buildDiscordGroupSystemPrompt(access.channelConfig); const modelOverride = normalizeOptionalString(params.discordConfig.voice?.model); const result = await agentCommandFromIngress( @@ -91,6 +93,7 @@ export async function processDiscordVoiceSegment(params: { agentId: entry.route.agentId, messageChannel: "discord", messageProvider: DISCORD_VOICE_MESSAGE_PROVIDER, + extraSystemPrompt, senderIsOwner: speaker.senderIsOwner, allowModelOverride: Boolean(modelOverride), model: modelOverride, diff --git a/extensions/discord/src/voice/session.ts b/extensions/discord/src/voice/session.ts index 5e2f18d5c96..4ed98b7f946 100644 --- a/extensions/discord/src/voice/session.ts +++ b/extensions/discord/src/voice/session.ts @@ -6,10 +6,18 @@ import type { VoiceReceiveRecoveryState } from "./receive-recovery.js"; export const MIN_SEGMENT_SECONDS = 0.35; export const CAPTURE_FINALIZE_GRACE_MS = 1_200; -export const VOICE_CONNECT_READY_TIMEOUT_MS = 15_000; +export const VOICE_CONNECT_READY_TIMEOUT_MS = 30_000; +export const VOICE_RECONNECT_GRACE_MS = 15_000; export const PLAYBACK_READY_TIMEOUT_MS = 60_000; export const SPEAKING_READY_TIMEOUT_MS = 60_000; +export function resolveVoiceTimeoutMs(value: number | undefined, fallbackMs: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallbackMs; + } + return Math.floor(value); +} + export type VoiceOperationResult = { ok: boolean; message: string; diff --git a/extensions/google/embedding-batch.ts b/extensions/google/embedding-batch.ts index 0f74174579f..c00be8f800a 100644 --- a/extensions/google/embedding-batch.ts +++ b/extensions/google/embedding-batch.ts @@ -19,12 +19,12 @@ type EmbeddingBatchExecutionParams = { debug?: (message: string, data?: Record) => void; }; -export type GeminiBatchRequest = { +type GeminiBatchRequest = { custom_id: string; request: GeminiTextEmbeddingRequest; }; -export type GeminiBatchStatus = { +type GeminiBatchStatus = { name?: string; state?: string; outputConfig?: { file?: string; fileId?: string }; @@ -36,7 +36,7 @@ export type GeminiBatchStatus = { error?: { message?: string }; }; -export type GeminiBatchOutputLine = { +type GeminiBatchOutputLine = { key?: string; custom_id?: string; request_id?: string; diff --git a/extensions/google/embedding-provider.ts b/extensions/google/embedding-provider.ts index d01eb569238..118f585266a 100644 --- a/extensions/google/embedding-provider.ts +++ b/extensions/google/embedding-provider.ts @@ -37,7 +37,7 @@ const GEMINI_MAX_INPUT_TOKENS: Record = { "gemini-embedding-2-preview": 8192, }; -export type GeminiTaskType = NonNullable; +type GeminiTaskType = NonNullable; // --- gemini-embedding-2-preview support --- @@ -49,12 +49,12 @@ export const GEMINI_EMBEDDING_2_MODELS = new Set([ const GEMINI_EMBEDDING_2_DEFAULT_DIMENSIONS = 3072; const GEMINI_EMBEDDING_2_VALID_DIMENSIONS = [768, 1536, 3072] as const; -export type GeminiTextPart = { text: string }; -export type GeminiInlinePart = { +type GeminiTextPart = { text: string }; +type GeminiInlinePart = { inlineData: { mimeType: string; data: string }; }; -export type GeminiPart = GeminiTextPart | GeminiInlinePart; -export type GeminiEmbeddingRequest = { +type GeminiPart = GeminiTextPart | GeminiInlinePart; +type GeminiEmbeddingRequest = { content: { parts: GeminiPart[] }; taskType: GeminiTaskType; outputDimensionality?: number; @@ -305,7 +305,7 @@ export async function createGeminiEmbeddingProvider( }; } -export async function resolveGeminiEmbeddingClient( +async function resolveGeminiEmbeddingClient( options: MemoryEmbeddingProviderCreateOptions, ): Promise { const remote = options.remote; diff --git a/extensions/google/google-genai-runtime.ts b/extensions/google/google-genai-runtime.ts index 96875c183bb..b02a09c8beb 100644 --- a/extensions/google/google-genai-runtime.ts +++ b/extensions/google/google-genai-runtime.ts @@ -1,7 +1,7 @@ import { GoogleGenAI } from "@google/genai"; export type GoogleGenAIClient = InstanceType; -export type GoogleGenAIOptions = ConstructorParameters[0]; +type GoogleGenAIOptions = ConstructorParameters[0]; export function createGoogleGenAI(options: GoogleGenAIOptions): GoogleGenAIClient { return new GoogleGenAI(options); diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index cc67a3535ce..4afed379c03 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -791,12 +791,3 @@ export function buildGoogleRealtimeVoiceProvider(): RealtimeVoiceProviderPlugin createBrowserSession: createGoogleRealtimeBrowserSession, }; } - -export { - GOOGLE_REALTIME_DEFAULT_API_VERSION, - GOOGLE_REALTIME_DEFAULT_MODEL, - GOOGLE_REALTIME_DEFAULT_VOICE, - GOOGLE_REALTIME_BROWSER_API_VERSION, - GOOGLE_REALTIME_BROWSER_WEBSOCKET_URL, -}; -export type { GoogleRealtimeVoiceProviderConfig }; diff --git a/extensions/gradium/shared.ts b/extensions/gradium/shared.ts index f957990136b..b21e6b19199 100644 --- a/extensions/gradium/shared.ts +++ b/extensions/gradium/shared.ts @@ -1,4 +1,4 @@ -export const DEFAULT_GRADIUM_BASE_URL = "https://api.gradium.ai"; +const DEFAULT_GRADIUM_BASE_URL = "https://api.gradium.ai"; export const DEFAULT_GRADIUM_VOICE_ID = "YTpq7expH9539ERJ"; export const GRADIUM_VOICES = [ diff --git a/extensions/inworld/tts.ts b/extensions/inworld/tts.ts index e5009d1e8b5..3261f63073d 100644 --- a/extensions/inworld/tts.ts +++ b/extensions/inworld/tts.ts @@ -1,7 +1,7 @@ import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -export const DEFAULT_INWORLD_BASE_URL = "https://api.inworld.ai"; +const DEFAULT_INWORLD_BASE_URL = "https://api.inworld.ai"; export const DEFAULT_INWORLD_VOICE_ID = "Sarah"; export const DEFAULT_INWORLD_MODEL_ID = "inworld-tts-1.5-max"; diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts index 68dd169c5f1..4553879d7f5 100644 --- a/extensions/kimi-coding/provider-catalog.ts +++ b/extensions/kimi-coding/provider-catalog.ts @@ -1,9 +1,9 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -export const KIMI_BASE_URL = "https://api.kimi.com/coding/"; +const KIMI_BASE_URL = "https://api.kimi.com/coding/"; const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; -export const KIMI_DEFAULT_MODEL_ID = "kimi-code"; -export const KIMI_LEGACY_MODEL_ID = "k2p5"; +const KIMI_DEFAULT_MODEL_ID = "kimi-code"; +const KIMI_LEGACY_MODEL_ID = "k2p5"; const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; const KIMI_CODING_DEFAULT_COST = { diff --git a/extensions/migrate-claude/helpers.ts b/extensions/migrate-claude/helpers.ts index c47cdd44c7b..a93339f2617 100644 --- a/extensions/migrate-claude/helpers.ts +++ b/extensions/migrate-claude/helpers.ts @@ -80,17 +80,6 @@ export function childRecord( return isRecord(value) ? value : {}; } -export function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -export function readStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== ""); -} - export async function appendItem(item: MigrationItem): Promise { if (!item.source || !item.target) { return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET); diff --git a/extensions/mistral/embedding-provider.ts b/extensions/mistral/embedding-provider.ts index 4ef3c25d5a2..7635e9a64b0 100644 --- a/extensions/mistral/embedding-provider.ts +++ b/extensions/mistral/embedding-provider.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -export type MistralEmbeddingClient = { +type MistralEmbeddingClient = { baseUrl: string; headers: Record; ssrfPolicy?: SsrFPolicy; @@ -17,7 +17,7 @@ export type MistralEmbeddingClient = { export const DEFAULT_MISTRAL_EMBEDDING_MODEL = "mistral-embed"; const DEFAULT_MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; -export function normalizeMistralModel(model: string): string { +function normalizeMistralModel(model: string): string { return normalizeEmbeddingModelWithPrefixes({ model, defaultModel: DEFAULT_MISTRAL_EMBEDDING_MODEL, @@ -40,7 +40,7 @@ export async function createMistralEmbeddingProvider( }; } -export async function resolveMistralEmbeddingClient( +async function resolveMistralEmbeddingClient( options: MemoryEmbeddingProviderCreateOptions, ): Promise { return await resolveRemoteEmbeddingClient({ diff --git a/extensions/moonshot/media-understanding-provider.ts b/extensions/moonshot/media-understanding-provider.ts index 861feb53329..bfa1c4f8761 100644 --- a/extensions/moonshot/media-understanding-provider.ts +++ b/extensions/moonshot/media-understanding-provider.ts @@ -16,7 +16,7 @@ import { } from "openclaw/plugin-sdk/provider-http"; import { MOONSHOT_DEFAULT_MODEL_ID } from "./provider-catalog.js"; -export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1"; +const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1"; const DEFAULT_MOONSHOT_VIDEO_MODEL = MOONSHOT_DEFAULT_MODEL_ID; const DEFAULT_MOONSHOT_VIDEO_PROMPT = "Describe the video."; diff --git a/extensions/moonshot/provider-discovery.ts b/extensions/moonshot/provider-discovery.ts index c9590b13144..751e15ae713 100644 --- a/extensions/moonshot/provider-discovery.ts +++ b/extensions/moonshot/provider-discovery.ts @@ -1,7 +1,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildMoonshotProvider } from "./provider-catalog.js"; -export const moonshotProviderDiscovery: ProviderPlugin = { +const moonshotProviderDiscovery: ProviderPlugin = { id: "moonshot", label: "Moonshot", docsPath: "/providers/moonshot", diff --git a/extensions/openai/embedding-batch.ts b/extensions/openai/embedding-batch.ts index f26707cbb4d..0b1366c1019 100644 --- a/extensions/openai/embedding-batch.ts +++ b/extensions/openai/embedding-batch.ts @@ -27,7 +27,7 @@ type EmbeddingBatchExecutionParams = { debug?: (message: string, data?: Record) => void; }; -export type OpenAiBatchRequest = { +type OpenAiBatchRequest = { custom_id: string; method: "POST"; url: "/v1/embeddings"; @@ -37,8 +37,8 @@ export type OpenAiBatchRequest = { }; }; -export type OpenAiBatchStatus = EmbeddingBatchStatus; -export type OpenAiBatchOutputLine = ProviderBatchOutputLine; +type OpenAiBatchStatus = EmbeddingBatchStatus; +type OpenAiBatchOutputLine = ProviderBatchOutputLine; export const OPENAI_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT; const OPENAI_BATCH_COMPLETION_WINDOW = "24h"; diff --git a/extensions/openai/embedding-provider.ts b/extensions/openai/embedding-provider.ts index 0df74f9da3e..94a30383541 100644 --- a/extensions/openai/embedding-provider.ts +++ b/extensions/openai/embedding-provider.ts @@ -26,7 +26,7 @@ const OPENAI_MAX_INPUT_TOKENS: Record = { "text-embedding-ada-002": 8191, }; -export function normalizeOpenAiModel(model: string): string { +function normalizeOpenAiModel(model: string): string { const trimmed = model.trim(); if (!trimmed) { return DEFAULT_OPENAI_EMBEDDING_MODEL; @@ -82,7 +82,7 @@ export async function createOpenAiEmbeddingProvider( }; } -export async function resolveOpenAiEmbeddingClient( +async function resolveOpenAiEmbeddingClient( options: MemoryEmbeddingProviderCreateOptions, ): Promise { const client = await resolveRemoteEmbeddingClient({ diff --git a/extensions/openai/media-understanding-provider.ts b/extensions/openai/media-understanding-provider.ts index 5de21d15b1d..ae009af6067 100644 --- a/extensions/openai/media-understanding-provider.ts +++ b/extensions/openai/media-understanding-provider.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/media-understanding"; import { OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL } from "./default-models.js"; -export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; +const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; export async function transcribeOpenAiAudio(params: AudioTranscriptionRequest) { return await transcribeOpenAiCompatibleAudio({ diff --git a/extensions/openai/native-web-search.ts b/extensions/openai/native-web-search.ts index c1c7abc74d2..f26f8c87022 100644 --- a/extensions/openai/native-web-search.ts +++ b/extensions/openai/native-web-search.ts @@ -7,7 +7,7 @@ import { isOpenAIApiBaseUrl } from "./base-url.js"; const OPENAI_WEB_SEARCH_TOOL = { type: "web_search" } as const; -export type OpenAINativeWebSearchPatchResult = +type OpenAINativeWebSearchPatchResult = | "payload_not_object" | "native_tool_already_present" | "injected"; @@ -38,7 +38,7 @@ function shouldUseOpenAINativeWebSearchProvider(config: OpenClawConfig | undefin return normalized === "" || normalized === "auto" || normalized === "openai"; } -export function shouldEnableOpenAINativeWebSearch(params: { +function shouldEnableOpenAINativeWebSearch(params: { config?: OpenClawConfig; model: { api?: unknown; provider?: unknown; baseUrl?: unknown }; }): boolean { @@ -65,9 +65,7 @@ function raiseMinimalReasoningForOpenAINativeWebSearch(payload: Record 0 ? chunks : [text]; +} + // Shared promise so concurrent multi-account startups serialize the dynamic // import of the gateway module, avoiding an ESM circular-dependency race. let _gatewayModulePromise: Promise | undefined; diff --git a/extensions/qqbot/src/engine/utils/text-chunk.ts b/extensions/qqbot/src/engine/utils/text-chunk.ts deleted file mode 100644 index c13e1a3c5e5..00000000000 --- a/extensions/qqbot/src/engine/utils/text-chunk.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Text chunking constants and fallback. - * - * The actual chunking logic is provided by the framework runtime - * (`runtime.channel.text.chunkMarkdownText`) and injected via the - * outbound dispatch pipeline — NOT via a global singleton. - * - * This module only exports the chunk limit constant and a naive - * fallback splitter for edge cases outside the pipeline. - */ - -/** Maximum text length for a single QQ Bot message. */ -export const TEXT_CHUNK_LIMIT = 5000; - -/** - * Naive text chunking fallback. - * - * Used only by code outside the outbound pipeline that needs a - * simple split. The real markdown-aware chunking is always done - * via `runtime.channel.text.chunkMarkdownText` inside the pipeline. - */ -export function chunkText(text: string, limit: number = TEXT_CHUNK_LIMIT): string[] { - const chunks: string[] = []; - for (let i = 0; i < text.length; i += limit) { - chunks.push(text.slice(i, i + limit)); - } - return chunks.length > 0 ? chunks : [text]; -} diff --git a/extensions/qwen/media-understanding-provider.ts b/extensions/qwen/media-understanding-provider.ts index 582975f9783..544409d235f 100644 --- a/extensions/qwen/media-understanding-provider.ts +++ b/extensions/qwen/media-understanding-provider.ts @@ -14,33 +14,11 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; -import { QWEN_STANDARD_CN_BASE_URL, QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js"; +import { QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js"; const DEFAULT_QWEN_VIDEO_MODEL = "qwen-vl-max-latest"; const DEFAULT_QWEN_VIDEO_PROMPT = "Describe the video in detail."; -function resolveQwenStandardBaseUrl( - cfg: { models?: { providers?: Record } } | undefined, - providerId: string, -): string { - const direct = cfg?.models?.providers?.[providerId]?.baseUrl?.trim(); - if (!direct) { - return QWEN_STANDARD_GLOBAL_BASE_URL; - } - try { - const url = new URL(direct); - if (url.hostname === "coding-intl.dashscope.aliyuncs.com") { - return QWEN_STANDARD_GLOBAL_BASE_URL; - } - if (url.hostname === "coding.dashscope.aliyuncs.com") { - return QWEN_STANDARD_CN_BASE_URL; - } - return `${url.origin}${url.pathname}`.replace(/\/+$/u, ""); - } catch { - return QWEN_STANDARD_GLOBAL_BASE_URL; - } -} - export async function describeQwenVideo( params: VideoDescriptionRequest, ): Promise { @@ -108,9 +86,3 @@ export function buildQwenMediaUnderstandingProvider(): MediaUnderstandingProvide describeVideo: describeQwenVideo, }; } - -export function resolveQwenMediaUnderstandingBaseUrl( - cfg: { models?: { providers?: Record } } | undefined, -): string { - return resolveQwenStandardBaseUrl(cfg, "qwen"); -} diff --git a/extensions/stepfun/provider-catalog.ts b/extensions/stepfun/provider-catalog.ts index b2bf1f8b7cc..83328647612 100644 --- a/extensions/stepfun/provider-catalog.ts +++ b/extensions/stepfun/provider-catalog.ts @@ -10,7 +10,7 @@ export const STEPFUN_STANDARD_INTL_BASE_URL = "https://api.stepfun.ai/v1"; export const STEPFUN_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"; export const STEPFUN_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"; -export const STEPFUN_DEFAULT_MODEL_ID = "step-3.5-flash"; +const STEPFUN_DEFAULT_MODEL_ID = "step-3.5-flash"; export const STEPFUN_FLASH_2603_MODEL_ID = "step-3.5-flash-2603"; export const STEPFUN_DEFAULT_MODEL_REF = `${STEPFUN_PROVIDER_ID}/${STEPFUN_DEFAULT_MODEL_ID}`; export const STEPFUN_PLAN_DEFAULT_MODEL_REF = `${STEPFUN_PLAN_PROVIDER_ID}/${STEPFUN_DEFAULT_MODEL_ID}`; diff --git a/extensions/synthetic/models.ts b/extensions/synthetic/models.ts index 23dc251cb59..62433289d5a 100644 --- a/extensions/synthetic/models.ts +++ b/extensions/synthetic/models.ts @@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-s export const SYNTHETIC_BASE_URL = "https://api.synthetic.new/anthropic"; export const SYNTHETIC_DEFAULT_MODEL_ID = "hf:MiniMaxAI/MiniMax-M2.5"; export const SYNTHETIC_DEFAULT_MODEL_REF = `synthetic/${SYNTHETIC_DEFAULT_MODEL_ID}`; -export const SYNTHETIC_DEFAULT_COST = { +const SYNTHETIC_DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, @@ -181,7 +181,7 @@ export const SYNTHETIC_MODEL_CATALOG = [ }, ] as const; -export type SyntheticCatalogEntry = (typeof SYNTHETIC_MODEL_CATALOG)[number]; +type SyntheticCatalogEntry = (typeof SYNTHETIC_MODEL_CATALOG)[number]; export function buildSyntheticModelDefinition(entry: SyntheticCatalogEntry): ModelDefinitionConfig { return { diff --git a/extensions/tencent/provider-discovery.ts b/extensions/tencent/provider-discovery.ts index ae8689c3920..55dc573aefd 100644 --- a/extensions/tencent/provider-discovery.ts +++ b/extensions/tencent/provider-discovery.ts @@ -1,7 +1,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildTokenHubProvider } from "./provider-catalog.js"; -export const tencentProviderDiscovery: ProviderPlugin = { +const tencentProviderDiscovery: ProviderPlugin = { id: "tencent-tokenhub", label: "Tencent TokenHub", docsPath: "/providers/models", diff --git a/extensions/together/onboard.ts b/extensions/together/onboard.ts index f9ca510a413..c822243664f 100644 --- a/extensions/together/onboard.ts +++ b/extensions/together/onboard.ts @@ -21,10 +21,6 @@ const togetherPresetAppliers = createModelCatalogPresetAppliers({ }), }); -export function applyTogetherProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return togetherPresetAppliers.applyProviderConfig(cfg); -} - export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig { return togetherPresetAppliers.applyConfig(cfg); } diff --git a/extensions/venice/models.ts b/extensions/venice/models.ts index ec945d21e80..eeb749aea0a 100644 --- a/extensions/venice/models.ts +++ b/extensions/venice/models.ts @@ -6,11 +6,11 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim const log = createSubsystemLogger("venice-models"); export const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; -export const VENICE_DEFAULT_MODEL_ID = "kimi-k2-5"; +const VENICE_DEFAULT_MODEL_ID = "kimi-k2-5"; export const VENICE_DEFAULT_MODEL_REF = `venice/${VENICE_DEFAULT_MODEL_ID}`; const VENICE_ALLOWED_HOSTNAMES = ["api.venice.ai"]; -export const VENICE_DEFAULT_COST = { +const VENICE_DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, @@ -413,7 +413,7 @@ export const VENICE_MODEL_CATALOG = [ }, ] as const; -export type VeniceCatalogEntry = (typeof VENICE_MODEL_CATALOG)[number]; +type VeniceCatalogEntry = (typeof VENICE_MODEL_CATALOG)[number]; export function buildVeniceModelDefinition(entry: VeniceCatalogEntry): ModelDefinitionConfig { return { diff --git a/extensions/venice/onboard.ts b/extensions/venice/onboard.ts index a76d6ad4ad4..11d5b42e094 100644 --- a/extensions/venice/onboard.ts +++ b/extensions/venice/onboard.ts @@ -22,10 +22,6 @@ const venicePresetAppliers = createModelCatalogPresetAppliers({ }), }); -export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return venicePresetAppliers.applyProviderConfig(cfg); -} - export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { return venicePresetAppliers.applyConfig(cfg); } diff --git a/extensions/vercel-ai-gateway/onboard.ts b/extensions/vercel-ai-gateway/onboard.ts index 5ca89c8ad33..15d7f04a45a 100644 --- a/extensions/vercel-ai-gateway/onboard.ts +++ b/extensions/vercel-ai-gateway/onboard.ts @@ -5,7 +5,7 @@ import { export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; -export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { +function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = { ...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF], diff --git a/extensions/volcengine/provider-discovery.ts b/extensions/volcengine/provider-discovery.ts index 14f878c4bd9..69d2bce04ce 100644 --- a/extensions/volcengine/provider-discovery.ts +++ b/extensions/volcengine/provider-discovery.ts @@ -1,7 +1,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; -export const volcengineProviderDiscovery: ProviderPlugin[] = [ +const volcengineProviderDiscovery: ProviderPlugin[] = [ { id: "volcengine", label: "Volcengine", diff --git a/extensions/voyage/embedding-batch.ts b/extensions/voyage/embedding-batch.ts index 0bef2d2aa25..4a90723c19b 100644 --- a/extensions/voyage/embedding-batch.ts +++ b/extensions/voyage/embedding-batch.ts @@ -26,17 +26,17 @@ import type { VoyageEmbeddingClient } from "./embedding-provider.js"; * Voyage Batch API Input Line format. * See: https://docs.voyageai.com/docs/batch-inference */ -export type VoyageBatchRequest = { +type VoyageBatchRequest = { custom_id: string; body: { input: string | string[]; }; }; -export type VoyageBatchStatus = EmbeddingBatchStatus; -export type VoyageBatchOutputLine = ProviderBatchOutputLine; +type VoyageBatchStatus = EmbeddingBatchStatus; +type VoyageBatchOutputLine = ProviderBatchOutputLine; -export const VOYAGE_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT; +const VOYAGE_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT; const VOYAGE_BATCH_COMPLETION_WINDOW = "12h"; const VOYAGE_BATCH_MAX_REQUESTS = 50000; diff --git a/extensions/voyage/embedding-provider.ts b/extensions/voyage/embedding-provider.ts index f4d218c80cc..67cc4b184e1 100644 --- a/extensions/voyage/embedding-provider.ts +++ b/extensions/voyage/embedding-provider.ts @@ -22,7 +22,7 @@ const VOYAGE_MAX_INPUT_TOKENS: Record = { "voyage-code-3": 32000, }; -export function normalizeVoyageModel(model: string): string { +function normalizeVoyageModel(model: string): string { return normalizeEmbeddingModelWithPrefixes({ model, defaultModel: DEFAULT_VOYAGE_EMBEDDING_MODEL, @@ -72,7 +72,7 @@ export async function createVoyageEmbeddingProvider( }; } -export async function resolveVoyageEmbeddingClient( +async function resolveVoyageEmbeddingClient( options: MemoryEmbeddingProviderCreateOptions, ): Promise { const { baseUrl, headers, ssrfPolicy } = await resolveRemoteEmbeddingBearerClient({ diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 3e44e2e9485..ef398217bf2 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -7,6 +7,7 @@ "@whiskeysockets/baileys": "7.0.0-rc.9", "https-proxy-agent": "^9.0.0", "jimp": "^1.6.1", + "qrcode": "1.5.4", "typebox": "1.1.34", "undici": "8.1.0" }, diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index c651c7e51ba..b29a9ac96db 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -5,11 +5,11 @@ export const XAI_BASE_URL = "https://api.x.ai/v1"; export const XAI_DEFAULT_IMAGE_MODEL = "grok-imagine-image"; export const XAI_IMAGE_MODELS = ["grok-imagine-image", "grok-imagine-image-pro"] as const; export const XAI_DEFAULT_CONTEXT_WINDOW = 256_000; -export const XAI_LARGE_CONTEXT_WINDOW = 2_000_000; -export const XAI_CODE_CONTEXT_WINDOW = 256_000; +const XAI_LARGE_CONTEXT_WINDOW = 2_000_000; +const XAI_CODE_CONTEXT_WINDOW = 256_000; export const XAI_DEFAULT_MAX_TOKENS = 64_000; -export const XAI_LEGACY_CONTEXT_WINDOW = 131_072; -export const XAI_LEGACY_MAX_TOKENS = 8_192; +const XAI_LEGACY_CONTEXT_WINDOW = 131_072; +const XAI_LEGACY_MAX_TOKENS = 8_192; export const XAI_DEFAULT_MODEL_ID = "grok-4"; export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; diff --git a/extensions/xai/provider-discovery.ts b/extensions/xai/provider-discovery.ts index 8c898ff8f37..44338906e91 100644 --- a/extensions/xai/provider-discovery.ts +++ b/extensions/xai/provider-discovery.ts @@ -16,7 +16,7 @@ function resolveXaiSyntheticAuth(config: unknown) { : undefined; } -export const xaiProviderDiscovery: ProviderPlugin = { +const xaiProviderDiscovery: ProviderPlugin = { id: PROVIDER_ID, label: "xAI", docsPath: "/providers/models", diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts index e3878bce5d8..6ed093fdf17 100644 --- a/extensions/xai/stream.ts +++ b/extensions/xai/stream.ts @@ -201,8 +201,7 @@ export function createXaiFastModeWrapper( }; } -export const createXaiToolCallArgumentDecodingWrapper = - createHtmlEntityToolCallArgumentDecodingWrapper; +const createXaiToolCallArgumentDecodingWrapper = createHtmlEntityToolCallArgumentDecodingWrapper; export function wrapXaiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined { const extraParams = ctx.extraParams; diff --git a/extensions/xai/test-helpers.ts b/extensions/xai/test-helpers.ts index 637c4b7dc22..366019f4adf 100644 --- a/extensions/xai/test-helpers.ts +++ b/extensions/xai/test-helpers.ts @@ -2,16 +2,16 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model } from "@mariozechner/pi-ai"; import { expect } from "vitest"; -export type XaiToolPayloadFunction = { +type XaiToolPayloadFunction = { function?: Record; }; -export type XaiTestPayload = Record & { +type XaiTestPayload = Record & { tools?: Array<{ type?: string; function?: Record }>; input?: unknown[]; }; -export function createXaiToolStreamPayload(): XaiTestPayload { +function createXaiToolStreamPayload(): XaiTestPayload { return { reasoning: { effort: "high" }, tools: [ diff --git a/extensions/xiaomi/speech-provider.ts b/extensions/xiaomi/speech-provider.ts index 6f8abc3785f..d650c28af76 100644 --- a/extensions/xiaomi/speech-provider.ts +++ b/extensions/xiaomi/speech-provider.ts @@ -13,14 +13,14 @@ import { ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; -export const DEFAULT_XIAOMI_TTS_BASE_URL = "https://api.xiaomimimo.com/v1"; -export const DEFAULT_XIAOMI_TTS_MODEL = "mimo-v2.5-tts"; -export const DEFAULT_XIAOMI_TTS_VOICE = "mimo_default"; -export const DEFAULT_XIAOMI_TTS_FORMAT = "mp3"; +const DEFAULT_XIAOMI_TTS_BASE_URL = "https://api.xiaomimimo.com/v1"; +const DEFAULT_XIAOMI_TTS_MODEL = "mimo-v2.5-tts"; +const DEFAULT_XIAOMI_TTS_VOICE = "mimo_default"; +const DEFAULT_XIAOMI_TTS_FORMAT = "mp3"; -export const XIAOMI_TTS_MODELS = ["mimo-v2.5-tts", "mimo-v2-tts"] as const; +const XIAOMI_TTS_MODELS = ["mimo-v2.5-tts", "mimo-v2-tts"] as const; -export const XIAOMI_TTS_VOICES = [ +const XIAOMI_TTS_VOICES = [ "mimo_default", "default_zh", "default_en", @@ -194,7 +194,7 @@ function decodeXiaomiAudioData(body: unknown): Buffer { return Buffer.from(audioData, "base64"); } -export async function xiaomiTTS(params: { +async function xiaomiTTS(params: { text: string; apiKey: string; baseUrl: string; diff --git a/extensions/zai/detect.ts b/extensions/zai/detect.ts index 6d01c0ddce7..482c383903b 100644 --- a/extensions/zai/detect.ts +++ b/extensions/zai/detect.ts @@ -8,10 +8,6 @@ type DetectZaiEndpointFn = typeof detectZaiEndpointCore; let detectZaiEndpointImpl: DetectZaiEndpointFn = detectZaiEndpointCore; -export function setDetectZaiEndpointForTesting(fn?: DetectZaiEndpointFn): void { - detectZaiEndpointImpl = fn ?? detectZaiEndpointCore; -} - export async function detectZaiEndpoint( ...args: Parameters ): ReturnType { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aca7c7c1e5..c752cbe3ef4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1458,6 +1458,9 @@ importers: jimp: specifier: ^1.6.1 version: 1.6.1 + qrcode: + specifier: 1.5.4 + version: 1.5.4 typebox: specifier: 1.1.34 version: 1.1.34 diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 0e152aa7afa..64e9f7b41a2 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -66,7 +66,11 @@ function acceptsIntent(coverage, id) { if (!coverage) { return true; } - return Array.isArray(coverage.acceptedIntents) && coverage.acceptedIntents.includes(id); + return ( + Array.isArray(coverage.acceptedIntents) && + coverage.acceptedIntents.includes(id) && + !coverage.skippedIntents?.includes(id) + ); } function hasCoverage(coverage) { @@ -189,10 +193,12 @@ function assertConfigSurvived() { "main agent contextTokens changed", ); } - assert( - agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true, - "ops fastModeDefault changed", - ); + if (!hasCoverage(coverage) || !coverage.skippedIntents?.includes("agent-modern-preferences")) { + assert( + agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true, + "ops fastModeDefault changed", + ); + } } if (acceptsIntent(coverage, "skills")) { diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index 28fe9db5c0f..e105bb23575 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -35,6 +35,28 @@ function readConfigSection(fileName) { return JSON.stringify(JSON.parse(fs.readFileSync(fileUrl, "utf8"))); } +function parseReleaseVersion(version) { + const match = /^([0-9]{4})\.([0-9]+)\.([0-9]+)/u.exec(String(version ?? "")); + if (!match) { + return null; + } + return match.slice(1).map((part) => Number.parseInt(part, 10)); +} + +function isReleaseBefore(version, minimum) { + const parsed = parseReleaseVersion(version); + const minimumParsed = parseReleaseVersion(minimum); + if (!parsed || !minimumParsed) { + return false; + } + for (let index = 0; index < parsed.length; index += 1) { + if (parsed[index] !== minimumParsed[index]) { + return parsed[index] < minimumParsed[index]; + } + } + return false; +} + function configSetJsonFile(id, intent, configPath, fileName) { return { id, @@ -112,6 +134,45 @@ function selectedScenario() { return process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base"; } +function adaptStepForBaseline(step, baselineVersion, summary) { + if (!isReleaseBefore(baselineVersion, "2026.4.0")) { + return step; + } + if (step.id === "plugins-feishu" || step.id === "channels-feishu") { + if (!summary.skippedIntents.includes("feishu-channel")) { + summary.skippedIntents.push("feishu-channel"); + } + return null; + } + if (step.id === "agents") { + const agents = JSON.parse(step.argv[3]); + delete agents.defaults?.skills; + for (const agent of agents.list ?? []) { + delete agent.thinkingDefault; + delete agent.fastModeDefault; + delete agent.skills; + } + summary.skippedIntents.push("agent-modern-preferences"); + return { + ...step, + argv: [...step.argv.slice(0, 3), JSON.stringify(agents), ...step.argv.slice(4)], + }; + } + if (step.intent === "plugins") { + const plugins = JSON.parse(step.argv[3]); + plugins.allow = (plugins.allow ?? []).filter((id) => id !== "memory"); + delete plugins.entries?.memory; + if (!summary.skippedIntents.includes("memory-plugin-allow")) { + summary.skippedIntents.push("memory-plugin-allow"); + } + return { + ...step, + argv: [...step.argv.slice(0, 3), JSON.stringify(plugins), ...step.argv.slice(4)], + }; + } + return step; +} + function runOpenClaw(step) { const result = spawnSync("openclaw", step.argv, { encoding: "utf8", @@ -156,7 +217,11 @@ function applyRecipe() { }; for (const step of [...recipe.slice(0, -1), ...scenarioSteps, recipe.at(-1)]) { - const outcome = runOpenClaw(step); + const adaptedStep = adaptStepForBaseline(step, baselineVersion, summary); + if (!adaptedStep) { + continue; + } + const outcome = runOpenClaw(adaptedStep); summary.steps.push(outcome); writeJson(summaryPath, summary); if (!outcome.ok) { diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json index 9cf0f87e2d1..cd9d479f513 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json @@ -3,8 +3,7 @@ "model": { "primary": "openai/gpt-4.1-mini" }, - "contextTokens": 64000, - "skills": ["memory"] + "contextTokens": 64000 }, "list": [ { diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index a106b1c8783..9546cda575a 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -234,6 +234,80 @@ package_root() { printf '%s/lib/node_modules/openclaw\n' "$npm_config_prefix" } +legacy_runtime_deps_symlink_plugin() { + local plugin="${OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK:-}" + if [ -z "$plugin" ]; then + return 1 + fi + case "$plugin" in + *[!A-Za-z0-9._-]*) + echo "OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK must be a plugin id, got: $plugin" >&2 + return 2 + ;; + esac + printf '%s\n' "$plugin" +} + +legacy_runtime_deps_symlink_target() { + local plugin="$1" + printf '%s/dist/extensions/%s/node_modules\n' "$(package_root)" "$plugin" +} + +legacy_runtime_deps_symlink_source() { + local plugin="$1" + printf '%s/.local/bundled-plugin-runtime-deps/%s-upgrade-survivor/node_modules\n' \ + "$(package_root)" \ + "$plugin" +} + +seed_legacy_runtime_deps_symlink() { + local plugin + plugin="$(legacy_runtime_deps_symlink_plugin)" || { + local status=$? + [ "$status" -eq 1 ] && return 0 + return "$status" + } + + local plugin_dir + plugin_dir="$(package_root)/dist/extensions/$plugin" + if [ ! -d "$plugin_dir" ]; then + echo "cannot seed legacy runtime deps symlink; packaged plugin is missing: $plugin_dir" >&2 + return 1 + fi + + local source_dir + local target_dir + source_dir="$(legacy_runtime_deps_symlink_source "$plugin")" + target_dir="$(legacy_runtime_deps_symlink_target "$plugin")" + mkdir -p "$source_dir" + printf '{"name":"openclaw-upgrade-survivor-legacy-runtime-deps","version":"0.0.0"}\n' \ + >"$source_dir/package.json" + rm -rf "$target_dir" + ln -s "$source_dir" "$target_dir" + if [ ! -L "$target_dir" ]; then + echo "failed to create legacy runtime deps symlink: $target_dir" >&2 + return 1 + fi + echo "Seeded legacy runtime deps symlink for $plugin: $target_dir -> $source_dir" +} + +assert_legacy_runtime_deps_symlink_repaired() { + local plugin + plugin="$(legacy_runtime_deps_symlink_plugin)" || { + local status=$? + [ "$status" -eq 1 ] && return 0 + return "$status" + } + + local target_dir + target_dir="$(legacy_runtime_deps_symlink_target "$plugin")" + if [ -L "$target_dir" ]; then + echo "legacy runtime deps symlink survived package update: $target_dir -> $(readlink "$target_dir")" >&2 + return 1 + fi + echo "Legacy runtime deps symlink repaired for $plugin." +} + read_installed_version() { node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1] + "/package.json", "utf8")).version' "$(package_root)" } @@ -450,8 +524,10 @@ phase seed-state seed_state phase apply-baseline-config-recipe apply_baseline_config_recipe phase validate-baseline-config validate_baseline_config phase assert-baseline assert_baseline_state +phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version phase update-candidate update_candidate +phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired phase doctor run_doctor phase validate-post-doctor-config validate_post_doctor_config phase assert-survival assert_survival diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index fda3b868877..1a6c769a340 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -143,7 +143,22 @@ Wait-OpenClawGateway Invoke-OpenClaw models set ${psSingleQuote(input.auth.modelId)} Invoke-OpenClaw config set agents.defaults.skipBootstrap true --strict-json Invoke-OpenClaw config set tools.profile minimal -Invoke-OpenClaw config set models.providers.openai ${psSingleQuote('{"baseUrl":"https://api.openai.com/v1","models":[],"timeoutSeconds":300}')} --strict-json +$configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json' +$config = Get-Content $configPath -Raw | ConvertFrom-Json +if ($null -eq $config.models) { + $config | Add-Member -MemberType NoteProperty -Name models -Value ([pscustomobject]@{}) +} +if ($null -eq $config.models.providers) { + $config.models | Add-Member -MemberType NoteProperty -Name providers -Value ([pscustomobject]@{}) +} +$config.models.providers | Add-Member -Force -MemberType NoteProperty -Name openai -Value ([pscustomobject]@{ + baseUrl = 'https://api.openai.com/v1' + models = @() + timeoutSeconds = 300 +}) +$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding utf8 +$sessionPath = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions\\parallels-npm-update-windows.jsonl' +Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue ${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")} Set-Item -Path ('Env:' + ${psSingleQuote(input.auth.apiKeyEnv)}) -Value ${psSingleQuote(input.auth.apiKeyValue)} Invoke-OpenClaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --thinking minimal --json`; diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 0045ce8787e..82db57d5f73 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -890,8 +890,22 @@ Invoke-OpenClaw config set agents.defaults.skipBootstrap true --strict-json if ($LASTEXITCODE -ne 0) { throw "config set failed" } Invoke-OpenClaw config set tools.profile minimal if ($LASTEXITCODE -ne 0) { throw "tools profile config set failed" } -Invoke-OpenClaw config set models.providers.openai ${psSingleQuote('{"baseUrl":"https://api.openai.com/v1","models":[],"timeoutSeconds":300}')} --strict-json -if ($LASTEXITCODE -ne 0) { throw "openai provider timeout config set failed" } +$configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json' +$config = Get-Content $configPath -Raw | ConvertFrom-Json +if ($null -eq $config.models) { + $config | Add-Member -MemberType NoteProperty -Name models -Value ([pscustomobject]@{}) +} +if ($null -eq $config.models.providers) { + $config.models | Add-Member -MemberType NoteProperty -Name providers -Value ([pscustomobject]@{}) +} +$config.models.providers | Add-Member -Force -MemberType NoteProperty -Name openai -Value ([pscustomobject]@{ + baseUrl = 'https://api.openai.com/v1' + models = @() + timeoutSeconds = 300 +}) +$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding utf8 +$sessionPath = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions\\parallels-windows-smoke.jsonl' +Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue ${windowsAgentWorkspaceScript("Parallels Windows smoke test assistant.")} Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)} $args = ${psArray([ diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index 0f301ae0b94..27394737765 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -41,6 +41,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then fi mkdir -p "$ARTIFACT_DIR" + chmod -R a+rwX "$ARTIFACT_DIR" || true DOCKER_E2E_PACKAGE_ARGS=() CANDIDATE_RAW="${OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE:-current}" @@ -83,6 +84,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then -e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND="$CANDIDATE_KIND" \ -e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC="$CANDIDATE_SPEC" \ -e OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="$SCENARIO" \ + -e OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK="${OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK:-}" \ -e OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON=/tmp/openclaw-upgrade-survivor-artifacts/summary.json \ -e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" \ -e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" \ @@ -97,6 +99,7 @@ PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "${OPENCLAW_CURRE docker_e2e_package_mount_args "$PACKAGE_TGZ" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 upgrade-survivor upgrade-survivor)" mkdir -p "$ARTIFACT_DIR" +chmod -R a+rwX "$ARTIFACT_DIR" || true docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" diff --git a/scripts/lib/bundled-runtime-deps-materialize.mjs b/scripts/lib/bundled-runtime-deps-materialize.mjs index d3004b6751e..9509c705a89 100644 --- a/scripts/lib/bundled-runtime-deps-materialize.mjs +++ b/scripts/lib/bundled-runtime-deps-materialize.mjs @@ -11,6 +11,7 @@ import { pruneStagedRuntimeDependencyCargo } from "./bundled-runtime-deps-prune. import { assertPathIsNotSymlink, makePluginOwnedTempDir, + removeLegacyBundledRuntimeDepsSymlink, removeOwnedTempPathBestEffort, removePathIfExists, replaceDirAtomically, @@ -155,6 +156,7 @@ export function stageInstalledRootRuntimeDeps(params) { const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution); const nodeModulesDir = path.join(pluginDir, "node_modules"); if (rootsToCopy.length === 0) { + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps"); removePathIfExists(nodeModulesDir); writeJsonAtomically(stampPath, { @@ -196,6 +198,7 @@ export function stageInstalledRootRuntimeDeps(params) { } pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir); writeJsonAtomically(stampPath, { cheapFingerprint, diff --git a/scripts/lib/bundled-runtime-deps-stage-state.mjs b/scripts/lib/bundled-runtime-deps-stage-state.mjs index 1349b8baaea..57050a6bb02 100644 --- a/scripts/lib/bundled-runtime-deps-stage-state.mjs +++ b/scripts/lib/bundled-runtime-deps-stage-state.mjs @@ -95,6 +95,53 @@ export function assertPathIsNotSymlink(targetPath, label) { } } +function isDirectChildPath(parentPath, childPath) { + const relativePath = path.relative(parentPath, childPath); + return ( + relativePath.length > 0 && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) && + !relativePath.includes(path.sep) + ); +} + +function isLegacyBundledRuntimeDepsNodeModulesPath(targetPath, repoRoot, linkedPath) { + const legacyRuntimeDepsRoot = path.resolve(repoRoot, ".local", "bundled-plugin-runtime-deps"); + const resolvedLinkedPath = path.resolve(path.dirname(targetPath), linkedPath); + return ( + path.basename(resolvedLinkedPath) === "node_modules" && + isDirectChildPath(legacyRuntimeDepsRoot, path.dirname(resolvedLinkedPath)) + ); +} + +export function removeLegacyBundledRuntimeDepsSymlink(targetPath, repoRoot) { + let stats; + try { + stats = fs.lstatSync(targetPath); + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } + if (!stats.isSymbolicLink()) { + return false; + } + + let linkedPath; + try { + linkedPath = fs.readlinkSync(targetPath); + } catch { + return false; + } + if (!isLegacyBundledRuntimeDepsNodeModulesPath(targetPath, repoRoot, linkedPath)) { + return false; + } + + removePathIfExists(targetPath); + return true; +} + export function replaceDirAtomically(targetPath, sourcePath) { assertPathIsNotSymlink(targetPath, "replace runtime deps"); const targetParentDir = path.dirname(targetPath); diff --git a/scripts/resolve-upgrade-survivor-baselines.mjs b/scripts/resolve-upgrade-survivor-baselines.mjs index 441a3f9500a..af3e958d58b 100644 --- a/scripts/resolve-upgrade-survivor-baselines.mjs +++ b/scripts/resolve-upgrade-survivor-baselines.mjs @@ -31,6 +31,17 @@ function dedupeSpecs(specs) { return [...new Set(specs.map(normalizeUpgradeSurvivorBaselineSpec).filter(Boolean))]; } +function readPublishedVersions(file) { + if (!file) { + return undefined; + } + const parsed = JSON.parse(readFileSync(file, "utf8")); + if (!Array.isArray(parsed)) { + throw new Error(`npm versions list must be a JSON array: ${file}`); + } + return new Set(parsed.filter((version) => typeof version === "string")); +} + function stableVersionFromTag(tagName) { const version = String(tagName ?? "").replace(/^v/u, ""); if (!/^[0-9]{4}\.[0-9]+\.[0-9]+(?:-[0-9]+)?$/u.test(version)) { @@ -39,7 +50,18 @@ function stableVersionFromTag(tagName) { return version; } -function readStableReleases(file) { +function npmPublishedVersion(version, publishedVersions) { + if (!version || !publishedVersions) { + return version; + } + if (publishedVersions.has(version)) { + return version; + } + const baseVersion = version.replace(/-[0-9]+$/u, ""); + return publishedVersions.has(baseVersion) ? baseVersion : undefined; +} + +function readStableReleases(file, publishedVersions) { const ansiEscape = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g"); const raw = readFileSync(file, "utf8").replace(ansiEscape, ""); const parsed = JSON.parse(raw); @@ -50,7 +72,7 @@ function readStableReleases(file) { .filter((release) => !release.isPrerelease) .map((release) => ({ publishedAt: release.publishedAt, - version: stableVersionFromTag(release.tagName), + version: npmPublishedVersion(stableVersionFromTag(release.tagName), publishedVersions), })) .filter((release) => release.version && release.publishedAt) .toSorted((a, b) => String(b.publishedAt).localeCompare(String(a.publishedAt))); @@ -67,7 +89,8 @@ export function resolveReleaseHistory(args) { } const includeVersion = args.get("include-version") ?? "2026.4.23"; const preDate = args.get("pre-date") ?? "2026-03-15T00:00:00Z"; - const releases = readStableReleases(releasesJson); + const publishedVersions = readPublishedVersions(args.get("npm-versions-json")); + const releases = readStableReleases(releasesJson, publishedVersions); const versions = releases.slice(0, historyCount).map((release) => release.version); const exact = releases.find((release) => release.version === includeVersion); if (exact) { diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 87f7756d903..3e611b36bae 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -24,6 +24,7 @@ import { import { assertPathIsNotSymlink, makePluginOwnedTempDir, + removeLegacyBundledRuntimeDepsSymlink, removeOwnedTempPathBestEffort, removePathIfExists, removeStaleRuntimeDepsTempDirs, @@ -323,8 +324,10 @@ function installPluginRuntimeDeps(params) { } if (fs.existsSync(stagedNodeModulesDir)) { pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir); } else { + removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot); assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps"); removePathIfExists(nodeModulesDir); } diff --git a/src/acp/translator.prompt-harness.test-support.ts b/src/acp/translator.prompt-harness.test-support.ts index ef384c60e11..b12701ac3a1 100644 --- a/src/acp/translator.prompt-harness.test-support.ts +++ b/src/acp/translator.prompt-harness.test-support.ts @@ -6,15 +6,15 @@ import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; -export type PendingPromptHarness = { +type PendingPromptHarness = { agent: AcpGatewayAgent; promptPromise: ReturnType; runId: string; }; -export const DEFAULT_SESSION_ID = "session-1"; +const DEFAULT_SESSION_ID = "session-1"; export const DEFAULT_SESSION_KEY = "agent:main:main"; -export const DEFAULT_PROMPT_TEXT = "hello"; +const DEFAULT_PROMPT_TEXT = "hello"; export function createSessionAgentHarness( request: GatewayClient["request"], diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index 2bd7fd2747f..222ee0ef59b 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -2,7 +2,7 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk"; import { vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; -export type TestAcpConnection = AgentSideConnection & { +type TestAcpConnection = AgentSideConnection & { __sessionUpdateMock: ReturnType; }; diff --git a/src/agents/bundle-mcp.test-harness.ts b/src/agents/bundle-mcp.test-harness.ts index baa6a572d24..c2019780f49 100644 --- a/src/agents/bundle-mcp.test-harness.ts +++ b/src/agents/bundle-mcp.test-harness.ts @@ -9,7 +9,7 @@ const require = createRequire(import.meta.url); const SDK_CLIENT_INDEX_PATH = require.resolve("@modelcontextprotocol/sdk/client/index.js"); const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/stdio.js"); -export { writeBundleProbeMcpServer, writeClaudeBundle, writeExecutable }; +export { writeBundleProbeMcpServer, writeClaudeBundle }; export async function writeFakeClaudeLiveCli(params: { filePath: string; diff --git a/src/agents/openclaw-tools.subagents.test-harness.ts b/src/agents/openclaw-tools.subagents.test-harness.ts index 355e1f543e1..b27b0cf89fb 100644 --- a/src/agents/openclaw-tools.subagents.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.test-harness.ts @@ -5,7 +5,7 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { __testing as subagentAnnounceTesting } from "./subagent-announce.js"; import { __testing as subagentControlTesting } from "./subagent-control.js"; -export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]>; +type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]>; export const callGatewayMock: MockFn = vi.fn(); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 1e3b6b40077..956207fb7d1 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -278,6 +278,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ blockStreaming: { type: "boolean", }, + replyContextApiFallback: { + type: "boolean", + }, groups: { type: "object", properties: {}, @@ -595,6 +598,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ blockStreaming: { type: "boolean", }, + replyContextApiFallback: { + type: "boolean", + }, groups: { type: "object", properties: {}, @@ -1495,6 +1501,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ minimum: 0, maximum: 9007199254740991, }, + connectTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, + reconnectGraceMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, tts: { type: "object", properties: { @@ -2861,6 +2877,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ minimum: 0, maximum: 9007199254740991, }, + connectTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, + reconnectGraceMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, tts: { type: "object", properties: { @@ -3567,6 +3593,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Discord Voice Decrypt Failure Tolerance", help: "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", }, + "voice.connectTimeoutMs": { + label: "Discord Voice Connect Timeout (ms)", + help: "Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000.", + }, + "voice.reconnectGraceMs": { + label: "Discord Voice Reconnect Grace (ms)", + help: "Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000.", + }, "voice.tts": { label: "Discord Voice Text-to-Speech", help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).", diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index d19ea0529ad..c5018dd699f 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -21648,6 +21648,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", const: "clawhub", }, + { + type: "string", + const: "git", + }, ], }, spec: { @@ -21717,6 +21721,15 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, ], }, + gitUrl: { + type: "string", + }, + gitRef: { + type: "string", + }, + gitCommit: { + type: "string", + }, hooks: { type: "array", items: { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 65446c8753f..9142ba43af0 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -138,6 +138,10 @@ export type DiscordVoiceConfig = { daveEncryption?: boolean; /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ decryptionFailureTolerance?: number; + /** Initial @discordjs/voice Ready wait in milliseconds (default: 30000). */ + connectTimeoutMs?: number; + /** Grace period for Discord voice reconnect signalling after a disconnect (default: 15000). */ + reconnectGraceMs?: number; /** Optional TTS overrides for Discord voice output. */ tts?: TtsConfig; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d8c1b879160..8f99b19b235 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -513,6 +513,8 @@ const DiscordVoiceSchema = z autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), daveEncryption: z.boolean().optional(), decryptionFailureTolerance: z.number().int().min(0).optional(), + connectTimeoutMs: z.number().int().positive().max(120_000).optional(), + reconnectGraceMs: z.number().int().positive().max(120_000).optional(), tts: TtsConfigSchema.optional(), }) .strict() diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 9e9418c7139..6988f275039 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -92,7 +92,7 @@ describe("tsdown config", () => { "plugins/runtime/index", "plugin-sdk/compat", "plugin-sdk/index", - bundledEntry("openai"), + bundledEntry("active-memory"), "bundled/boot-md/handler", ]), ); @@ -128,6 +128,7 @@ describe("tsdown config", () => { ); expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/discord")).toBe(true); expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/msteams")).toBe(true); + expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/openai")).toBe(true); expect( stagedGraphs.some( (config) => diff --git a/src/plugins/bundled-runtime-deps-drift.test.ts b/src/plugins/bundled-runtime-deps-drift.test.ts index 6094359c643..1b60da8609e 100644 --- a/src/plugins/bundled-runtime-deps-drift.test.ts +++ b/src/plugins/bundled-runtime-deps-drift.test.ts @@ -13,7 +13,7 @@ describe("mirrored root runtime dependency drift guard", () => { "file-type", // available transitively via mirrored deps "ipaddr.js", // available transitively via mirrored deps "proxy-agent", // available transitively via mirrored deps - "qrcode", // type-only import in src/media/qr-runtime.ts + "qrcode", // QR setup flows stage this through the owning channel plugin, not the root mirror "typescript", // CLI/dev only (api-baseline, jiti-runtime-api) ]); diff --git a/src/plugins/bundled-runtime-deps-roots.ts b/src/plugins/bundled-runtime-deps-roots.ts index d37a7fb7f77..e71ddf820aa 100644 --- a/src/plugins/bundled-runtime-deps-roots.ts +++ b/src/plugins/bundled-runtime-deps-roots.ts @@ -269,6 +269,57 @@ export function listSiblingExternalBundledRuntimeDepsRoots(params: { .map((entry) => entry.root); } +export function pruneSiblingExternalBundledRuntimeDepsRoots(params: { + installRoot: string; + nowMs?: number; + warn?: (message: string) => void; +}): { scanned: number; removed: number; skippedLocked: number } { + const installRoot = path.resolve(params.installRoot); + const installRootHash = readPackageKeyPathHash(path.basename(installRoot)); + if (!installRootHash) { + return { scanned: 0, removed: 0, skippedLocked: 0 }; + } + const parentDir = path.dirname(installRoot); + const nowMs = params.nowMs ?? Date.now(); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(parentDir, { withFileTypes: true }); + } catch { + return { scanned: 0, removed: 0, skippedLocked: 0 }; + } + + let scanned = 0; + let removed = 0; + let skippedLocked = 0; + for (const entry of entries) { + if ( + !entry.isDirectory() || + !entry.name.startsWith("openclaw-") || + readPackageKeyPathHash(entry.name) !== installRootHash + ) { + continue; + } + const root = path.join(parentDir, entry.name); + if (path.resolve(root) === installRoot) { + continue; + } + scanned += 1; + const lockDir = path.join(root, BUNDLED_RUNTIME_DEPS_LOCK_DIR); + if (fs.existsSync(lockDir) && !removeRuntimeDepsLockIfStale(lockDir, nowMs)) { + skippedLocked += 1; + continue; + } + try { + fs.rmSync(root, { recursive: true, force: true }); + removed += 1; + } catch (error) { + params.warn?.(`failed to remove sibling bundled runtime deps root ${root}: ${String(error)}`); + } + } + + return { scanned, removed, skippedLocked }; +} + function readPackageKeyPathHash(packageKey: string): string | null { return PACKAGE_KEY_PATH_HASH_RE.exec(packageKey)?.[1] ?? null; } diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index e405ca1c440..7ff759836b4 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -1860,6 +1860,7 @@ describe("createBundledRuntimeDepsPackagePlan config policy", () => { ]); expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(true); expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); + expect(fs.existsSync(previousRoot)).toBe(true); expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({ name: "openclaw-runtime-deps-install", private: true, @@ -1904,6 +1905,7 @@ describe("createBundledRuntimeDepsPackagePlan config policy", () => { }, ]); expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(false); + expect(fs.existsSync(previousRoot)).toBe(true); }); it("does not create a reuse symlink when an earlier configured layer already satisfies the plan", async () => { @@ -1974,6 +1976,7 @@ describe("createBundledRuntimeDepsPackagePlan config policy", () => { }, ]); expect(fs.lstatSync(path.join(installRoot, "node_modules")).isSymbolicLink()).toBe(false); + expect(fs.existsSync(previousRoot)).toBe(false); }); it("does not reuse a compatible external runtime deps root from a different package key", async () => { @@ -3184,6 +3187,23 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(result).toEqual({ installedSpecs: [] }); }); + it("accepts package.json runtime-deps supersets when generated metadata is absent", () => { + const installRoot = makeTempDir(); + fs.writeFileSync( + path.join(installRoot, "package.json"), + JSON.stringify({ + name: "openclaw-bundled-runtime-deps", + dependencies: { + "alpha-runtime": "1.0.0", + tokenjuice: "0.7.0", + }, + }), + ); + writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0"); + + expect(isRuntimeDepsPlanMaterialized(installRoot, ["alpha-runtime@1.0.0"])).toBe(true); + }); + it("drops stale package versions from the next package-level plan", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 656231383a3..a9da526fdcf 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -25,6 +25,7 @@ import { import { isSourceCheckoutRoot, listSiblingExternalBundledRuntimeDepsRoots, + pruneSiblingExternalBundledRuntimeDepsRoots, pruneUnknownBundledRuntimeDepsRoots, resolveBundledRuntimeDependencyInstallRootPlan, resolveBundledRuntimeDependencyPackageInstallRootPlan, @@ -404,6 +405,10 @@ export async function repairBundledRuntimeDepsPackagePlanAsync(params: { }); const plan = createBundledRuntimeDepsPackagePlan(params); if (plan.missingSpecs.length === 0) { + pruneSiblingExternalBundledRuntimeDepsRoots({ + installRoot: plan.installRootPlan.installRoot, + ...(params.warn ? { warn: params.warn } : {}), + }); return { plan, repairedSpecs: [] }; } const reuseResult = withBundledRuntimeDepsInstallRootLock(plan.installRootPlan.installRoot, () => @@ -416,6 +421,12 @@ export async function repairBundledRuntimeDepsPackagePlanAsync(params: { ); if (reuseResult) { const refreshedPlan = createBundledRuntimeDepsPackagePlan(params); + if (reuseResult.status === "materialized") { + pruneSiblingExternalBundledRuntimeDepsRoots({ + installRoot: refreshedPlan.installRootPlan.installRoot, + ...(params.warn ? { warn: params.warn } : {}), + }); + } return { plan: refreshedPlan, repairedSpecs: [], @@ -442,6 +453,10 @@ export async function repairBundledRuntimeDepsPackagePlanAsync(params: { ...(params.onProgress ? { onProgress: params.onProgress } : {}), ...(params.warn ? { warn: params.warn } : {}), }); + pruneSiblingExternalBundledRuntimeDepsRoots({ + installRoot: plan.installRootPlan.installRoot, + ...(params.warn ? { warn: params.warn } : {}), + }); return { plan, repairedSpecs: result.installSpecs }; } diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 9bb540db323..d8768c57f34 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -265,6 +265,83 @@ describe("resolvePluginCapabilityProviders", () => { expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(); }); + it("merges configured media-understanding providers missing from the active registry", () => { + const active = createEmptyPluginRegistry(); + active.mediaUnderstandingProviders.push({ + pluginId: "openai", + pluginName: "OpenAI", + source: "test", + provider: { + id: "openai", + capabilities: ["image"], + }, + } as never); + const loaded = createEmptyPluginRegistry(); + loaded.mediaUnderstandingProviders.push( + { + pluginId: "deepgram", + pluginName: "Deepgram", + source: "test", + provider: { + id: "deepgram", + capabilities: ["audio"], + }, + } as never, + { + pluginId: "google", + pluginName: "Google", + source: "test", + provider: { + id: "google", + capabilities: ["image", "audio", "video"], + }, + } as never, + ); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "deepgram", + origin: "bundled", + contracts: { mediaUnderstandingProviders: ["deepgram"] }, + }, + { + id: "google", + origin: "bundled", + contracts: { mediaUnderstandingProviders: ["google"] }, + }, + ] as never, + diagnostics: [], + }); + mocks.resolveRuntimePluginRegistry.mockImplementation((params?: unknown) => + params === undefined ? active : loaded, + ); + + const providers = resolvePluginCapabilityProviders({ + key: "mediaUnderstandingProviders", + cfg: { + plugins: { allow: ["openai", "deepgram", "google"] }, + tools: { + media: { + audio: { enabled: true, models: [{ provider: "deepgram", model: "nova-3" }] }, + }, + }, + } as OpenClawConfig, + }); + + expectResolvedCapabilityProviderIds(providers, ["openai", "deepgram"]); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["openai", "deepgram", "google"], + }), + }), + onlyPluginIds: ["deepgram", "google"], + activate: false, + installBundledRuntimeDeps: false, + }); + }); + it("keeps active speech providers when cfg requests an active provider alias", () => { const active = createEmptyPluginRegistry(); active.speechProviders.push({ diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 881cd9745cd..07a63d4e5a5 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -241,6 +241,43 @@ function collectRequestedSpeechProviderIds(cfg: OpenClawConfig | undefined): Set return requested; } +function addMediaModelProviders(target: Set, value: unknown): void { + if (!Array.isArray(value)) { + return; + } + for (const entry of value) { + if (typeof entry === "object" && entry !== null) { + addStringValue(target, (entry as { provider?: unknown }).provider); + } + } +} + +function collectRequestedMediaUnderstandingProviderIds( + cfg: OpenClawConfig | undefined, +): Set { + const requested = new Set(); + const media = cfg?.tools?.media; + addMediaModelProviders(requested, media?.models); + addMediaModelProviders(requested, media?.image?.models); + addMediaModelProviders(requested, media?.audio?.models); + addMediaModelProviders(requested, media?.video?.models); + return requested; +} + +function collectRequestedCapabilityProviderIds(params: { + key: CapabilityProviderRegistryKey; + cfg?: OpenClawConfig; +}): Set | undefined { + switch (params.key) { + case "speechProviders": + return collectRequestedSpeechProviderIds(params.cfg); + case "mediaUnderstandingProviders": + return collectRequestedMediaUnderstandingProviderIds(params.cfg); + default: + return undefined; + } +} + function removeActiveProviderIds(requested: Set, entries: readonly unknown[]): void { for (const entry of entries as Array<{ provider: { id?: unknown; aliases?: unknown } }>) { const provider = entry.provider as { id?: unknown; aliases?: unknown }; @@ -262,7 +299,7 @@ function filterLoadedProvidersForRequestedConfig; entries: PluginRegistry[K]; }): PluginRegistry[K] { - if (params.key !== "speechProviders") { + if (params.key !== "speechProviders" && params.key !== "mediaUnderstandingProviders") { return [] as unknown as PluginRegistry[K]; } if (params.requested.size === 0) { @@ -341,23 +378,16 @@ export function resolvePluginCapabilityProviders 0 && - params.key !== "memoryEmbeddingProviders" && - params.key !== "speechProviders" - ) { - return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey[]; - } - if (activeProviders.length > 0 && params.key === "speechProviders" && !params.cfg) { - return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey[]; - } - const missingRequestedSpeechProviders = - activeProviders.length > 0 && params.key === "speechProviders" - ? collectRequestedSpeechProviderIds(params.cfg) + const missingRequestedProviders = + activeProviders.length > 0 + ? collectRequestedCapabilityProviderIds({ key: params.key, cfg: params.cfg }) : undefined; - if (missingRequestedSpeechProviders) { - removeActiveProviderIds(missingRequestedSpeechProviders, activeProviders); - if (missingRequestedSpeechProviders.size === 0) { + if (activeProviders.length > 0 && params.key !== "memoryEmbeddingProviders") { + if (!missingRequestedProviders) { + return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey[]; + } + removeActiveProviderIds(missingRequestedProviders, activeProviders); + if (missingRequestedProviders.size === 0) { return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey[]; } } @@ -390,7 +420,7 @@ export function resolvePluginCapabilityProviders 0 ? filterLoadedProvidersForRequestedConfig({ key: params.key, - requested: missingRequestedSpeechProviders ?? new Set(), + requested: missingRequestedProviders ?? new Set(), entries: loadedProviders, }) : loadedProviders; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 796522d5d2f..dc949126235 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -23,7 +23,10 @@ import { } from "../tasks/detached-task-runtime-state.js"; import { withEnv } from "../test-utils/env.js"; import type { BundledRuntimeDepsInstallParams } from "./bundled-runtime-deps-install.js"; -import { resolveBundledRuntimeDependencyInstallRootPlan } from "./bundled-runtime-deps-roots.js"; +import { + resolveBundledRuntimeDependencyInstallRootPlan, + resolveBundledRuntimeDependencyPackageInstallRoot, +} from "./bundled-runtime-deps-roots.js"; import { ensureOpenClawPluginSdkAlias } from "./bundled-runtime-root.js"; import { clearPluginCommands } from "./command-registry-state.js"; import { getPluginCommandSpecs } from "./command-specs.js"; @@ -95,6 +98,10 @@ import { ensurePluginRegistryLoaded, } from "./runtime/runtime-registry-loader.js"; import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; +import { + writeGeneratedRuntimeDepsManifest, + writeInstalledRuntimeDepPackage, +} from "./test-helpers/bundled-runtime-deps-fixtures.js"; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; @@ -118,6 +125,14 @@ function createDetachedTaskRuntimeStub(id: string): DetachedTaskLifecycleRuntime }; } +function realpathOrResolveForTest(value: string): string { + try { + return fs.realpathSync.native(value); + } catch { + return path.resolve(value); + } +} + const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = { id: "telegram", register(api) { @@ -1592,6 +1607,136 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); }); + it("does not reuse cached bundled runtime deps after an in-place package version upgrade", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + const markerDir = makeTempDir(); + const markerPath = path.join(markerDir, "browser-runtime-marker.json"); + const bundledDir = path.join(packageRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledDir, "browser"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/browser", + version: "1.0.0", + dependencies: { + "browser-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "browser", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_PLUGIN_STAGE_DIR: stageDir, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + VITEST: "true", + }; + const writePackageVersion = (version: string) => { + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version, type: "module" }, null, 2), + "utf-8", + ); + }; + const writeRuntimeEntry = (marker: string) => { + fs.writeFileSync( + path.join(pluginRoot, "index.cjs"), + ` +const fs = require("node:fs"); +const runtimeDep = require("browser-runtime/package.json"); +fs.writeFileSync( + ${JSON.stringify(markerPath)}, + JSON.stringify({ marker: ${JSON.stringify(marker)}, filename: __filename, runtimeDep: runtimeDep.name }) + "\\n", + "utf-8", +); +module.exports = { id: "browser", register() {} }; +`, + "utf-8", + ); + }; + const installRoots: string[] = []; + const loadOptions = { + env, + onlyPluginIds: ["browser"], + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: ({ installRoot, installSpecs, missingSpecs }) => { + installRoots.push(installRoot); + writeInstalledRuntimeDepPackage(installRoot, "browser-runtime", "1.0.0"); + writeGeneratedRuntimeDepsManifest(installRoot, installSpecs ?? missingSpecs); + }, + } satisfies Parameters[0]; + + writePackageVersion("2026.4.26"); + writeRuntimeEntry("v26"); + const first = withEnv(env, () => loadOpenClawPlugins(loadOptions)); + const firstInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { + env, + }); + const firstPlugin = first.plugins.find((entry) => entry.id === "browser"); + expect(firstPlugin?.error).toBeUndefined(); + expect(firstPlugin?.status).toBe("loaded"); + const firstMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as { + filename: string; + marker: string; + runtimeDep: string; + }; + + expect(firstMarker.marker).toBe("v26"); + expect(firstMarker.runtimeDep).toBe("browser-runtime"); + expect(realpathOrResolveForTest(firstMarker.filename)).toContain( + realpathOrResolveForTest(path.join(firstInstallRoot, "dist", "extensions")), + ); + expect(installRoots.map((root) => realpathOrResolveForTest(root))).toContain( + realpathOrResolveForTest(firstInstallRoot), + ); + + writePackageVersion("2026.4.27"); + writeRuntimeEntry("v27"); + const secondInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, { + env, + }); + const second = withEnv(env, () => loadOpenClawPlugins(loadOptions)); + const secondMarker = JSON.parse(fs.readFileSync(markerPath, "utf-8")) as { + filename: string; + marker: string; + runtimeDep: string; + }; + + expect(second).not.toBe(first); + expect(second.plugins.find((entry) => entry.id === "browser")?.status).toBe("loaded"); + expect(secondMarker.marker).toBe("v27"); + expect(secondMarker.runtimeDep).toBe("browser-runtime"); + expect(realpathOrResolveForTest(secondMarker.filename)).toContain( + realpathOrResolveForTest(path.join(secondInstallRoot, "dist", "extensions")), + ); + expect(secondInstallRoot).not.toBe(firstInstallRoot); + }); + it("loads bundled plugins from symlinked package roots with an external stage dir", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3d3a17e21de..bd482f00db8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -544,6 +544,72 @@ function setCachedPluginRegistry(cacheKey: string, state: CachedPluginState): vo pluginLoaderCacheState.set(cacheKey, state); } +function resolveBundledPackageRootForCache(stockRoot?: string): string | undefined { + if (!stockRoot) { + return undefined; + } + const resolved = path.resolve(stockRoot); + const parent = path.dirname(resolved); + if ( + path.basename(resolved) === "extensions" && + (path.basename(parent) === "dist" || path.basename(parent) === "dist-runtime") + ) { + return path.dirname(parent); + } + const sourcePackageRoot = parent; + if (fs.existsSync(path.join(sourcePackageRoot, "package.json"))) { + return sourcePackageRoot; + } + return undefined; +} + +function readPackageVersionForCache(packageJsonPath: string): string { + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return "unknown"; + } + const version = (parsed as { version?: unknown }).version; + return typeof version === "string" && version.trim() ? version.trim() : "unknown"; + } catch { + return "unknown"; + } +} + +function resolveBundledPackageCacheIdentity(stockRoot?: string): + | { + packageJson: string; + packageRoot: string; + packageVersion: string; + size: number; + mtimeMs: number; + } + | undefined { + const packageRoot = resolveBundledPackageRootForCache(stockRoot); + if (!packageRoot) { + return undefined; + } + const packageJsonPath = path.join(packageRoot, "package.json"); + try { + const stat = fs.statSync(packageJsonPath); + return { + packageJson: safeRealpathOrResolve(packageJsonPath), + packageRoot: safeRealpathOrResolve(packageRoot), + packageVersion: readPackageVersionForCache(packageJsonPath), + size: stat.size, + mtimeMs: stat.mtimeMs, + }; + } catch { + return { + packageJson: path.resolve(packageJsonPath), + packageRoot: safeRealpathOrResolve(packageRoot), + packageVersion: "missing", + size: -1, + mtimeMs: -1, + }; + } +} + function buildCacheKey(params: { workspaceDir?: string; plugins: NormalizedPluginsConfig; @@ -567,6 +633,7 @@ function buildCacheKey(params: { loadPaths: params.plugins.loadPaths, env: params.env, }); + const bundledPackage = resolveBundledPackageCacheIdentity(roots.stock); const installs = Object.fromEntries( Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ pluginId, @@ -600,6 +667,7 @@ function buildCacheKey(params: { const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []); const activationMode = params.activate === false ? "snapshot" : "active"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ + bundledPackage, ...params.plugins, installs, loadPaths, diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index e95696615aa..0a842abfcee 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -422,8 +422,9 @@ console.log(JSON.stringify(result)); expect(script).toContain('guestPowerShellBackground(\n "agent-turn"'); expect(script).toContain("OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S"); expect(script).toContain("finalAssistant(Raw|Visible)Text"); - expect(script).toContain("models.providers.openai"); - expect(script).toContain('"timeoutSeconds":300'); + expect(script).toContain("$config.models.providers"); + expect(script).toContain("timeoutSeconds = 300"); + expect(script).toContain("parallels-windows-smoke.jsonl"); }); it("waits through transient Windows restoring state before VM operations", () => { diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index c58c4f1acb7..5a0c983aa25 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -46,6 +46,26 @@ describe("stageBundledPluginRuntimeDeps", () => { return path.join(repoRoot, ".artifacts", "bundled-runtime-deps-stamps", `${pluginId}.json`); } + function legacyRuntimeDepsNodeModulesPath( + repoRoot: string, + stageKey = "fixture-plugin-1234567890abcdef", + ) { + return path.join(repoRoot, ".local", "bundled-plugin-runtime-deps", stageKey, "node_modules"); + } + + function writeLegacyRuntimeDepsNodeModulesSymlink(params: { + pluginDir: string; + repoRoot: string; + stageKey?: string; + }) { + const legacyNodeModulesDir = legacyRuntimeDepsNodeModulesPath(params.repoRoot, params.stageKey); + const nodeModulesDir = path.join(params.pluginDir, "node_modules"); + fs.mkdirSync(legacyNodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(legacyNodeModulesDir, "legacy.js"), "module.exports = 0;\n", "utf8"); + fs.symlinkSync(legacyNodeModulesDir, nodeModulesDir); + return { legacyNodeModulesDir, nodeModulesDir }; + } + it("pins fallback install specs to exact installed versions", () => { const { repoRoot } = createBundledPluginFixture({ packageJson: { @@ -708,6 +728,93 @@ describe("stageBundledPluginRuntimeDeps", () => { ); }); + it("replaces legacy OpenClaw-owned symlinked plugin node_modules", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + fs.mkdirSync(directDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); + const { legacyNodeModulesDir, nodeModulesDir } = writeLegacyRuntimeDepsNodeModulesSymlink({ + pluginDir, + repoRoot, + }); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect(fs.lstatSync(nodeModulesDir).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(nodeModulesDir, "direct", "index.js"), "utf8")).toBe( + "module.exports = 'direct';\n", + ); + expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); + }); + + it("removes legacy OpenClaw-owned symlinked plugin node_modules when deps converge to empty", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + optionalDependencies: { optional: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const rootNodeModulesDir = path.join(repoRoot, "node_modules"); + fs.mkdirSync(rootNodeModulesDir, { recursive: true }); + const { legacyNodeModulesDir, nodeModulesDir } = writeLegacyRuntimeDepsNodeModulesSymlink({ + pluginDir, + repoRoot, + }); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect(fs.existsSync(nodeModulesDir)).toBe(false); + expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); + }); + + it("refuses nested symlink targets under the legacy runtime deps root", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + const nestedLegacyNodeModulesDir = path.join( + repoRoot, + ".local", + "bundled-plugin-runtime-deps", + "fixture-plugin-1234567890abcdef", + "nested", + "node_modules", + ); + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(directDir, { recursive: true }); + fs.mkdirSync(nestedLegacyNodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); + fs.symlinkSync(nestedLegacyNodeModulesDir, nodeModulesDir); + + expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow( + /refusing to replace runtime deps via symlinked path/u, + ); + }); + it("refuses to write a runtime deps stamp through a symlink", () => { const { repoRoot } = createBundledPluginFixture({ packageJson: { diff --git a/test/scripts/upgrade-survivor-baselines.test.ts b/test/scripts/upgrade-survivor-baselines.test.ts index fd340b15ab7..54035863013 100644 --- a/test/scripts/upgrade-survivor-baselines.test.ts +++ b/test/scripts/upgrade-survivor-baselines.test.ts @@ -15,6 +15,17 @@ function withReleaseFixture(releases: unknown[], fn: (file: string) => T): T } } +function withJsonFixture(name: string, contents: unknown, fn: (file: string) => T): T { + const dir = mkdtempSync(path.join(tmpdir(), "openclaw-upgrade-baselines-")); + try { + const file = path.join(dir, name); + writeFileSync(file, `${JSON.stringify(contents)}\n`); + return fn(file); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +} + describe("scripts/resolve-upgrade-survivor-baselines", () => { it("keeps the single fallback baseline when no expanded request is provided", () => { expect(resolveBaselines(new Map([["fallback", "2026.4.23"]]))).toEqual(["openclaw@2026.4.23"]); @@ -63,4 +74,51 @@ describe("scripts/resolve-upgrade-survivor-baselines", () => { ]); }); }); + + it("maps release-history anchors to npm-published package versions when GitHub tags have republish suffixes", () => { + const releases = ( + [ + ["v2026.4.29", "2026-04-30T00:00:00Z"], + ["v2026.4.27", "2026-04-28T00:00:00Z"], + ["v2026.4.26", "2026-04-27T00:00:00Z"], + ["v2026.4.25", "2026-04-26T00:00:00Z"], + ["v2026.4.24", "2026-04-25T00:00:00Z"], + ["v2026.4.23", "2026-04-22T00:00:00Z"], + ["v2026.3.13-1", "2026-03-14T18:04:00Z"], + ] as const + ).map(([tagName, publishedAt]) => ({ + isPrerelease: false, + publishedAt, + tagName, + })); + + withReleaseFixture(releases, (releasesFile) => { + withJsonFixture( + "versions.json", + ["2026.4.29", "2026.4.27", "2026.4.26", "2026.4.25", "2026.4.24", "2026.4.23", "2026.3.13"], + (versionsFile) => { + expect( + resolveBaselines( + new Map([ + ["requested", "release-history"], + ["releases-json", releasesFile], + ["npm-versions-json", versionsFile], + ["history-count", "6"], + ["include-version", "2026.4.23"], + ["pre-date", "2026-03-15T00:00:00Z"], + ]), + ), + ).toEqual([ + "openclaw@2026.4.29", + "openclaw@2026.4.27", + "openclaw@2026.4.26", + "openclaw@2026.4.25", + "openclaw@2026.4.24", + "openclaw@2026.4.23", + "openclaw@2026.3.13", + ]); + }, + ); + }); + }); });