chore: merge main into status reaction cleanup

This commit is contained in:
Peter Steinberger
2026-05-01 12:00:51 +01:00
98 changed files with 1285 additions and 275 deletions

View File

@@ -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

View File

@@ -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-<version>-<hash>` 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -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.

View File

@@ -363,31 +363,66 @@ Example (read-only source + an extra data directory):
Default Docker image: `openclaw-sandbox:bookworm-slim`
<Note>
**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.
</Note>
<Steps>
<Step title="Build the default image">
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.
</Step>
<Step title="Optional: build the common image">
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`.
</Step>
<Step title="Optional: build the sandbox browser image">
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.
</Step>
</Steps>

View File

@@ -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
```
</Accordion>

View File

@@ -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
<AccordionGroup>
<Accordion title="Image missing or sandbox container not starting">
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.
</Accordion>

View File

@@ -51,8 +51,8 @@ function isSupportedRegion(region: string): boolean {
// Bearer token resolution
// ---------------------------------------------------------------------------
export type MantleBearerTokenProvider = () => Promise<string>;
export type MantleBearerTokenProviderFactory = (opts?: {
type MantleBearerTokenProvider = () => Promise<string>;
type MantleBearerTokenProviderFactory = (opts?: {
region?: string;
expiresInSeconds?: number;
}) => MantleBearerTokenProvider;

View File

@@ -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<AwsCredentialProviderSdk | n
const MODEL_PREFIX_RE = /^(?:bedrock|amazon-bedrock|aws)\//;
const REGION_RE = /bedrock-runtime\.([a-z0-9-]+)\./;
export function normalizeBedrockEmbeddingModel(model: string): string {
function normalizeBedrockEmbeddingModel(model: string): string {
const trimmed = model.trim();
return trimmed ? trimmed.replace(MODEL_PREFIX_RE, "") : DEFAULT_BEDROCK_EMBEDDING_MODEL;
}
@@ -337,7 +337,7 @@ export async function createBedrockEmbeddingProvider(
// Client resolution
// ---------------------------------------------------------------------------
export function resolveBedrockEmbeddingClient(
function resolveBedrockEmbeddingClient(
options: MemoryEmbeddingProviderCreateOptions,
): BedrockEmbeddingClient {
const model = normalizeBedrockEmbeddingModel(options.model);

View File

@@ -165,7 +165,7 @@ function toCanonicalAnthropicModelRef(ref: string): string {
: ref;
}
export function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
providerConfig: T,
): T {
if (

View File

@@ -23,7 +23,7 @@ function resolveClaudeCliSyntheticAuth() {
};
}
export const anthropicProviderDiscovery: ProviderPlugin = {
const anthropicProviderDiscovery: ProviderPlugin = {
id: CLAUDE_CLI_BACKEND_ID,
label: "Claude CLI",
docsPath: "/providers/models",

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function escapeXmlAttr(value: string): string {
function escapeXmlAttr(value: string): string {
return escapeXmlText(value).replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}

View File

@@ -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<string, MockFn> {
return chromeMcpMocks as unknown as Record<string, MockFn>;
}
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<void> {
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");
});

View File

@@ -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",

View File

@@ -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;
} {

View File

@@ -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<string, unknown>;
export type ComfyProviderConfig = Record<string, unknown>;
type ComfyMode = "local" | "cloud";
type ComfyCapability = "image" | "music" | "video";
type ComfyOutputKind = "audio" | "gifs" | "images" | "videos";
type ComfyWorkflow = Record<string, unknown>;
type ComfyProviderConfig = Record<string, unknown>;
type ComfyFetchGuardParams = Parameters<typeof fetchWithSsrFGuard>[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";
}

View File

@@ -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[] = [
{

View File

@@ -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],

View File

@@ -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",

View File

@@ -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],

View File

@@ -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).",

View File

@@ -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 });
});
});

View File

@@ -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." };
}

View File

@@ -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({

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -19,12 +19,12 @@ type EmbeddingBatchExecutionParams = {
debug?: (message: string, data?: Record<string, unknown>) => 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;

View File

@@ -37,7 +37,7 @@ const GEMINI_MAX_INPUT_TOKENS: Record<string, number> = {
"gemini-embedding-2-preview": 8192,
};
export type GeminiTaskType = NonNullable<MemoryEmbeddingProviderCreateOptions["taskType"]>;
type GeminiTaskType = NonNullable<MemoryEmbeddingProviderCreateOptions["taskType"]>;
// --- 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<GeminiEmbeddingClient> {
const remote = options.remote;

View File

@@ -1,7 +1,7 @@
import { GoogleGenAI } from "@google/genai";
export type GoogleGenAIClient = InstanceType<typeof GoogleGenAI>;
export type GoogleGenAIOptions = ConstructorParameters<typeof GoogleGenAI>[0];
type GoogleGenAIOptions = ConstructorParameters<typeof GoogleGenAI>[0];
export function createGoogleGenAI(options: GoogleGenAIOptions): GoogleGenAIClient {
return new GoogleGenAI(options);

View File

@@ -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 };

View File

@@ -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 = [

View File

@@ -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";

View File

@@ -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 = {

View File

@@ -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<MigrationItem> {
if (!item.source || !item.target) {
return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET);

View File

@@ -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<string, string>;
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<MistralEmbeddingClient> {
return await resolveRemoteEmbeddingClient({

View File

@@ -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.";

View File

@@ -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",

View File

@@ -27,7 +27,7 @@ type EmbeddingBatchExecutionParams = {
debug?: (message: string, data?: Record<string, unknown>) => 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";

View File

@@ -26,7 +26,7 @@ const OPENAI_MAX_INPUT_TOKENS: Record<string, number> = {
"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<OpenAiEmbeddingClient> {
const client = await resolveRemoteEmbeddingClient({

View File

@@ -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({

View File

@@ -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<string, u
reasoning.effort = "low";
}
export function patchOpenAINativeWebSearchPayload(
payload: unknown,
): OpenAINativeWebSearchPatchResult {
function patchOpenAINativeWebSearchPayload(payload: unknown): OpenAINativeWebSearchPatchResult {
if (!isRecord(payload)) {
return "payload_not_object";
}

View File

@@ -4,8 +4,8 @@ import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"
const PROVIDER_ID = "opencode-go";
export const OPENCODE_GO_OPENAI_BASE_URL = "https://opencode.ai/zen/go/v1";
export const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go";
const OPENCODE_GO_OPENAI_BASE_URL = "https://opencode.ai/zen/go/v1";
const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go";
const OPENCODE_GO_SUPPLEMENTAL_MODELS = (
[

View File

@@ -21,10 +21,25 @@ import {
normalizeTarget as coreNormalizeTarget,
looksLikeQQBotTarget,
} from "./engine/messaging/target-parser.js";
// Re-export text helpers from core/.
export { chunkText, TEXT_CHUNK_LIMIT } from "./engine/utils/text-chunk.js";
import type { ResolvedQQBotAccount } from "./types.js";
/** Maximum text length for a single QQ Bot message. */
export const TEXT_CHUNK_LIMIT = 5000;
/**
* Naive text chunking fallback.
*
* The outbound pipeline normally uses `runtime.channel.text.chunkMarkdownText`;
* this remains exported for callers that need the legacy channel helper.
*/
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];
}
// Shared promise so concurrent multi-account startups serialize the dynamic
// import of the gateway module, avoiding an ESM circular-dependency race.
let _gatewayModulePromise: Promise<typeof import("./bridge/gateway.js")> | undefined;

View File

@@ -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];
}

View File

@@ -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<string, { baseUrl?: string } | undefined> } } | 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<VideoDescriptionResult> {
@@ -108,9 +86,3 @@ export function buildQwenMediaUnderstandingProvider(): MediaUnderstandingProvide
describeVideo: describeQwenVideo,
};
}
export function resolveQwenMediaUnderstandingBaseUrl(
cfg: { models?: { providers?: Record<string, { baseUrl?: string } | undefined> } } | undefined,
): string {
return resolveQwenStandardBaseUrl(cfg, "qwen");
}

View File

@@ -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}`;

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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],

View File

@@ -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",

View File

@@ -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;

View File

@@ -22,7 +22,7 @@ const VOYAGE_MAX_INPUT_TOKENS: Record<string, number> = {
"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<VoyageEmbeddingClient> {
const { baseUrl, headers, ssrfPolicy } = await resolveRemoteEmbeddingBearerClient({

View File

@@ -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"
},

View File

@@ -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}`;

View File

@@ -16,7 +16,7 @@ function resolveXaiSyntheticAuth(config: unknown) {
: undefined;
}
export const xaiProviderDiscovery: ProviderPlugin = {
const xaiProviderDiscovery: ProviderPlugin = {
id: PROVIDER_ID,
label: "xAI",
docsPath: "/providers/models",

View File

@@ -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;

View File

@@ -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<string, unknown>;
};
export type XaiTestPayload = Record<string, unknown> & {
type XaiTestPayload = Record<string, unknown> & {
tools?: Array<{ type?: string; function?: Record<string, unknown> }>;
input?: unknown[];
};
export function createXaiToolStreamPayload(): XaiTestPayload {
function createXaiToolStreamPayload(): XaiTestPayload {
return {
reasoning: { effort: "high" },
tools: [

View File

@@ -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;

View File

@@ -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<DetectZaiEndpointFn>
): ReturnType<DetectZaiEndpointFn> {

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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")) {

View File

@@ -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) {

View File

@@ -3,8 +3,7 @@
"model": {
"primary": "openai/gpt-4.1-mini"
},
"contextTokens": 64000,
"skills": ["memory"]
"contextTokens": 64000
},
"list": [
{

View File

@@ -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

View File

@@ -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`;

View File

@@ -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([

View File

@@ -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"

View File

@@ -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,

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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<AcpGatewayAgent["prompt"]>;
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"],

View File

@@ -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<typeof vi.fn>;
};

View File

@@ -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;

View File

@@ -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();

View File

@@ -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).",

View File

@@ -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: {

View File

@@ -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;
};

View File

@@ -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()

View File

@@ -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) =>

View File

@@ -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)
]);

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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 };
}

View File

@@ -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({

View File

@@ -241,6 +241,43 @@ function collectRequestedSpeechProviderIds(cfg: OpenClawConfig | undefined): Set
return requested;
}
function addMediaModelProviders(target: Set<string>, 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<string> {
const requested = new Set<string>();
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<string> | undefined {
switch (params.key) {
case "speechProviders":
return collectRequestedSpeechProviderIds(params.cfg);
case "mediaUnderstandingProviders":
return collectRequestedMediaUnderstandingProviderIds(params.cfg);
default:
return undefined;
}
}
function removeActiveProviderIds(requested: Set<string>, 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<K extends CapabilityProviderReg
requested: Set<string>;
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<K extends CapabilityProviderReg
const activeRegistry = resolveRuntimePluginRegistry();
const activeProviders = activeRegistry?.[params.key] ?? [];
if (
activeProviders.length > 0 &&
params.key !== "memoryEmbeddingProviders" &&
params.key !== "speechProviders"
) {
return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey<K>[];
}
if (activeProviders.length > 0 && params.key === "speechProviders" && !params.cfg) {
return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey<K>[];
}
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<K>[];
}
removeActiveProviderIds(missingRequestedProviders, activeProviders);
if (missingRequestedProviders.size === 0) {
return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey<K>[];
}
}
@@ -390,7 +420,7 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
activeProviders.length > 0
? filterLoadedProvidersForRequestedConfig({
key: params.key,
requested: missingRequestedSpeechProviders ?? new Set(),
requested: missingRequestedProviders ?? new Set(),
entries: loadedProviders,
})
: loadedProviders;

View File

@@ -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<typeof loadOpenClawPlugins>[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();

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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: {

View File

@@ -15,6 +15,17 @@ function withReleaseFixture<T>(releases: unknown[], fn: (file: string) => T): T
}
}
function withJsonFixture<T>(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",
]);
},
);
});
});
});