mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
38 Commits
codex/code
...
v2026.4.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f788c88b4c | ||
|
|
33f55917b2 | ||
|
|
ae4449bd42 | ||
|
|
995febb7b1 | ||
|
|
fd2c9dfaef | ||
|
|
6584716054 | ||
|
|
f64c45ae49 | ||
|
|
7ddc11a387 | ||
|
|
c18933ebfb | ||
|
|
4bcfbef717 | ||
|
|
9b32ec9ff7 | ||
|
|
115f05d595 | ||
|
|
4e25479cb2 | ||
|
|
52d0a22d62 | ||
|
|
9040cda408 | ||
|
|
815c2e3052 | ||
|
|
79840c9fdf | ||
|
|
542086ccea | ||
|
|
1e9627f92d | ||
|
|
26b359bebd | ||
|
|
8eac996344 | ||
|
|
3243c14547 | ||
|
|
ddd05f4e89 | ||
|
|
bfde3c98a4 | ||
|
|
835de92b7a | ||
|
|
2020e63bd2 | ||
|
|
b835337cd6 | ||
|
|
7e4a5f8a6e | ||
|
|
8b3ddb28cd | ||
|
|
ca245b8621 | ||
|
|
2db45c7892 | ||
|
|
8ce7c4f08b | ||
|
|
87b81fa66f | ||
|
|
e57e54e591 | ||
|
|
adef75c1e1 | ||
|
|
ed6ccc9923 | ||
|
|
c4ddaf63fd | ||
|
|
c127812bba |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.20"
|
||||
"version": "2026.4.21"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (1–4) |
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. ` +
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
27
extensions/discord/src/ui-colors.ts
Normal file
27
extensions/discord/src/ui-colors.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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==",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
2
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,6 +33,9 @@ execution:
|
||||
- can’t 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
|
||||
- won’t guess
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 couldn’t list them because `codex models` requires running outside the sandbox here, and that approval was rejected.",
|
||||
"I couldn’t list them because the local `codex models` command requires elevated execution in this environment, and that request was rejected.",
|
||||
"I couldn’t list them because the local `codex models` command requires host permissions here, and that escalation was rejected.",
|
||||
"I couldn’t 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` didn’t 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 couldn’t 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);
|
||||
});
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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"],
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
39
src/media/outbound-attachment.test.ts
Normal file
39
src/media/outbound-attachment.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ export async function resolveOutboundAttachmentFromUrl(
|
||||
media.contentType ?? undefined,
|
||||
"outbound",
|
||||
maxBytes,
|
||||
media.fileName,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
test/scripts/root-package-overrides.test.ts
Normal file
25
test/scripts/root-package-overrides.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user