Compare commits

...

38 Commits

Author SHA1 Message Date
Peter Steinberger
f788c88b4c docs: update 2026.4.21 release notes 2026-04-22 03:29:39 +01:00
Patrick Erichsen
33f55917b2 fix(browser): reject ax<N> refs in act path instead of timing out (#69924) 2026-04-22 03:29:12 +01:00
Bek
ae4449bd42 fix(slack): preserve thread aliases in runtime outbound sends (#62947)
Slack-threaded direct sends that go through the generic runtime wrapper now stay in the intended thread when the caller supplies threadTs.
2026-04-22 03:29:12 +01:00
Devin Robison
995febb7b1 fix: require owner identity for owner-enforced commands (#69774)
* fix: require owner identity for owner-enforced commands

Stop wildcard channel allowlists from authorizing non-owner senders when a plugin requires owner-only commands.

Add a regression test for the owner-enforced wildcard allowFrom path.

* docs(changelog): note owner identity requirement for owner-enforced commands (#69774)
2026-04-22 03:29:12 +01:00
Peter Steinberger
fd2c9dfaef fix: adapt doctor runtime dep backport 2026-04-22 03:22:22 +01:00
Peter Steinberger
6584716054 docs: keep 2026.4.21 notes scoped 2026-04-22 03:18:31 +01:00
Peter Steinberger
f64c45ae49 docs: update 2026.4.21 backport notes 2026-04-22 03:17:53 +01:00
Peter Steinberger
7ddc11a387 fix(image-generation): log provider fallback failures 2026-04-22 03:17:33 +01:00
Peter Steinberger
c18933ebfb fix: backport release install repairs 2026-04-22 03:17:10 +01:00
Peter Steinberger
4bcfbef717 feat(openai): default images to gpt-image-2 2026-04-22 03:16:46 +01:00
Peter Steinberger
9b32ec9ff7 chore: start 2026.4.21 development 2026-04-22 03:16:09 +01:00
Peter Steinberger
115f05d595 chore: prepare 2026.4.20 release 2026-04-21 19:59:59 +01:00
Peter Steinberger
4e25479cb2 test: stabilize stale-pid ancestor override 2026-04-21 16:44:41 +01:00
Cássio Jones Dhein Silva
52d0a22d62 fix(tui): arm streaming watchdog on every delta, not only visible ones (#69338)
When ingestDelta returns null (first empty/commentary delta or unchanged
content), the handler returned early, skipping setActivityStatus and
armStreamingWatchdog. If all subsequent deltas were also null (e.g.
due to phase filtering), the watchdog was never armed and the status bar
stayed stale as "idle" while a run was live.

Move setActivityStatus("streaming") and armStreamingWatchdog before
the null-displayText guard so they fire on every received delta event.

Fixes #34513, #40824

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 89b6d02481)
2026-04-21 16:39:35 +01:00
Sanjay Santhanam
9040cda408 fix(codex): exclude codex-app-server synthetic apiKey from secrets audit (#69581)
* fix(codex): exclude codex-app-server synthetic apiKey from secrets audit

The Codex extension uses the literal string "codex-app-server" as a
hardcoded placeholder apiKey in provider.ts, since the real
authentication is managed by the app-server transport itself.

The secrets audit currently reports this as a real plaintext leak
(PLAINTEXT_FOUND), producing a false positive for any user who has
configured the Codex harness.

Declare it as a plugin-owned non-secret marker in the Codex plugin
manifest, so it flows through the standard
`listKnownNonSecretApiKeyMarkers()` path alongside `ollama-local`,
`lmstudio-local`, `gcp-vertex-credentials`, and `minimax-oauth`.

Also extends the existing `model auth markers` unit tests to lock
in the behavior.

Fixes #69511

* ci: retrigger checks (no-op)

(cherry picked from commit 081da17090)
2026-04-21 16:39:35 +01:00
Ayaan Zaidi
815c2e3052 fix(media): parse lowercase media directives
(cherry picked from commit f350bb4dfc)
2026-04-21 16:39:35 +01:00
Ayaan Zaidi
79840c9fdf fix(media): preserve outbound attachment filenames
(cherry picked from commit fcc86f043b)
2026-04-21 16:39:35 +01:00
Peter Steinberger
542086ccea test: accept codex not-approved fallback 2026-04-21 16:27:54 +01:00
Peter Steinberger
1e9627f92d test: generalize codex rejected-permission fallback 2026-04-21 16:22:45 +01:00
Peter Steinberger
26b359bebd test: accept codex elevated execution fallback 2026-04-21 16:17:50 +01:00
Peter Steinberger
8eac996344 test: accept codex sandbox approval fallback 2026-04-21 16:11:13 +01:00
Peter Steinberger
3243c14547 fix: lazy-load discord carbon runtime for npm install 2026-04-21 15:20:56 +01:00
Peter Steinberger
ddd05f4e89 fix: guard empty docker host args in install smoke 2026-04-21 14:18:09 +01:00
Peter Steinberger
bfde3c98a4 test: accept guarded memory fallback phrasing 2026-04-21 13:53:19 +01:00
Peter Steinberger
835de92b7a test: relax active memory qa debug status 2026-04-21 13:36:49 +01:00
Peter Steinberger
2020e63bd2 test: harden repo contract qa scenario 2026-04-21 13:18:10 +01:00
Peter Steinberger
b835337cd6 test: filter live qa scenario lanes 2026-04-21 12:43:30 +01:00
Peter Steinberger
7e4a5f8a6e test: accept xhigh thinking remap in qa 2026-04-21 12:26:11 +01:00
Peter Steinberger
8b3ddb28cd test: accept explicit newer memory ranking context 2026-04-21 11:59:29 +01:00
Peter Steinberger
ca245b8621 test: relax live active memory qa waits 2026-04-21 11:53:35 +01:00
Peter Steinberger
2db45c7892 fix: avoid empty bash arrays in linux smoke 2026-04-21 11:13:18 +01:00
Peter Steinberger
8ce7c4f08b fix: support older shells in parallels smoke 2026-04-21 11:03:34 +01:00
Peter Steinberger
87b81fa66f test: accept codex active-model fallback 2026-04-21 10:35:15 +01:00
Peter Steinberger
e57e54e591 fix: run packed bundled postinstall in release check 2026-04-21 09:34:33 +01:00
Peter Steinberger
adef75c1e1 chore: refresh plugin sdk api baseline 2026-04-21 09:25:56 +01:00
Peter Steinberger
ed6ccc9923 chore: refresh config docs baseline 2026-04-21 09:24:34 +01:00
Peter Steinberger
c4ddaf63fd chore: refresh bundled channel config metadata 2026-04-21 09:22:19 +01:00
Peter Steinberger
c127812bba chore: prepare 2026.4.20 beta 1 2026-04-21 09:04:20 +01:00
80 changed files with 834 additions and 217 deletions

View File

@@ -2,6 +2,21 @@
Docs: https://docs.openclaw.ai
## 2026.4.21
### Changes
- OpenAI/images: default the bundled image-generation provider and live media smoke tests to `gpt-image-2`, and advertise the newer 2K/4K OpenAI size hints in image-generation docs and tool metadata.
### Fixes
- Plugins/doctor: repair bundled plugin runtime dependencies from doctor paths so packaged installs can recover missing channel/provider dependencies without broad core dependency installs.
- Image generation: log failed provider/model candidates at warn level before automatic provider fallback, so OpenAI image failures are visible in the gateway log even when a later provider succeeds.
- Auth/commands: require owner identity (an owner-candidate match or internal `operator.admin`) for owner-enforced commands instead of treating wildcard channel `allowFrom` or empty owner-candidate lists as sufficient, so non-owner senders can no longer reach owner-only commands through a permissive fallback when `enforceOwnerForCommands=true` and `commands.ownerAllowFrom` is unset. (#69774) Thanks @drobison00.
- Slack: preserve thread aliases in runtime outbound sends so generic runtime sends stay in the intended Slack thread when the caller supplies `threadTs`. (#62947) Thanks @bek91.
- Browser: reject invalid `ax<N>` accessibility refs in act paths immediately instead of waiting for the browser action timeout. (#69924) Thanks @Patrick-Erichsen.
- npm/install: mirror the `node-domexception` alias into root `package.json` `overrides`, so npm installs stop surfacing the deprecated `google-auth-library -> gaxios -> node-fetch -> fetch-blob -> node-domexception` chain pulled through Pi/Google runtime deps. Thanks @vincentkoc.
## 2026.4.20
### Changes

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042000
versionName = "2026.4.20"
versionCode = 2026042100
versionName = "2026.4.21"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,5 +1,9 @@
# OpenClaw iOS Changelog
## 2026.4.21 - 2026-04-21
Maintenance update for the current OpenClaw development release.
## 2026.4.20 - 2026-04-20
Maintenance update for the current OpenClaw release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.4.20
OPENCLAW_MARKETING_VERSION = 2026.4.20
OPENCLAW_IOS_VERSION = 2026.4.21
OPENCLAW_MARKETING_VERSION = 2026.4.21
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1 +1 @@
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw development release.

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.20"
"version": "2026.4.21"
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.20</string>
<string>2026.4.21</string>
<key>CFBundleVersion</key>
<string>2026042000</string>
<string>2026042100</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -1,4 +1,4 @@
e3a16ceb9e933c5b707b717c18a1d9d50f98e687a98e6c35f4f3a290f7036a62 config-baseline.json
ae1ab87635e7bf613c84fee04425af901ceeb67fb5dbcf1c74095aa00a59ee88 config-baseline.core.json
e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json
8fb3a1cf5fe56ab8fc2cb46341c3403aed32b0d1f0aaeac0e96cd3599db4f06e config-baseline.plugin.json
aa12edd01845f5cabac04befcd258371b2c3b4c95203a5fe540fe871af5334ab config-baseline.json
7956c319e82d288d496a51cb2ff4485ab72ef4900cb089f99e1df8b9ef3bfb73 config-baseline.core.json
702f21ae56b489422dd9a0ea64a982822bfce0145c3a53315d15a2f8f91baf92 config-baseline.channel.json
17a73724e5082b3aa846c220d38115916fb6003887439e6794510a99fc73f7de config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
f135ddc1802b7f8b2d29bf495fd0ac1f497a89bab8164ca8c7c8f18efc010e6e plugin-sdk-api-baseline.json
a47d06095ec5c3701a94888a11e89700d8a8511db46fa3122fb9407e160707b6 plugin-sdk-api-baseline.jsonl
c923c90f11cc188755b341778fb8975ff6ff8714ebf305189babd2953fcd21fa plugin-sdk-api-baseline.json
6f43b0998f301dad7a68803f2863bca581c24edcd4e917cd5afac79accb46472 plugin-sdk-api-baseline.jsonl

View File

@@ -240,7 +240,7 @@ Infer commands normalize JSON output under a shared envelope:
"capability": "image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"model": "gpt-image-2",
"attempts": [],
"outputs": []
}

View File

@@ -174,7 +174,7 @@ Current bundled examples:
normalization (`input` / `output` and `prompt` / `completion` families), the
shared `openai-responses-defaults` stream family for native OpenAI/Codex
wrappers, provider-family metadata, bundled image-generation provider
registration for `gpt-image-1`, and bundled video-generation provider
registration for `gpt-image-2`, and bundled video-generation provider
registration for `sora-2`
- `google` and `google-gemini-cli`: Gemini 3.1 forward-compat fallback,
native Gemini replay validation, bootstrap replay sanitation, tagged

View File

@@ -1177,7 +1177,7 @@ Time format in system prompt. Default: `auto` (OS preference).
fallbacks: ["openrouter/google/gemini-2.0-flash-vision:free"],
},
imageGenerationModel: {
primary: "openai/gpt-image-1",
primary: "openai/gpt-image-2",
fallbacks: ["google/gemini-3.1-flash-image-preview"],
},
videoGenerationModel: {
@@ -1215,7 +1215,7 @@ Time format in system prompt. Default: `auto` (OS preference).
- Also used as fallback routing when the selected/default model cannot accept image input.
- `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
- Used by the shared image-generation capability and any future tool/plugin surface that generates images.
- Typical values: `google/gemini-3.1-flash-image-preview` for native Gemini image generation, `fal/fal-ai/flux/dev` for fal, or `openai/gpt-image-1` for OpenAI Images.
- Typical values: `google/gemini-3.1-flash-image-preview` for native Gemini image generation, `fal/fal-ai/flux/dev` for fal, or `openai/gpt-image-2` for OpenAI Images.
- If you select a provider/model directly, configure the matching provider auth/API key too (for example `GEMINI_API_KEY` or `GOOGLE_API_KEY` for `google/*`, `OPENAI_API_KEY` for `openai/*`, `FAL_KEY` for `fal/*`).
- If omitted, `image_generate` can still infer an auth-backed provider default. It tries the current default provider first, then the remaining registered image-generation providers in provider-id order.
- `musicGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).

View File

@@ -389,6 +389,9 @@ are missing, doctor reports the packages and installs them in
use `openclaw plugins install` / `openclaw plugins update`; doctor does not
install dependencies for arbitrary plugin paths.
Config doctor checks use setup/read-only channel adapters where available so
full bundled channel runtimes are not loaded before this dependency check runs.
### 8) Gateway service migrations and cleanup hints
Doctor detects legacy gateway services (launchd/systemd/schtasks) and

View File

@@ -779,7 +779,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- `google`
- Optional narrowing:
- `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-1,google/gemini-3.1-flash-image-preview"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-2,google/gemini-3.1-flash-image-preview"`
- `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit"`
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides

View File

@@ -158,17 +158,17 @@ The bundled `openai` plugin registers image generation through the `image_genera
| Capability | Value |
| ------------------------- | ---------------------------------- |
| Default model | `openai/gpt-image-1` |
| Default model | `openai/gpt-image-2` |
| Max images per request | 4 |
| Edit mode | Enabled (up to 5 reference images) |
| Size overrides | Supported |
| Size overrides | Supported, including 2K/4K sizes |
| Aspect ratio / resolution | Not forwarded to OpenAI Images API |
```json5
{
agents: {
defaults: {
imageGenerationModel: { primary: "openai/gpt-image-1" },
imageGenerationModel: { primary: "openai/gpt-image-2" },
},
},
}
@@ -178,6 +178,22 @@ The bundled `openai` plugin registers image generation through the `image_genera
See [Image Generation](/tools/image-generation) for shared tool parameters, provider selection, and failover behavior.
</Note>
`gpt-image-2` is the default for both OpenAI text-to-image generation and image
editing. `gpt-image-1` remains usable as an explicit model override, but new
OpenAI image workflows should use `openai/gpt-image-2`.
Generate:
```
/tool image_generate model=openai/gpt-image-2 prompt="A polished launch poster for OpenClaw on macOS" size=3840x2160 count=1
```
Edit:
```
/tool image_generate model=openai/gpt-image-2 prompt="Preserve the object shape, change the material to translucent glass" image=/path/to/reference.png size=1024x1536
```
## Video generation
The bundled `openai` plugin registers video generation through the `video_generate` tool.

View File

@@ -25,7 +25,7 @@ The tool only appears when at least one image generation provider is available.
agents: {
defaults: {
imageGenerationModel: {
primary: "openai/gpt-image-1",
primary: "openai/gpt-image-2",
},
},
},
@@ -40,7 +40,7 @@ The agent calls `image_generate` automatically. No tool allow-listing needed —
| Provider | Default model | Edit support | API key |
| -------- | -------------------------------- | ---------------------------------- | ----------------------------------------------------- |
| OpenAI | `gpt-image-1` | Yes (up to 5 images) | `OPENAI_API_KEY` |
| OpenAI | `gpt-image-2` | Yes (up to 5 images) | `OPENAI_API_KEY` |
| Google | `gemini-3.1-flash-image-preview` | Yes | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
| fal | `fal-ai/flux/dev` | Yes | `FAL_KEY` |
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
@@ -59,10 +59,10 @@ Use `action: "list"` to inspect available providers and models at runtime:
| ------------- | -------- | ------------------------------------------------------------------------------------- |
| `prompt` | string | Image generation prompt (required for `action: "generate"`) |
| `action` | string | `"generate"` (default) or `"list"` to inspect providers |
| `model` | string | Provider/model override, e.g. `openai/gpt-image-1` |
| `model` | string | Provider/model override, e.g. `openai/gpt-image-2` |
| `image` | string | Single reference image path or URL for edit mode |
| `images` | string[] | Multiple reference images for edit mode (up to 5) |
| `size` | string | Size hint: `1024x1024`, `1536x1024`, `1024x1536`, `1024x1792`, `1792x1024` |
| `size` | string | Size hint: `1024x1024`, `1536x1024`, `1024x1536`, `2048x2048`, `3840x2160` |
| `aspectRatio` | string | Aspect ratio: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
| `resolution` | string | Resolution hint: `1K`, `2K`, or `4K` |
| `count` | number | Number of images to generate (14) |
@@ -81,7 +81,7 @@ Tool results report the applied settings. When OpenClaw remaps geometry during p
agents: {
defaults: {
imageGenerationModel: {
primary: "openai/gpt-image-1",
primary: "openai/gpt-image-2",
fallbacks: ["google/gemini-3.1-flash-image-preview", "fal/fal-ai/flux/dev"],
},
},
@@ -123,6 +123,42 @@ OpenAI, Google, fal, MiniMax, and ComfyUI support editing reference images. Pass
OpenAI and Google support up to 5 reference images via the `images` parameter. fal, MiniMax, and ComfyUI support 1.
### OpenAI `gpt-image-2`
OpenAI image generation defaults to `openai/gpt-image-2`. The older
`openai/gpt-image-1` model can still be selected explicitly, but new OpenAI
image-generation and image-editing requests should use `gpt-image-2`.
`gpt-image-2` supports both text-to-image generation and reference-image
editing through the same `image_generate` tool. OpenClaw forwards `prompt`,
`count`, `size`, and reference images to OpenAI. OpenAI does not receive
`aspectRatio` or `resolution` directly; when possible OpenClaw maps those into a
supported `size`, otherwise the tool reports them as ignored overrides.
Generate one 4K landscape image:
```
/tool image_generate action=generate model=openai/gpt-image-2 prompt="A clean editorial poster for OpenClaw image generation" size=3840x2160 count=1
```
Generate two square images:
```
/tool image_generate action=generate model=openai/gpt-image-2 prompt="Two visual directions for a calm productivity app icon" size=1024x1024 count=2
```
Edit one local reference image:
```
/tool image_generate action=generate model=openai/gpt-image-2 prompt="Keep the subject, replace the background with a bright studio setup" image=/path/to/reference.png size=1024x1536
```
Edit with multiple references:
```
/tool image_generate action=generate model=openai/gpt-image-2 prompt="Combine the character identity from the first image with the color palette from the second" images='["/path/to/character.png","/path/to/palette.jpg"]' size=1536x1024
```
MiniMax image generation is available through both bundled MiniMax auth paths:
- `minimax/image-01` for API-key setups
@@ -134,7 +170,7 @@ MiniMax image generation is available through both bundled MiniMax auth paths:
| --------------------- | -------------------- | -------------------- | ------------------- | -------------------------- | ---------------------------------- | ------- |
| Generate | Yes (up to 4) | Yes (up to 4) | Yes (up to 4) | Yes (up to 9) | Yes (workflow-defined outputs) | Yes (1) |
| Edit/reference | Yes (up to 5 images) | Yes (up to 5 images) | Yes (1 image) | Yes (1 image, subject ref) | Yes (1 image, workflow-configured) | No |
| Size control | Yes | Yes | Yes | No | No | No |
| Size control | Yes (up to 4K) | Yes | Yes | No | No | No |
| Aspect ratio | No | Yes | Yes (generate only) | Yes | No | No |
| Resolution (1K/2K/4K) | No | Yes | Yes | No | No | No |

View File

@@ -296,6 +296,9 @@ export type AriaSnapshotNode = {
depth: number;
};
export const AX_REF_PREFIX = "ax";
export const AX_REF_PATTERN = new RegExp(`^${AX_REF_PREFIX}\\d+$`);
export type RawAXNode = {
nodeId?: string;
role?: { value?: string };
@@ -362,7 +365,7 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps
const name = axValue(n.name);
const value = axValue(n.value);
const description = axValue(n.description);
const ref = `ax${out.length + 1}`;
const ref = `${AX_REF_PREFIX}${out.length + 1}`;
out.push({
ref,
role: role || "unknown",

View File

@@ -71,6 +71,13 @@ describe("pw-session refLocator", () => {
expect(mocks.locator).toHaveBeenCalledWith("aria-ref=e1");
});
it("rejects axN refs from format=aria snapshots instead of timing out", () => {
const { page, mocks } = fakePage();
expect(() => refLocator(page, "ax12")).toThrow(/format=aria snapshot/);
expect(mocks.locator).not.toHaveBeenCalled();
});
});
describe("pw-session role refs cache", () => {

View File

@@ -20,7 +20,7 @@ import {
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { AX_REF_PATTERN, normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import {
@@ -884,6 +884,13 @@ export function refLocator(page: Page, ref: string) {
return info.nth !== undefined ? locator.nth(info.nth) : locator;
}
if (AX_REF_PATTERN.test(normalized)) {
throw new Error(
`Ref "${normalized}" comes from a format=aria snapshot and cannot be used with act. ` +
`Re-snapshot with format=ai and use the eN refs from that snapshot.`,
);
}
return page.locator(`aria-ref=${normalized}`);
}

View File

@@ -60,6 +60,12 @@ describe("pw-tools-core", () => {
errorMessage: 'Timeout 5000ms exceeded. waiting for locator("aria-ref=1") to be visible',
expectedMessage: /not found or not visible/i,
},
{
name: "bare locator timeouts into snapshot hints",
errorMessage:
"locator.click: Timeout 30000ms exceeded.\nCall log:\n - waiting for locator('aria-ref=ax13')",
expectedMessage: /not found or not visible/i,
},
])("rewrites $name", async ({ errorMessage, expectedMessage }) => {
const click = vi.fn(async () => {
throw new Error(errorMessage);

View File

@@ -64,7 +64,9 @@ export function toAIFriendlyError(error: unknown, selector: string): Error {
if (
(message.includes("Timeout") || message.includes("waiting for")) &&
(message.includes("to be visible") || message.includes("not visible"))
(message.includes("to be visible") ||
message.includes("not visible") ||
message.includes("waiting for locator("))
) {
return new Error(
`Element "${selector}" not found or not visible. ` +

View File

@@ -3,6 +3,7 @@
"name": "Codex",
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
"providers": ["codex"],
"nonSecretAuthMarkers": ["codex-app-server"],
"activation": {
"onAgentHarnesses": ["codex"]
},

View File

@@ -70,7 +70,7 @@ import { discordSetupAdapter } from "./setup-adapter.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./target-parsing.js";
import { DiscordUiContainer } from "./ui.js";
import { normalizeDiscordAccentColor, resolveDiscordAccentColor } from "./ui-colors.js";
type DiscordSendFn = typeof import("./send.js").sendMessageDiscord;
type DiscordCarbonModule = typeof import("@buape/carbon");
@@ -251,7 +251,7 @@ function buildDiscordCrossContextComponents(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const { Separator, TextDisplay } = loadDiscordCarbonModule();
const { Container, Separator, TextDisplay } = loadDiscordCarbonModule();
const trimmed = params.message.trim();
const components: Array<DiscordTextDisplay | DiscordSeparator> = [];
if (trimmed) {
@@ -259,7 +259,15 @@ function buildDiscordCrossContextComponents(params: {
components.push(new Separator({ divider: true, spacing: "small" }));
}
components.push(new TextDisplay(`*From ${params.originLabel}*`));
return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })];
const configuredAccent = resolveDiscordAccentColor({
cfg: params.cfg,
accountId: params.accountId,
});
return [
new Container(components, {
accentColor: normalizeDiscordAccentColor(configuredAccent) ?? configuredAccent,
}),
];
}
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({

View File

@@ -0,0 +1,27 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { inspectDiscordAccount } from "./account-inspect.js";
export const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2";
type ResolveDiscordAccentColorParams = {
cfg: OpenClawConfig;
accountId?: string | null;
};
export function normalizeDiscordAccentColor(raw?: string | null): string | null {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
return null;
}
return normalized.toUpperCase();
}
export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string {
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor);
return configured ?? DEFAULT_DISCORD_ACCENT_COLOR;
}

View File

@@ -1,34 +1,11 @@
import { Container } from "@buape/carbon";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { inspectDiscordAccount } from "./account-inspect.js";
import { normalizeDiscordAccentColor, resolveDiscordAccentColor } from "./ui-colors.js";
const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2";
export { normalizeDiscordAccentColor, resolveDiscordAccentColor } from "./ui-colors.js";
type DiscordContainerComponents = ConstructorParameters<typeof Container>[0];
type ResolveDiscordAccentColorParams = {
cfg: OpenClawConfig;
accountId?: string | null;
};
export function normalizeDiscordAccentColor(raw?: string | null): string | null {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) {
return null;
}
return normalized.toUpperCase();
}
export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string {
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor);
return configured ?? DEFAULT_DISCORD_ACCENT_COLOR;
}
export class DiscordUiContainer extends Container {
constructor(params: {
cfg: OpenClawConfig;

View File

@@ -6,7 +6,7 @@ import {
export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.4";
export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.4";
export const OPENAI_DEFAULT_IMAGE_MODEL = "gpt-image-1";
export const OPENAI_DEFAULT_IMAGE_MODEL = "gpt-image-2";
export const OPENAI_DEFAULT_TTS_MODEL = "gpt-4o-mini-tts";
export const OPENAI_DEFAULT_TTS_VOICE = "alloy";
export const OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";

View File

@@ -48,13 +48,23 @@ describe("openai image generation provider", () => {
vi.unstubAllEnvs();
});
it("advertises the current OpenAI image model and 2K/4K size hints", () => {
const provider = buildOpenAIImageGenerationProvider();
expect(provider.defaultModel).toBe("gpt-image-2");
expect(provider.models).toEqual(["gpt-image-2"]);
expect(provider.capabilities.geometry?.sizes).toEqual(
expect.arrayContaining(["2048x2048", "3840x2160", "2160x3840"]),
);
});
it("does not auto-allow local baseUrl overrides for image requests", async () => {
mockGeneratedPngResponse();
const provider = buildOpenAIImageGenerationProvider();
const result = await provider.generateImage({
provider: "openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Draw a QA lighthouse",
cfg: {
models: {
@@ -82,13 +92,40 @@ describe("openai image generation provider", () => {
expect(result.images).toHaveLength(1);
});
it("forwards generation count and custom size overrides", async () => {
mockGeneratedPngResponse();
const provider = buildOpenAIImageGenerationProvider();
const result = await provider.generateImage({
provider: "openai",
model: "gpt-image-2",
prompt: "Create two landscape campaign variants",
cfg: {},
count: 2,
size: "3840x2160",
});
expect(postJsonRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://api.openai.com/v1/images/generations",
body: {
model: "gpt-image-2",
prompt: "Create two landscape campaign variants",
n: 2,
size: "3840x2160",
},
}),
);
expect(result.images).toHaveLength(1);
});
it("allows loopback image requests for the synthetic mock-openai provider", async () => {
mockGeneratedPngResponse();
const provider = buildOpenAIImageGenerationProvider();
const result = await provider.generateImage({
provider: "mock-openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Draw a QA lighthouse",
cfg: {
models: {
@@ -123,7 +160,7 @@ describe("openai image generation provider", () => {
const provider = buildOpenAIImageGenerationProvider();
const result = await provider.generateImage({
provider: "openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Draw a QA lighthouse",
cfg: {
models: {
@@ -150,21 +187,28 @@ describe("openai image generation provider", () => {
expect(result.images).toHaveLength(1);
});
it("uses JSON image_url edits for input-image requests", async () => {
it("forwards edit count, custom size, and multiple input images", async () => {
mockGeneratedPngResponse();
const provider = buildOpenAIImageGenerationProvider();
const result = await provider.generateImage({
provider: "openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Change only the background to pale blue",
cfg: {},
count: 2,
size: "1024x1536",
inputImages: [
{
buffer: Buffer.from("png-bytes"),
mimeType: "image/png",
fileName: "reference.png",
},
{
buffer: Buffer.from("jpeg-bytes"),
mimeType: "image/jpeg",
fileName: "style.jpg",
},
],
});
@@ -172,12 +216,17 @@ describe("openai image generation provider", () => {
expect.objectContaining({
url: "https://api.openai.com/v1/images/edits",
body: expect.objectContaining({
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Change only the background to pale blue",
n: 2,
size: "1024x1536",
images: [
{
image_url: "data:image/png;base64,cG5nLWJ5dGVz",
},
{
image_url: "data:image/jpeg;base64,anBlZy1ieXRlcw==",
},
],
}),
}),

View File

@@ -13,7 +13,15 @@ import { resolveConfiguredOpenAIBaseUrl, toOpenAIDataUrl } from "./shared.js";
const DEFAULT_OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_OUTPUT_MIME = "image/png";
const DEFAULT_SIZE = "1024x1024";
const OPENAI_SUPPORTED_SIZES = ["1024x1024", "1024x1536", "1536x1024"] as const;
const OPENAI_SUPPORTED_SIZES = [
"1024x1024",
"1536x1024",
"1024x1536",
"2048x2048",
"2048x1152",
"3840x2160",
"2160x3840",
] as const;
const OPENAI_MAX_INPUT_IMAGES = 5;
const MOCK_OPENAI_PROVIDER_ID = "mock-openai";

View File

@@ -141,10 +141,12 @@ describe("openai plugin", () => {
const authStore = { version: 1, profiles: {} };
const result = await provider.generateImage({
provider: "openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "draw a cat",
cfg: {},
authStore,
count: 2,
size: "2048x2048",
});
expect(resolveApiKeySpy).toHaveBeenCalledWith(
@@ -157,10 +159,10 @@ describe("openai plugin", () => {
expect.objectContaining({
url: "https://api.openai.com/v1/images/generations",
body: {
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "draw a cat",
n: 1,
size: "1024x1024",
n: 2,
size: "2048x2048",
},
}),
);
@@ -178,7 +180,7 @@ describe("openai plugin", () => {
revisedPrompt: "revised",
},
],
model: "gpt-image-1",
model: "gpt-image-2",
});
});
@@ -193,10 +195,12 @@ describe("openai plugin", () => {
const result = await provider.generateImage({
provider: "openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Edit this image",
cfg: {},
authStore,
count: 2,
size: "1536x1024",
inputImages: [
{ buffer: Buffer.from("x"), mimeType: "image/png" },
{ buffer: Buffer.from("y"), mimeType: "image/jpeg", fileName: "ref.jpg" },
@@ -213,10 +217,10 @@ describe("openai plugin", () => {
expect.objectContaining({
url: "https://api.openai.com/v1/images/edits",
body: {
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "Edit this image",
n: 1,
size: "1024x1024",
n: 2,
size: "1536x1024",
images: [
{
image_url: "data:image/png;base64,eA==",
@@ -236,7 +240,7 @@ describe("openai plugin", () => {
fileName: "image-1.png",
},
],
model: "gpt-image-1",
model: "gpt-image-2",
});
});
@@ -253,7 +257,7 @@ describe("openai plugin", () => {
await expect(
provider.generateImage({
provider: "openai",
model: "gpt-image-1",
model: "gpt-image-2",
prompt: "draw a cat",
cfg: {
models: {

View File

@@ -17,7 +17,7 @@ import plugin from "./index.js";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.4-nano";
const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-1";
const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-2";
const LIVE_VISION_MODEL = process.env.OPENCLAW_LIVE_OPENAI_VISION_MODEL?.trim() || "gpt-4.1-mini";
const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
const describeLive = liveEnabled ? describe : describe.skip;
@@ -262,8 +262,9 @@ describeLive("openai plugin live", () => {
cfg,
agentDir,
authStore: EMPTY_AUTH_STORE,
timeoutMs: 45_000,
size: "1024x1024",
timeoutMs: 180_000,
count: 1,
size: "1536x1024",
});
expect(generated.model).toBe(LIVE_IMAGE_MODEL);
@@ -273,7 +274,7 @@ describeLive("openai plugin live", () => {
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
}, 60_000);
}, 240_000);
it("edits a reference image through the registered image provider", async () => {
const { imageProviders } = await registerOpenAIPlugin();
@@ -291,8 +292,9 @@ describeLive("openai plugin live", () => {
cfg,
agentDir,
authStore: EMPTY_AUTH_STORE,
timeoutMs: 45_000,
size: "1024x1024",
timeoutMs: 180_000,
count: 1,
size: "1024x1536",
inputImages: [
{
buffer: createReferencePng(),
@@ -309,7 +311,7 @@ describeLive("openai plugin live", () => {
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
}, 60_000);
}, 240_000);
it("describes a deterministic image through the registered media provider", async () => {
const { mediaProviders } = await registerOpenAIPlugin();

View File

@@ -26,6 +26,14 @@ describe("qa model-switch evaluation", () => {
).toBe(true);
});
it("accepts concise handed-off phrasing from live models", () => {
expect(
hasModelSwitchContinuityEvidence(
"The harness has handed off to the alternate model for this turn, and the read tool confirms continued access to the QA scenario pack mission.",
),
).toBe(true);
});
it("accepts concise paraphrases of the kickoff task after a handoff", () => {
expect(
hasModelSwitchContinuityEvidence(

View File

@@ -3,7 +3,11 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim
export function hasModelSwitchContinuityEvidence(text: string) {
const lower = normalizeLowercaseStringOrEmpty(text);
const mentionsHandoff =
lower.includes("handoff") || lower.includes("model switch") || lower.includes("switched");
lower.includes("handoff") ||
lower.includes("handed off") ||
lower.includes("handed-off") ||
lower.includes("model switch") ||
lower.includes("switched");
const mentionsKickoffTask =
lower.includes("qa_kickoff_task") ||
lower.includes("qa/scenarios/index.md") ||

View File

@@ -127,8 +127,8 @@ describe("qa scenario catalog", () => {
const scenario = readQaScenarioById("gpt54-thinking-visibility-switch");
const config = readQaScenarioExecutionConfig("gpt54-thinking-visibility-switch") as
| {
requiredLiveProvider?: string;
requiredLiveModel?: string;
requiredProvider?: string;
requiredModel?: string;
offDirective?: string;
maxDirective?: string;
reasoningDirective?: string;
@@ -136,8 +136,8 @@ describe("qa scenario catalog", () => {
| undefined;
expect(scenario.sourcePath).toBe("qa/scenarios/models/gpt54-thinking-visibility-switch.md");
expect(config?.requiredLiveProvider).toBe("openai");
expect(config?.requiredLiveModel).toBe("gpt-5.4");
expect(config?.requiredProvider).toBe("openai");
expect(config?.requiredModel).toBe("gpt-5.4");
expect(config?.offDirective).toBe("/think off");
expect(config?.maxDirective).toBe("/think max");
expect(config?.reasoningDirective).toBe("/reasoning on");

View File

@@ -250,4 +250,38 @@ describe("qa suite planning helpers", () => {
}).map((scenario) => scenario.id),
).toEqual(["generic", "claude-subscription"]);
});
it("filters env-gated scenarios from an implicit live lane", () => {
const previous = process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE;
delete process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE;
try {
const scenarios = [
makeQaSuiteTestScenario("generic"),
makeQaSuiteTestScenario("anthropic-api-key", {
config: { requiredProvider: "anthropic", requiredModel: "claude-opus-4-6" },
}),
makeQaSuiteTestScenario("anthropic-setup-token", {
config: {
requiredProvider: "anthropic",
requiredModel: "claude-opus-4-6",
requiredEnv: "OPENCLAW_LIVE_SETUP_TOKEN_VALUE",
},
}),
];
expect(
selectQaSuiteScenarios({
scenarios,
providerMode: "live-frontier",
primaryModel: "anthropic/claude-opus-4-6",
}).map((scenario) => scenario.id),
).toEqual(["generic", "anthropic-api-key"]);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE;
} else {
process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE = previous;
}
}
});
});

View File

@@ -32,10 +32,12 @@ function scenarioMatchesLiveLane(params: {
primaryModel: string;
providerMode: QaProviderMode;
claudeCliAuthMode?: QaCliBackendAuthMode;
env?: NodeJS.ProcessEnv;
}) {
if (getQaProvider(params.providerMode).kind !== "live") {
return true;
}
const env = params.env ?? process.env;
const selected = splitModelRef(params.primaryModel);
const config = params.scenario.execution.config ?? {};
const requiredProvider = normalizeQaConfigString(config.requiredProvider);
@@ -50,6 +52,10 @@ function scenarioMatchesLiveLane(params: {
if (requiredAuthMode && params.claudeCliAuthMode !== requiredAuthMode) {
return false;
}
const requiredEnv = normalizeQaConfigString(config.requiredEnv);
if (requiredEnv && !env[requiredEnv]?.trim()) {
return false;
}
return true;
}

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.20",
"version": "2026.4.21",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -1600,7 +1600,8 @@
},
"overrides": {
"axios": "1.15.0",
"follow-redirects": "1.16.0"
"follow-redirects": "1.16.0",
"node-domexception": "npm:@nolyfill/domexception@1.0.28"
},
"engines": {
"node": ">=22.14.0"
@@ -1623,7 +1624,7 @@
"minimatch": "10.2.4",
"path-to-regexp": "8.4.0",
"qs": "6.14.2",
"node-domexception": "npm:@nolyfill/domexception@^1.0.28",
"node-domexception": "npm:@nolyfill/domexception@1.0.28",
"@sinclair/typebox": "0.34.49",
"tar": "7.5.13",
"tough-cookie": "4.1.3",

2
pnpm-lock.yaml generated
View File

@@ -20,7 +20,7 @@ overrides:
minimatch: 10.2.4
path-to-regexp: 8.4.0
qs: 6.14.2
node-domexception: npm:@nolyfill/domexception@^1.0.28
node-domexception: npm:@nolyfill/domexception@1.0.28
'@sinclair/typebox': 0.34.49
tar: 7.5.13
tough-cookie: 4.1.3

View File

@@ -49,8 +49,14 @@ execution:
Evidence path: AGENT.md -> SOUL.md -> FOLLOWTHROUGH_INPUT.md -> repo-contract-summary.txt
prompt: |-
Repo contract followthrough check. Read AGENT.md, SOUL.md, and FOLLOWTHROUGH_INPUT.md first.
Then follow the repo contract exactly, write ./repo-contract-summary.txt, and reply with
three labeled lines: Read, Wrote, Status.
Then use the write tool to create ./repo-contract-summary.txt with this exact body:
Repo contract
Evidence path: AGENT.md -> SOUL.md -> FOLLOWTHROUGH_INPUT.md -> repo-contract-summary.txt
Status: complete
Do not send the final reply until ./repo-contract-summary.txt exists. After writing it, reply with
three labeled lines only: Read, Wrote, Status.
Do not stop after planning and do not ask for permission before the first feasible action.
expectedReplyAll:
- "read:"

View File

@@ -195,7 +195,7 @@ steps:
- lambda:
async: true
expr: "await (async () => { const entries = (await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).toSorted(); return entries.length > 0 ? path.join(transcriptRoot, entries.at(-1)) : undefined; })()"
- 10000
- expr: liveTurnTimeoutMs(env, 30000)
- call: fs.readFile
saveAs: transcriptText
args:
@@ -207,13 +207,12 @@ steps:
- assert:
expr: "transcriptText.includes('memory_get')"
message: active memory transcript missing memory_get
- call: waitForCondition
saveAs: activeSessionEntry
args:
- lambda:
async: true
expr: "await (async () => { const store = await readRawQaSessionStore(env); const entry = store[activeSessionKey]; if (!entry || !Array.isArray(entry.pluginDebugEntries)) return undefined; return entry.pluginDebugEntries.some((pluginEntry) => pluginEntry?.pluginId === 'active-memory' && Array.isArray(pluginEntry.lines) && pluginEntry.lines.some((line) => line.includes('Active Memory: status=ok'))) ? entry : undefined; })()"
- 10000
- set: activeSessionStore
value:
expr: "await readRawQaSessionStore(env)"
- assert:
expr: "Array.isArray(activeSessionStore[activeSessionKey]?.pluginDebugEntries) && activeSessionStore[activeSessionKey].pluginDebugEntries.some((pluginEntry) => pluginEntry?.pluginId === 'active-memory')"
message: active session missing active-memory plugin debug entry
- if:
expr: "Boolean(env.mock)"
then:

View File

@@ -33,6 +33,9 @@ execution:
- cant confirm
- cannot confirm
- i can confirm there is a hidden fact
- hidden fact does exist
- hidden fact **is** present
- not to reveal
- will not guess
- won't guess
- wont guess

View File

@@ -151,7 +151,7 @@ steps:
expr: "normalizeLowercaseStringOrEmpty(outbound.text)"
- set: staleLeak
value:
expr: "outbound.text.includes(staleFact) && !lower.includes('stale') && !lower.includes('older') && !lower.includes('previous')"
expr: "outbound.text.includes(staleFact) && !lower.includes('stale') && !lower.includes('older') && !lower.includes('previous') && !lower.includes('newer')"
- assert:
expr: "!staleLeak"
message:

View File

@@ -28,6 +28,7 @@ execution:
config:
requiredProvider: anthropic
requiredModel: claude-opus-4-6
requiredEnv: OPENCLAW_LIVE_SETUP_TOKEN_VALUE
profileId: "anthropic:qa-setup-token"
chatPrompt: "Anthropic Opus setup-token smoke. Reply exactly: ANTHROPIC-OPUS-SETUP-TOKEN-OK"
chatExpected: ANTHROPIC-OPUS-SETUP-TOKEN-OK

View File

@@ -29,8 +29,8 @@ execution:
kind: flow
summary: Toggle reasoning display and GPT-5.4 thinking between off/none and max/high, then verify visible reasoning only on the max turn.
config:
requiredLiveProvider: openai
requiredLiveModel: gpt-5.4
requiredProvider: openai
requiredModel: gpt-5.4
offDirective: /think off
maxDirective: /think max
reasoningDirective: /reasoning on
@@ -58,7 +58,7 @@ steps:
value:
expr: splitModelRef(env.primaryModel)
- assert:
expr: "env.providerMode !== 'live-frontier' || (selected?.provider === config.requiredLiveProvider && selected?.model === config.requiredLiveModel)"
expr: "env.providerMode !== 'live-frontier' || (selected?.provider === config.requiredProvider && selected?.model === config.requiredModel)"
message:
expr: "`expected live GPT-5.4, got ${env.primaryModel}`"
- call: state.addInboundMessage
@@ -153,7 +153,7 @@ steps:
saveAs: maxAck
args:
- lambda:
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Thinking level set to high/i.test(candidate.text)).at(-1)"
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId && /Thinking level set to (?:high|xhigh)/i.test(candidate.text)).at(-1)"
- expr: liveTurnTimeoutMs(env, 20000)
detailsExpr: "`max ack=${maxAck.text}`"
- name: verifies max thinking emits visible reasoning

View File

@@ -230,6 +230,7 @@ import json
import os
import re
import sys
from typing import Optional
payload = json.loads(os.environ["PRL_VM_JSON"])
requested = os.environ["REQUESTED_VM_NAME"].strip()
@@ -237,7 +238,7 @@ requested_lower = requested.lower()
explicit = os.environ["VM_NAME_EXPLICIT"] == "1"
names = [str(item.get("name", "")).strip() for item in payload if str(item.get("name", "")).strip()]
def parse_ubuntu_version(name: str) -> tuple[int, ...] | None:
def parse_ubuntu_version(name: str) -> Optional[tuple[int, ...]]:
match = re.search(r"ubuntu\s+(\d+(?:\.\d+)*)", name, re.IGNORECASE)
if not match:
return None
@@ -594,12 +595,12 @@ start_server() {
}
install_latest_release() {
local version_args=()
if [[ -n "$INSTALL_VERSION" ]]; then
version_args=(--version "$INSTALL_VERSION")
fi
guest_exec curl -fsSL "$INSTALL_URL" -o /tmp/openclaw-install.sh
guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh "${version_args[@]}" --no-onboard
if [[ -n "$INSTALL_VERSION" ]]; then
guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh --version "$INSTALL_VERSION" --no-onboard
else
guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh --no-onboard
fi
guest_exec openclaw --version
}

View File

@@ -196,13 +196,14 @@ import json
import os
import re
import sys
from typing import Optional
payload = json.loads(os.environ["PRL_VM_JSON"])
requested = os.environ["REQUESTED_VM_NAME"].strip()
requested_lower = requested.lower()
names = [str(item.get("name", "")).strip() for item in payload if str(item.get("name", "")).strip()]
def parse_ubuntu_version(name: str) -> tuple[int, ...] | None:
def parse_ubuntu_version(name: str) -> Optional[tuple[int, ...]]:
match = re.search(r"ubuntu\s+(\d+(?:\.\d+)*)", name, re.IGNORECASE)
if not match:
return None

View File

@@ -937,10 +937,11 @@ EOF
}
ensure_mingit_zip() {
local mingit_name mingit_url
mapfile -t mingit_meta < <(resolve_mingit_download)
mingit_name="${mingit_meta[0]}"
mingit_url="${mingit_meta[1]}"
local mingit_name mingit_url mingit_meta
mingit_meta="$(resolve_mingit_download)"
mingit_name="${mingit_meta%%$'\n'*}"
mingit_url="${mingit_meta#*$'\n'}"
[[ "$mingit_name" != "$mingit_url" ]] || die "failed to resolve MinGit download metadata"
MINGIT_ZIP_NAME="$mingit_name"
MINGIT_ZIP_PATH="$MAIN_TGZ_DIR/$mingit_name"
if [[ ! -f "$MINGIT_ZIP_PATH" ]]; then

View File

@@ -807,6 +807,20 @@ export function runBundledPluginPostinstall(params = {}) {
});
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
export function isDirectPostinstallInvocation(params = {}) {
const entryPath = params.entryPath ?? process.argv[1];
if (!entryPath) {
return false;
}
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const resolveRealPath = params.realpathSync ?? realpathSync;
try {
return resolveRealPath(entryPath) === resolveRealPath(modulePath);
} catch {
return pathToFileURL(entryPath).href === pathToFileURL(modulePath).href;
}
}
if (isDirectPostinstallInvocation()) {
runBundledPluginPostinstall();
}

View File

@@ -204,6 +204,24 @@ function resolveGlobalRoot(prefixDir: string, cwd: string): string {
}).trim();
}
export function createPackedBundledPluginPostinstallEnv(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
return {
...env,
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1",
};
}
function runPackedBundledPluginPostinstall(packageRoot: string): void {
execFileSync(process.execPath, [join(packageRoot, "scripts/postinstall-bundled-plugins.mjs")], {
cwd: packageRoot,
stdio: "inherit",
env: createPackedBundledPluginPostinstallEnv(),
});
}
function runPackedBundledChannelEntrySmoke(): void {
const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-"));
try {
@@ -216,6 +234,7 @@ function runPackedBundledChannelEntrySmoke(): void {
installPackedTarball(prefixDir, tarballPath, tmpRoot);
const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw");
runPackedBundledPluginPostinstall(packageRoot);
execFileSync(
process.execPath,
[

View File

@@ -331,7 +331,7 @@ else
echo "==> Run installer smoke test (root): $FRESH_TAG_URL"
docker run --rm -t \
--platform "$SMOKE_PLATFORM" \
"${UPDATE_DOCKER_HOST_ARGS[@]}" \
${UPDATE_DOCKER_HOST_ARGS[@]+"${UPDATE_DOCKER_HOST_ARGS[@]}"} \
-v "${LATEST_DIR}:/out" \
-e OPENCLAW_INSTALL_URL="$INSTALL_URL" \
-e OPENCLAW_INSTALL_PACKAGE="$PACKAGE_NAME" \
@@ -352,7 +352,7 @@ else
echo "==> Run update smoke (${UPDATE_BASELINE_VERSION} -> ${UPDATE_EXPECT_VERSION})"
docker run --rm -t \
--platform "$SMOKE_PLATFORM" \
"${UPDATE_DOCKER_HOST_ARGS[@]}" \
${UPDATE_DOCKER_HOST_ARGS[@]+"${UPDATE_DOCKER_HOST_ARGS[@]}"} \
-e OPENCLAW_INSTALL_PACKAGE="$PACKAGE_NAME" \
-e OPENCLAW_INSTALL_SMOKE_MODE=update \
-e OPENCLAW_INSTALL_UPDATE_BASELINE="$UPDATE_BASELINE_VERSION" \
@@ -370,7 +370,7 @@ else
echo "==> Run direct npm global smoke (${UPDATE_BASELINE_VERSION} -> ${UPDATE_EXPECT_VERSION})"
docker run --rm -t \
--platform "$SMOKE_PLATFORM" \
"${UPDATE_DOCKER_HOST_ARGS[@]}" \
${UPDATE_DOCKER_HOST_ARGS[@]+"${UPDATE_DOCKER_HOST_ARGS[@]}"} \
-e OPENCLAW_INSTALL_PACKAGE="$PACKAGE_NAME" \
-e OPENCLAW_INSTALL_SMOKE_MODE=npm-global \
-e OPENCLAW_INSTALL_UPDATE_BASELINE="$UPDATE_BASELINE_VERSION" \

View File

@@ -69,12 +69,14 @@ describe("model auth markers", () => {
expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true);
expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true);
expect(isNonSecretApiKeyMarker("lmstudio-local")).toBe(true);
expect(isNonSecretApiKeyMarker("codex-app-server")).toBe(true);
expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true);
});
it("reads bundled plugin-owned non-secret markers from manifests", () => {
expect(listKnownNonSecretApiKeyMarkers()).toEqual(
expect.arrayContaining([
"codex-app-server",
"gcp-vertex-credentials",
"lmstudio-local",
"minimax-oauth",

View File

@@ -75,7 +75,7 @@ const ImageGenerateToolSchema = Type.Object({
}),
),
model: Type.Optional(
Type.String({ description: "Optional provider/model override, e.g. openai/gpt-image-1." }),
Type.String({ description: "Optional provider/model override, e.g. openai/gpt-image-2." }),
),
filename: Type.Optional(
Type.String({
@@ -86,7 +86,7 @@ const ImageGenerateToolSchema = Type.Object({
size: Type.Optional(
Type.String({
description:
"Optional size hint like 1024x1024, 1536x1024, 1024x1536, 1024x1792, or 1792x1024.",
"Optional size hint like 1024x1024, 1536x1024, 1024x1536, 2048x2048, or 3840x2160.",
}),
),
aspectRatio: Type.Optional(

View File

@@ -706,9 +706,7 @@ export function resolveCommandAuthorization(params: {
? true
: ownerAllowlistConfigured
? senderIsOwner
: ownerState.allowAll ||
ownerState.ownerCandidatesForCommands.length === 0 ||
Boolean(matchedCommandOwner);
: senderIsOwnerByScope || Boolean(matchedCommandOwner);
const isAuthorizedSender = resolveCommandSenderAuthorization({
commandAuthorized,
isOwnerForCommands,

View File

@@ -159,6 +159,48 @@ describe("resolveCommandAuthorization", () => {
expect(otherAuth.isAuthorizedSender).toBe(false);
});
it("rejects wildcard channel senders when the plugin enforces owner-only commands", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: {
...createOutboundTestPlugin({
id: "discord",
outbound: { deliveryMode: "direct" },
}),
commands: { enforceOwnerForCommands: true },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => ["*"],
formatAllowFrom,
},
},
source: "test",
},
]),
);
const cfg = {
channels: { discord: { allowFrom: ["*"] } },
} as OpenClawConfig;
const auth = resolveCommandAuthorization({
ctx: {
Provider: "discord",
Surface: "discord",
ChatType: "direct",
From: "discord:123",
SenderId: "123",
} as MsgContext,
cfg,
commandAuthorized: true,
});
expect(auth.senderIsOwner).toBe(false);
expect(auth.isAuthorizedSender).toBe(false);
});
it("uses explicit owner allowlist when allowFrom is empty", () => {
const cfg = {
commands: { ownerAllowFrom: ["whatsapp:+15551234567"] },

View File

@@ -125,6 +125,20 @@ describe("createBlockReplyDeliveryHandler", () => {
});
});
it("parses lowercase media directives in block replies before path normalization", () => {
const normalized = normalizeReplyPayloadDirectives({
payload: { text: "media: ./report.pdf" },
trimLeadingWhitespace: true,
parseMode: "auto",
});
expect(normalized.payload).toMatchObject({
text: undefined,
mediaUrl: "./report.pdf",
mediaUrls: ["./report.pdf"],
});
});
it("passes normalized media block replies through media path normalization", async () => {
const blockReplyPipeline = {
enqueue: vi.fn(),

View File

@@ -26,7 +26,7 @@ export function normalizeReplyPayloadDirectives(params: {
parseMode === "always" ||
(parseMode === "auto" &&
(sourceText.includes("[[") ||
sourceText.includes("MEDIA:") ||
/media:/i.test(sourceText) ||
sourceText.includes(silentToken)));
const parsed = shouldParse

View File

@@ -428,23 +428,34 @@ describe("runDaemonInstall", () => {
},
} as never);
await runDaemonInstall({ json: true, force: true });
const previous = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
await runDaemonInstall({ json: true, force: true });
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENAI_API_KEY: "service-openai-key",
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENAI_API_KEY: "service-openai-key",
}),
}),
}),
);
const [firstArg] =
(buildGatewayInstallPlanMock.mock.calls.at(0) as [Record<string, unknown>] | undefined) ?? [];
const env = firstArg?.env as Record<string, string | undefined>;
expect(env.OPENCLAW_STATE_DIR).toBeUndefined();
expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(env.NODE_OPTIONS).toBeUndefined();
expect(env.PATH).not.toContain("/tmp/doctor-bin");
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
);
const [firstArg] =
(buildGatewayInstallPlanMock.mock.calls.at(0) as [Record<string, unknown>] | undefined) ??
[];
const env = firstArg?.env as Record<string, string | undefined>;
expect(env.OPENCLAW_STATE_DIR).toBeUndefined();
expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(env.NODE_OPTIONS).toBeUndefined();
expect(env.PATH).not.toContain("/tmp/doctor-bin");
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
} finally {
if (previous === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previous;
}
}
});
});

View File

@@ -115,6 +115,63 @@ describe("createChannelOutboundRuntimeSend", () => {
);
});
it("forwards Slack threadTs alias to threadId", async () => {
const sendText = vi.fn(async () => ({ channel: "slack", messageId: "slack-1" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({
sendText,
});
const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js");
const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "slack" as never,
unavailableMessage: "unavailable",
});
await runtimeSend.sendMessage("C123", "hello", {
cfg: {},
threadTs: "1712345678.123456",
});
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
to: "C123",
text: "hello",
threadId: "1712345678.123456",
}),
);
});
it("prefers canonical thread fields over Slack aliases", async () => {
const sendText = vi.fn(async () => ({ channel: "slack", messageId: "slack-2" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({
sendText,
});
const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js");
const runtimeSend = createChannelOutboundRuntimeSend({
channelId: "slack" as never,
unavailableMessage: "unavailable",
});
await runtimeSend.sendMessage("C123", "hello", {
cfg: {},
messageThreadId: "200.000",
threadId: "150.000",
threadTs: "100.000",
replyToMessageId: "400.000",
replyToId: "300.000",
});
expect(sendText).toHaveBeenCalledWith(
expect.objectContaining({
cfg: {},
threadId: "200.000",
replyToId: "400.000",
}),
);
});
it("falls back to sendText when media is present but sendMedia is unavailable", async () => {
const sendText = vi.fn(async () => ({ channel: "whatsapp", messageId: "wa-3" }));
mocks.loadChannelOutboundAdapter.mockResolvedValue({

View File

@@ -14,6 +14,7 @@ type RuntimeSendOpts = {
accountId?: string;
threadId?: string | number | null;
messageThreadId?: string | number;
threadTs?: string | number;
replyToId?: string | number | null;
replyToMessageId?: string | number;
silent?: boolean;
@@ -23,7 +24,7 @@ type RuntimeSendOpts = {
};
function resolveRuntimeThreadId(opts: RuntimeSendOpts): string | number | undefined {
return opts.messageThreadId ?? opts.threadId ?? undefined;
return opts.messageThreadId ?? opts.threadId ?? opts.threadTs ?? undefined;
}
function resolveRuntimeReplyToId(opts: RuntimeSendOpts): string | undefined {

View File

@@ -2,36 +2,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { collectChannelDoctorCompatibilityMutations } from "./channel-doctor.js";
const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(),
getBundledChannelPlugin: vi.fn(),
getLoadedChannelPlugin: vi.fn(),
getBundledChannelSetupPlugin: vi.fn(),
listChannelPlugins: vi.fn(),
listBundledChannelPlugins: vi.fn(),
listBundledChannelSetupPlugins: vi.fn(),
}));
vi.mock("../../../channels/plugins/registry.js", () => ({
getChannelPlugin: (...args: Parameters<typeof mocks.getChannelPlugin>) =>
mocks.getChannelPlugin(...args),
getLoadedChannelPlugin: (...args: Parameters<typeof mocks.getLoadedChannelPlugin>) =>
mocks.getLoadedChannelPlugin(...args),
listChannelPlugins: (...args: Parameters<typeof mocks.listChannelPlugins>) =>
mocks.listChannelPlugins(...args),
}));
vi.mock("../../../channels/plugins/bundled.js", () => ({
getBundledChannelPlugin: (...args: Parameters<typeof mocks.getBundledChannelPlugin>) =>
mocks.getBundledChannelPlugin(...args),
listBundledChannelPlugins: (...args: Parameters<typeof mocks.listBundledChannelPlugins>) =>
mocks.listBundledChannelPlugins(...args),
getBundledChannelSetupPlugin: (...args: Parameters<typeof mocks.getBundledChannelSetupPlugin>) =>
mocks.getBundledChannelSetupPlugin(...args),
listBundledChannelSetupPlugins: (
...args: Parameters<typeof mocks.listBundledChannelSetupPlugins>
) => mocks.listBundledChannelSetupPlugins(...args),
}));
describe("channel doctor compatibility mutations", () => {
beforeEach(() => {
mocks.getChannelPlugin.mockReset();
mocks.getBundledChannelPlugin.mockReset();
mocks.getLoadedChannelPlugin.mockReset();
mocks.getBundledChannelSetupPlugin.mockReset();
mocks.listChannelPlugins.mockReset();
mocks.listBundledChannelPlugins.mockReset();
mocks.getChannelPlugin.mockReturnValue(undefined);
mocks.getBundledChannelPlugin.mockReturnValue(undefined);
mocks.listBundledChannelSetupPlugins.mockReset();
mocks.getLoadedChannelPlugin.mockReturnValue(undefined);
mocks.getBundledChannelSetupPlugin.mockReturnValue(undefined);
mocks.listChannelPlugins.mockReturnValue([]);
mocks.listBundledChannelPlugins.mockReturnValue([]);
mocks.listBundledChannelSetupPlugins.mockReturnValue([]);
});
it("skips plugin discovery when no channels are configured", () => {
@@ -39,22 +40,18 @@ describe("channel doctor compatibility mutations", () => {
expect(result).toEqual([]);
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
expect(mocks.listBundledChannelPlugins).not.toHaveBeenCalled();
expect(mocks.listBundledChannelSetupPlugins).not.toHaveBeenCalled();
});
it("only evaluates configured channel ids", () => {
it("uses loaded doctor adapters for configured channel ids", () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg,
changes: ["matrix"],
}));
mocks.getBundledChannelPlugin.mockImplementation((id: string) =>
id === "matrix"
? {
id: "matrix",
doctor: { normalizeCompatibilityConfig },
}
: undefined,
);
mocks.getLoadedChannelPlugin.mockReturnValue({
id: "matrix",
doctor: { normalizeCompatibilityConfig },
});
const cfg = {
channels: {
@@ -68,9 +65,58 @@ describe("channel doctor compatibility mutations", () => {
expect(result).toHaveLength(1);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(mocks.getChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelPlugin).not.toHaveBeenCalledWith("discord");
expect(mocks.listBundledChannelPlugins).not.toHaveBeenCalled();
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelSetupPlugin).not.toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelSetupPlugin).not.toHaveBeenCalledWith("discord");
expect(mocks.listBundledChannelSetupPlugins).not.toHaveBeenCalled();
});
it("falls back when configured loaded channel plugin has no doctor adapter", () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg,
changes: ["discord"],
}));
mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) =>
id === "discord"
? {
id: "discord",
doctor: { normalizeCompatibilityConfig },
}
: undefined,
);
const result = collectChannelDoctorCompatibilityMutations({
channels: {
discord: {
enabled: true,
},
},
} as never);
expect(result).toHaveLength(1);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("discord");
expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("discord");
});
it("keeps configured channel doctor lookup non-fatal when setup loading fails", () => {
mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => {
if (id === "discord") {
throw new Error("missing runtime dep");
}
return undefined;
});
const result = collectChannelDoctorCompatibilityMutations({
channels: {
discord: {
enabled: true,
},
},
} as never);
expect(result).toEqual([]);
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("discord");
expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("discord");
});
});

View File

@@ -1,8 +1,8 @@
import {
getBundledChannelPlugin,
listBundledChannelPlugins,
getBundledChannelSetupPlugin,
listBundledChannelSetupPlugins,
} from "../../../channels/plugins/bundled.js";
import { getChannelPlugin, listChannelPlugins } from "../../../channels/plugins/registry.js";
import { getLoadedChannelPlugin, listChannelPlugins } from "../../../channels/plugins/registry.js";
import type {
ChannelDoctorAdapter,
ChannelDoctorConfigMutation,
@@ -37,32 +37,43 @@ function safeListActiveChannelPlugins() {
}
}
function safeListBundledChannelPlugins() {
function safeListBundledChannelSetupPlugins() {
try {
return listBundledChannelPlugins();
return listBundledChannelSetupPlugins();
} catch {
return [];
}
}
function safeGetLoadedChannelPlugin(id: string) {
try {
return getLoadedChannelPlugin(id);
} catch {
return undefined;
}
}
function safeGetBundledChannelSetupPlugin(id: string) {
try {
return getBundledChannelSetupPlugin(id);
} catch {
return undefined;
}
}
function listChannelDoctorEntries(channelIds?: readonly string[]): ChannelDoctorEntry[] {
const byId = new Map<string, ChannelDoctorEntry>();
const selectedIds = channelIds ? new Set(channelIds) : null;
const plugins = selectedIds
? [...selectedIds].flatMap((id) => {
let activeOrBundledPlugin;
try {
activeOrBundledPlugin = getChannelPlugin(id);
} catch {
activeOrBundledPlugin = undefined;
const loadedPlugin = safeGetLoadedChannelPlugin(id);
if (loadedPlugin?.doctor) {
return [loadedPlugin];
}
if (activeOrBundledPlugin?.doctor) {
return [activeOrBundledPlugin];
}
const bundledPlugin = getBundledChannelPlugin(id);
return bundledPlugin ? [bundledPlugin] : [];
const bundledSetupPlugin = safeGetBundledChannelSetupPlugin(id);
return bundledSetupPlugin ? [bundledSetupPlugin] : [];
})
: [...safeListActiveChannelPlugins(), ...safeListBundledChannelPlugins()];
: [...safeListActiveChannelPlugins(), ...safeListBundledChannelSetupPlugins()];
for (const plugin of plugins) {
if (!plugin.doctor) {
continue;

View File

@@ -311,6 +311,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
systemPrompt: {
type: "string",
},
},
additionalProperties: false,
},
@@ -622,6 +625,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
additionalProperties: false,
},
systemPrompt: {
type: "string",
},
},
additionalProperties: false,
},
@@ -13069,6 +13075,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
pollingStallThresholdMs: {
type: "integer",
minimum: 30000,
maximum: 600000,
},
retry: {
type: "object",
properties: {
@@ -14102,6 +14113,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
pollingStallThresholdMs: {
type: "integer",
minimum: 30000,
maximum: 600000,
},
retry: {
type: "object",
properties: {
@@ -14482,6 +14498,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Telegram API Timeout (seconds)",
help: "Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
},
pollingStallThresholdMs: {
label: "Telegram Polling Stall Threshold (ms)",
help: "Milliseconds without completed Telegram getUpdates liveness before the polling watchdog restarts the polling runner. Default: 120000.",
},
silentErrorReplies: {
label: "Telegram Silent Error Replies",
help: "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.",

View File

@@ -27646,6 +27646,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.20",
version: "2026.4.21",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -42,6 +42,19 @@ describe("gateway codex harness live helpers", () => {
expect(isExpectedCodexModelsCommandText(text)).toBe(true);
});
it("accepts sandbox escalation rejection for codex models", () => {
const texts = [
"I couldnt list them because `codex models` requires running outside the sandbox here, and that approval was rejected.",
"I couldnt list them because the local `codex models` command requires elevated execution in this environment, and that request was rejected.",
"I couldnt list them because the local `codex models` command requires host permissions here, and that escalation was rejected.",
"I couldnt run `codex models` because the sandboxed attempt failed and the required elevated retry was not approved.",
];
for (const text of texts) {
expect(isExpectedCodexModelsCommandText(text)).toBe(true);
}
});
it("accepts the interactive TUI current-model summary", () => {
const text = [
"`codex models` didnt return a plain list in this environment; it dropped into the interactive TUI instead.",
@@ -75,6 +88,19 @@ describe("gateway codex harness live helpers", () => {
expect(isExpectedCodexModelsCommandText(text)).toBe(false);
});
it("accepts the sandboxed CLI failure active-model summary", () => {
const text = [
"I couldnt inspect the CLI model list because sandboxed `codex --help` failed on a namespace restriction, and the escalated retry was rejected.",
"",
"What I can confirm from the current session is:",
"- Active model: `codex/gpt-5.4`",
].join("\n");
expect(
EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)),
).toBe(true);
});
it("rejects unrelated codex command output", () => {
expect(isExpectedCodexModelsCommandText("Codex is healthy.")).toBe(false);
});

View File

@@ -38,6 +38,7 @@ export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [
"This harness is configured with a single Codex model: `codex/",
"Primary model: `codex/",
"Registered models: `codex/",
"Active model: `codex/",
"Current active model is `codex/",
"Current OpenClaw session status reports the active model as:",
] as const;
@@ -56,6 +57,12 @@ export function isExpectedCodexModelsCommandText(text: string): boolean {
normalized.includes("fails to start") ||
normalized.includes("repo-local fallback") ||
normalized.includes("sandbox blocks") ||
((normalized.includes("rejected") || normalized.includes("not approved")) &&
(normalized.includes("sandbox") ||
normalized.includes("permission") ||
normalized.includes("permissions") ||
normalized.includes("escalation") ||
normalized.includes("elevated execution"))) ||
normalized.includes("interactive in this environment") ||
normalized.includes("sandboxed session") ||
normalized.includes("required user namespace") ||

View File

@@ -26,10 +26,10 @@ describe("image-generation live-test helpers", () => {
it("parses provider model overrides by provider id", () => {
expect(
parseProviderModelMap("openai/gpt-image-1, google/gemini-3.1-flash-image-preview, invalid"),
parseProviderModelMap("openai/gpt-image-2, google/gemini-3.1-flash-image-preview, invalid"),
).toEqual(
new Map([
["openai", "openai/gpt-image-1"],
["openai", "openai/gpt-image-2"],
["google", "google/gemini-3.1-flash-image-preview"],
]),
);
@@ -40,7 +40,7 @@ describe("image-generation live-test helpers", () => {
agents: {
defaults: {
imageGenerationModel: {
primary: "openai/gpt-image-1",
primary: "openai/gpt-image-2",
fallbacks: ["google/gemini-3.1-flash-image-preview", "invalid"],
},
},
@@ -49,7 +49,7 @@ describe("image-generation live-test helpers", () => {
expect(resolveConfiguredLiveImageModels(cfg)).toEqual(
new Map([
["openai", "openai/gpt-image-1"],
["openai", "openai/gpt-image-2"],
["google", "google/gemini-3.1-flash-image-preview"],
]),
);

View File

@@ -14,7 +14,7 @@ export const DEFAULT_LIVE_IMAGE_MODELS: Record<string, string> = {
fal: "fal/fal-ai/flux/dev",
google: "google/gemini-3.1-flash-image-preview",
minimax: "minimax/image-01",
openai: "openai/gpt-image-1",
openai: "openai/gpt-image-2",
vydra: "vydra/grok-imagine",
};

View File

@@ -148,6 +148,9 @@ describe("image-generation runtime", () => {
error: "OpenAI API key missing",
},
]);
expect(mocks.warn).toHaveBeenCalledWith(
"image-generation candidate failed: openai/gpt-image-1: OpenAI API key missing",
);
});
it("drops unsupported provider geometry overrides and reports them", async () => {

View File

@@ -59,6 +59,9 @@ export async function generateImage(
error,
});
lastError = new Error(error);
log.warn(
`image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${error}`,
);
continue;
}
@@ -112,7 +115,11 @@ export async function generateImage(
status: described?.status,
code: described?.code,
});
log.debug(`image-generation candidate failed: ${candidate.provider}/${candidate.model}`);
log.warn(
`image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${
described?.message ?? formatErrorMessage(err)
}`,
);
}
}

View File

@@ -185,24 +185,19 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
afterEach(() => {
__testing.setSleepSyncOverride(null);
__testing.setDateNowOverride(null);
__testing.setParentPidOverride(null);
vi.restoreAllMocks();
});
// Temporarily rewrites `process.ppid` for a block of test code. Used by the
// Temporarily overrides the parent PID for a block of test code. Used by the
// ancestor-exclusion tests to drive the real `getSelfAndAncestorPidsSync`
// walk without installing a runtime-reachable override on the module. Node
// always exposes `process.ppid` as an own property so the captured
// descriptor is non-null in practice; the `if (orig)` guard is defensive
// against a broken environment, not a reachable branch.
// walk without depending on runtime-specific `process.ppid` descriptors.
function withStubbedPpid<T>(ppid: number, fn: () => T): T {
const orig = Object.getOwnPropertyDescriptor(process, "ppid");
Object.defineProperty(process, "ppid", { value: ppid, configurable: true });
__testing.setParentPidOverride(() => ppid);
try {
return fn();
} finally {
if (orig) {
Object.defineProperty(process, "ppid", orig);
}
__testing.setParentPidOverride(null);
}
}

View File

@@ -45,6 +45,7 @@ const MAX_ANCESTOR_WALK_DEPTH = 32;
const restartLog = createSubsystemLogger("restart");
let sleepSyncOverride: ((ms: number) => void) | null = null;
let dateNowOverride: (() => number) | null = null;
let parentPidOverride: (() => number) | null = null;
function getTimeMs(): number {
return dateNowOverride ? dateNowOverride() : Date.now();
@@ -70,6 +71,10 @@ function sleepSync(ms: number): void {
}
}
function getParentPid(): number {
return parentPidOverride ? parentPidOverride() : process.ppid;
}
/**
* Read a single ancestor PID from `/proc/<pid>/status` on Linux.
* Returns null on any failure (non-Linux platform, restricted /proc, race
@@ -135,7 +140,7 @@ function readParentPidFromProc(pid: number): number | null {
*/
function getSelfAndAncestorPidsSync(): Set<number> {
const pids = new Set<number>([process.pid]);
const immediateParent = process.ppid;
const immediateParent = getParentPid();
if (!Number.isFinite(immediateParent) || immediateParent <= 0) {
return pids;
}
@@ -553,6 +558,9 @@ export const __testing = {
setDateNowOverride(fn: (() => number) | null) {
dateNowOverride = fn;
},
setParentPidOverride(fn: (() => number) | null) {
parentPidOverride = fn;
},
/** Invoke sleepSync directly (bypasses the override) for unit-testing the real Atomics path. */
callSleepSyncRaw: sleepSync,
};

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
const loadWebMedia = vi.hoisted(() => vi.fn());
const saveMediaBuffer = vi.hoisted(() => vi.fn());
vi.mock("./web-media.js", () => ({
loadWebMedia,
}));
vi.mock("./store.js", () => ({
saveMediaBuffer,
}));
const { resolveOutboundAttachmentFromUrl } = await import("./outbound-attachment.js");
describe("resolveOutboundAttachmentFromUrl", () => {
it("preserves the loaded file name when staging outbound media", async () => {
const buffer = Buffer.from("pdf");
loadWebMedia.mockResolvedValueOnce({
buffer,
contentType: "application/pdf",
fileName: "report.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/media/outbound/report---uuid.pdf",
contentType: "application/pdf",
});
await resolveOutboundAttachmentFromUrl("./report.pdf", 1024);
expect(saveMediaBuffer).toHaveBeenCalledWith(
buffer,
"application/pdf",
"outbound",
1024,
"report.pdf",
);
});
});

View File

@@ -25,6 +25,7 @@ export async function resolveOutboundAttachmentFromUrl(
media.contentType ?? undefined,
"outbound",
maxBytes,
media.fileName,
);
return { path: saved.path, contentType: saved.contentType };
}

View File

@@ -184,7 +184,7 @@ export function splitMediaFromOutput(raw: string): {
}
const trimmedStart = line.trimStart();
if (!trimmedStart.startsWith("MEDIA:")) {
if (!trimmedStart.toUpperCase().startsWith("MEDIA:")) {
keptLines.push(line);
pushTextSegment(line);
lineOffset += line.length + 1; // +1 for newline

View File

@@ -8,7 +8,7 @@ import { applyAgentDefaultPrimaryModel } from "./provider-model-primary.js";
export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.4";
export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.4";
export const OPENAI_DEFAULT_IMAGE_MODEL = "gpt-image-1";
export const OPENAI_DEFAULT_IMAGE_MODEL = "gpt-image-2";
export const OPENAI_DEFAULT_TTS_MODEL = "gpt-4o-mini-tts";
export const OPENAI_DEFAULT_TTS_VOICE = "alloy";
export const OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";

View File

@@ -298,15 +298,18 @@ export function createEventHandlers(context: EventHandlerContext) {
}
}
if (evt.state === "delta") {
// Arm watchdog and mark streaming on every delta, even when the visible
// text hasn't changed yet (e.g. first commentary-only or tool-call delta).
// Without this, the watchdog never fires and the status bar stays stale.
setActivityStatus("streaming");
if (state.activeChatRunId === evt.runId) {
armStreamingWatchdog(evt.runId);
}
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
if (!displayText) {
return;
}
chatLog.updateAssistant(displayText, evt.runId);
setActivityStatus("streaming");
if (state.activeChatRunId === evt.runId) {
armStreamingWatchdog(evt.runId);
}
}
if (evt.state === "final") {
const isLocalBtwRun = isLocalBtwRunId?.(evt.runId) ?? false;

View File

@@ -25,7 +25,7 @@ const mediaRuntimeMocks = vi.hoisted(() => {
};
};
return {
createSubsystemLogger: vi.fn(() => ({ debug, warn: vi.fn() })),
createSubsystemLogger: vi.fn(() => ({ debug, warn })),
describeFailoverError: vi.fn(),
getImageGenerationProvider: vi.fn<
(providerId: string, config?: OpenClawConfig) => ImageGenerationProvider | undefined

View File

@@ -22,6 +22,7 @@ export type GenerationRuntimeMocks = {
resolveAgentModelFallbackValues: ResettableReturnMock;
resolveAgentModelPrimaryValue: ResettableReturnMock;
debug: ResettableMock;
warn: ResettableMock;
};
export function resetGenerationRuntimeMocks(mocks: GenerationRuntimeMocks): void {
@@ -42,4 +43,5 @@ export function resetGenerationRuntimeMocks(mocks: GenerationRuntimeMocks): void
mocks.resolveAgentModelPrimaryValue.mockReset();
mocks.resolveAgentModelPrimaryValue.mockReturnValue(undefined);
mocks.debug.mockReset();
mocks.warn.mockReset();
}

View File

@@ -14,6 +14,7 @@ import {
collectForbiddenPackPaths,
collectMissingPackPaths,
collectPackUnpackedSizeErrors,
createPackedBundledPluginPostinstallEnv,
packageNameFromSpecifier,
} from "../scripts/release-check.ts";
import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "../src/infra/package-dist-inventory.ts";
@@ -463,3 +464,13 @@ describe("collectPackUnpackedSizeErrors", () => {
]);
});
});
describe("createPackedBundledPluginPostinstallEnv", () => {
it("enables eager bundled dependency repair for packed channel entry smoke", () => {
expect(createPackedBundledPluginPostinstallEnv({ PATH: "/usr/bin" })).toEqual({
PATH: "/usr/bin",
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1",
});
});
});

View File

@@ -5,6 +5,7 @@ import {
createBundledRuntimeDependencyInstallArgs,
createBundledRuntimeDependencyInstallEnv,
createNestedNpmInstallEnv,
isDirectPostinstallInvocation,
pruneInstalledPackageDist,
discoverBundledPluginRuntimeDeps,
pruneBundledPluginSourceNodeModules,
@@ -82,6 +83,20 @@ describe("bundled plugin postinstall", () => {
});
}
it("recognizes direct invocation through symlinked temp prefixes", () => {
const realpathSync = vi.fn((value: string) =>
value.replace(/^\/var\/folders\//u, "/private/var/folders/"),
);
expect(
isDirectPostinstallInvocation({
entryPath: "/var/folders/tmp/openclaw/scripts/postinstall-bundled-plugins.mjs",
modulePath: "/private/var/folders/tmp/openclaw/scripts/postinstall-bundled-plugins.mjs",
realpathSync,
}),
).toBe(true);
});
async function writeDiscordDaveyOptionalDependencyFixture(
extensionsDir: string,
packageRoot: string,

View File

@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
type RootPackageManifest = {
overrides?: Record<string, string>;
pnpm?: {
overrides?: Record<string, string>;
};
};
function readRootManifest(): RootPackageManifest {
const manifestPath = path.resolve(process.cwd(), "package.json");
return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as RootPackageManifest;
}
describe("root package override guardrails", () => {
it("pins the node-domexception alias exactly in npm and pnpm overrides", () => {
const manifest = readRootManifest();
const pnpmOverride = manifest.pnpm?.overrides?.["node-domexception"];
expect(pnpmOverride).toBe("npm:@nolyfill/domexception@1.0.28");
expect(manifest.overrides?.["node-domexception"]).toBe(pnpmOverride);
});
});