Compare commits

..

8 Commits

Author SHA1 Message Date
Peter Steinberger cff6dc94e3 docs: format changelog for release
Docker Release / validate_manual_backfill (push) Has been cancelled
Docker Release / approve_manual_backfill (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-03-25 09:34:30 -07:00
Peter Steinberger 97a7e93db4 build: prepare 2026.3.24 release 2026-03-25 09:31:05 -07:00
liyuan97 e2e9f979ca feat(minimax): add image generation provider and trim model catalog to M2.7 (#54487)
* feat(minimax): add image generation and TTS providers, trim TUI model list

Register MiniMax image-01 and speech-2.8 models as plugin providers for
the image_generate and TTS tools. Both resolve CN/global base URLs from
the configured model endpoint origin.

- Image generation: base64 response, aspect-ratio support, image-to-image
  via subject_reference, registered for minimax and minimax-portal
- TTS: speech-2.8-turbo (default) and speech-2.8-hd, hex-encoded audio,
  voice listing via get_voice API, telephony PCM support
- Add MiniMax to TTS auto-detection cascade (after ElevenLabs, before
  Microsoft) and TTS config section
- Remove MiniMax-VL-01, M2, M2.1, M2.5 and variants from TUI picker;
  keep M2.7 and M2.7-highspeed only (backend routing unchanged)

* feat(minimax): trim legacy model catalog to M2.7 only

Cherry-picked from temp/feat/minimax-trim-legacy-models (949ed28).
Removes MiniMax-VL-01, M2, M2.1, M2.5 and variants from the model
catalog, model order, modern model matchers, OAuth config, docs, and
tests. Keeps only M2.7 and M2.7-highspeed.

Conflicts resolved:
- provider-catalog.ts: removed MINIMAX_TUI_MODELS filter (no longer
  needed since source array is now M2.7-only)
- index.ts: kept image generation + speech provider registrations
  (added by this branch), moved media understanding registrations
  earlier (as intended by the cherry-picked commit)

* fix(minimax): update discovery contract test to reflect M2.7-only catalog

Cherry-picked from temp/feat/minimax-trim-legacy-models (2c750cb).

* feat(minimax): add web search provider and register in plugin entry

* fix(minimax): resolve OAuth credentials for TTS speech provider

* MiniMax: remove web search and TTS providers

* fix(minimax): throw on empty images array after generation failure

* feat(minimax): add image generation provider and trim catalog to M2.7 (#54487) (thanks @liyuan97)

---------

Co-authored-by: tars90percent <tars@minimaxi.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 09:29:35 -07:00
xieyongliang 7cc86e9685 fix(release): add plugin-sdk:check-exports to release:check (#54283)
* fix(plugins): resolve sdk alias from import.meta.url for external plugins

When a plugin is installed outside the openclaw package (e.g.
~/.openclaw/extensions/), resolveLoaderPluginSdkPackageRoot() fails to
locate the openclaw root via cwd or argv1 hints, resulting in an empty
alias map. Jiti then cannot resolve openclaw/plugin-sdk/* imports and
the plugin fails to load with "Cannot find module".

Since sdk-alias.ts is always compiled into the openclaw package itself,
import.meta.url reliably points inside the installation directory. Add it
as an unconditional fallback in resolveLoaderPluginSdkPackageRoot() so
external plugins can always resolve the plugin SDK.

Fixes: Error: Cannot find module 'openclaw/plugin-sdk/plugin-entry'

* fix(plugins): pass loader moduleUrl to resolve sdk alias for external plugins

The previous approach of adding import.meta.url as an unconditional
fallback inside resolveLoaderPluginSdkPackageRoot() broke test isolation:
tests that expected null from untrusted fixtures started finding the real
openclaw root. Revert that and instead thread an optional moduleUrl through
buildPluginLoaderAliasMap → resolvePluginSdkScopedAliasMap →
listPluginSdkExportedSubpaths → resolveLoaderPluginSdkPackageRoot.

loader.ts passes its own import.meta.url as the hint, which is always
inside the openclaw installation. This guarantees the sdk alias map is
built correctly even when argv1 does not resolve to the openclaw root
(e.g. single-binary distributions, custom launchers, or Docker images
where the binary wrapper is not a standard npm symlink).

Tests that call sdk-alias helpers directly without moduleUrl are
unaffected and continue to enforce the existing isolation semantics.
A new test covers the moduleUrl resolution path explicitly.

* fix(plugins): use existing fixture file for moduleUrl hint in test

The previous test pointed loaderModuleUrl to dist/plugins/loader.js
which is not created by createPluginSdkAliasFixture, causing resolution
to fall back to the real openclaw root instead of the fixture root.
Use fixture.root/openclaw.mjs (created by the bin+marker fixture) so
the moduleUrl hint reliably resolves to the fixture package root.

* fix(test): use fixture.root as cwd in external plugin alias test

When process.cwd() is mocked to the external plugin dir, the
findNearestPluginSdkPackageRoot(process.cwd()) fallback resolves to
the real openclaw repo root in the CI test runner, making the test
resolve the wrong aliases. Using fixture.root as cwd ensures all
resolution paths consistently point to the fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(release): add plugin-sdk:check-exports to release:check

plugin-sdk subpath exports (e.g. openclaw/plugin-sdk/plugin-entry,
openclaw/plugin-sdk/provider-auth) were missing from the published
package.json, causing external plugins to fail at load time with
'Cannot find module openclaw/plugin-sdk/plugin-entry'.

Root cause: sync-plugin-sdk-exports.mjs syncs plugin-sdk-entrypoints.json
into package.json exports, but this sync was never validated in the
release:check pipeline. As a result, any drift between
plugin-sdk-entrypoints.json and the published package.json goes
undetected until users hit the runtime error.

Fix: add plugin-sdk:check-exports to release:check so the CI gate
fails loudly if the exports are out of sync before publishing.

* fix(test): isolate moduleUrl hint test from process.cwd() fallback

Use externalPluginRoot as cwd instead of fixture.root, so only the
moduleUrl hint can resolve the openclaw package root. Previously,
withCwd(fixture.root) allowed the process.cwd() fallback to also
resolve the fixture root, making the moduleUrl path untested.

Spotted by greptile-apps review on #54283.

* fix(test): use empty string to disable argv1 in moduleUrl hint test

Passing undefined for argv1 in buildPluginLoaderAliasMap triggers the
STARTUP_ARGV1 default (process.argv[1], the vitest runner binary inside
the openclaw repo). resolveTrustedOpenClawRootFromArgvHint then resolves
to the real openclaw root before the moduleUrl hint is checked, making
the test resolve wrong aliases.

Pass "" instead: falsy so the hint is skipped, but does not trigger the
default parameter value. Only the moduleUrl can bridge the gap.

Made-with: Cursor

* fix(plugins): thread moduleUrl through SDK alias resolution for external plugins (#54283) Thanks @xieyongliang

---------

Co-authored-by: bojsun <bojie.sun@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jerry <jerry@JerrydeMacBook-Air-2.local>
Co-authored-by: yongliang.xie <yongliang.xie@bytedance.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 09:11:17 -07:00
Devin Robison c2a2edb329 Fix local copied package installs honoring staged project .npmrc (#54543) 2026-03-25 09:59:33 -06:00
Lin Z a0b9dc0078 fix(feishu): use message create_time for inbound timestamps (#52809)
* fix(feishu): use message create_time instead of Date.now() for Timestamp field

When a message is sent offline and later retried by the Feishu client
upon reconnection, Date.now() captures the *delivery* time rather than
the *authoring* time.  This causes downstream consumers to see a
timestamp that can be minutes or hours after the user actually composed
the message, leading to incorrect temporal semantics — for example, a
"delete this" command may target the wrong resource because the agent
believes the instruction was issued much later than it actually was.

Replace every Date.now() used for message timestamps with the original
create_time from the Feishu event payload (millisecond-epoch string),
falling back to Date.now() only when the field is absent.  The
definition is also hoisted to the top of handleFeishuMessage so that
both the pending-history path and the main inbound-payload path share
the same authoritative value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(feishu): verify Timestamp uses message create_time

Add two test cases:
1. When create_time is present, Timestamp must equal the parsed value
2. When create_time is absent, Timestamp falls back to Date.now()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: revert unrelated formatting change to lifecycle.test.ts

This file was inadvertently formatted in a prior commit. Reverting to
match main and keep the PR scoped to the Feishu timestamp fix only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(feishu): use message create_time for inbound timestamps (#52809) (thanks @schumilin)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 08:36:12 -07:00
Lin Z bd4237c16c fix(feishu): close WebSocket connections on monitor stop (#52844)
* fix(feishu): close WebSocket connections on monitor stop/abort

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(feishu): add WebSocket cleanup tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(feishu): close WebSocket connections on monitor stop (#52844) (thanks @schumilin)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
2026-03-25 08:32:21 -07:00
Nimrod Gutman edb5123f26 fix(sandbox): honor sandbox alsoAllow and explicit re-allows (#54492)
* fix(sandbox): honor effective sandbox alsoAllow policy

* fix(sandbox): prefer resolved sandbox context policy

* fix: honor sandbox alsoAllow policy (#54492) (thanks @ngutman)
2026-03-25 16:51:13 +02:00
47 changed files with 1540 additions and 345 deletions
+51
View File
@@ -8,8 +8,59 @@ Docs: https://docs.openclaw.ai
### Changes
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
- MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.
### Fixes
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
- Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.
- Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.
- Plugins/SDK: thread `moduleUrl` through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory (e.g. `~/.openclaw/extensions/`) correctly resolve `openclaw/plugin-sdk/*` subpath imports, and gate `plugin-sdk:check-exports` in `release:check`. (#54283) Thanks @xieyongliang.
## 2026.3.24
### Breaking
### Changes
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live "Available Right Now" section in the Control UI so it is easier to see what will work before you ask.
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
### Fixes
- Outbound media/local files: align outbound media access with the configured fs policy so host-local files and inbound-media paths keep sending when `workspaceOnly` is off, while strict workspace-only agents remain sandboxed.
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
## 2026.3.24-beta.2
### Breaking
+2 -2
View File
@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.24-beta.2
OPENCLAW_GATEWAY_VERSION = 2026.3.24
OPENCLAW_MARKETING_VERSION = 2026.3.24
OPENCLAW_BUILD_VERSION = 202603240
OPENCLAW_BUILD_VERSION = 2026032490
#include? "../build/Version.xcconfig"
@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2026.3.24</string>
<key>CFBundleVersion</key>
<string>202603240</string>
<string>2026032490</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>
+2 -3
View File
@@ -2153,9 +2153,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
1. Upgrade to a current OpenClaw release (or run from source `main`), then restart the gateway.
2. Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
exists in env/auth profiles so the provider can be injected.
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.7`,
`minimax/MiniMax-M2.7-highspeed`, `minimax/MiniMax-M2.5`, or
`minimax/MiniMax-M2.5-highspeed`.
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.7` or
`minimax/MiniMax-M2.7-highspeed`.
4. Run:
```bash
+2 -66
View File
@@ -8,16 +8,12 @@ title: "MiniMax"
# MiniMax
OpenClaw's MiniMax provider defaults to **MiniMax M2.7** and keeps
**MiniMax M2.5** in the catalog for compatibility.
OpenClaw's MiniMax provider defaults to **MiniMax M2.7**.
## Model lineup
- `MiniMax-M2.7`: default hosted text model.
- `MiniMax-M2.7-highspeed`: faster M2.7 text tier.
- `MiniMax-M2.5`: previous text model, still available in the MiniMax catalog.
- `MiniMax-M2.5-highspeed`: faster M2.5 text tier.
- `MiniMax-VL-01`: vision model for text + image inputs.
## Choose a setup
@@ -80,24 +76,6 @@ Configure via CLI:
contextWindow: 200000,
maxTokens: 8192,
},
{
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
contextWindow: 200000,
maxTokens: 8192,
},
{
id: "MiniMax-M2.5-highspeed",
name: "MiniMax M2.5 Highspeed",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
@@ -128,46 +106,6 @@ Example below uses Opus as a concrete primary; swap to your preferred latest-gen
}
```
### Optional: Local via LM Studio (manual)
**Best for:** local inference with LM Studio.
We have seen strong results with MiniMax M2.5 on powerful hardware (e.g. a
desktop/server) using LM Studio's local server.
Configure manually via `openclaw.json`:
```json5
{
agents: {
defaults: {
model: { primary: "lmstudio/minimax-m2.5-gs32" },
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
},
},
models: {
mode: "merge",
providers: {
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "lmstudio",
api: "openai-responses",
models: [
{
id: "minimax-m2.5-gs32",
name: "MiniMax M2.5 GS32",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 196608,
maxTokens: 8192,
},
],
},
},
},
}
```
## Configure via `openclaw configure`
Use the interactive config wizard to set MiniMax without editing JSON:
@@ -190,7 +128,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
- Model refs are `minimax/<model>`.
- Default text model: `MiniMax-M2.7`.
- Alternate text models: `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`.
- Alternate text model: `MiniMax-M2.7-highspeed`.
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
- Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): [https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
@@ -214,8 +152,6 @@ Make sure the model id is **casesensitive**:
- `minimax/MiniMax-M2.7`
- `minimax/MiniMax-M2.7-highspeed`
- `minimax/MiniMax-M2.5`
- `minimax/MiniMax-M2.5-highspeed`
Then recheck with:
+71
View File
@@ -733,6 +733,77 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("uses message create_time as Timestamp instead of Date.now()", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-create-time",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "delete this" }),
create_time: "1700000000000",
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
Timestamp: 1700000000000,
}),
);
});
it("falls back to Date.now() when create_time is absent", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-no-create-time",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
const before = Date.now();
await dispatchMessage({ cfg, event });
const after = Date.now();
const call = mockFinalizeInboundContext.mock.calls[0]?.[0] as { Timestamp: number };
expect(call.Timestamp).toBeGreaterThanOrEqual(before);
expect(call.Timestamp).toBeLessThanOrEqual(after);
});
it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
const cfg: ClawdbotConfig = {
channels: {
+10 -6
View File
@@ -357,6 +357,14 @@ export async function handleFeishuMessage(params: {
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
: null;
// Parse message create_time early so every downstream consumer (pending
// history, inbound payload, etc.) uses the original authoring timestamp
// instead of the delivery/processing time. Feishu uses a millisecond
// epoch string; fall back to Date.now() only when the field is absent.
const messageCreateTimeMs = event.message.create_time
? parseInt(event.message.create_time, 10)
: Date.now();
let requireMention = false; // DMs never require mention; groups may override below
if (isGroup) {
if (groupConfig?.enabled === false) {
@@ -434,7 +442,7 @@ export async function handleFeishuMessage(params: {
entry: {
sender: ctx.senderOpenId,
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
timestamp: Date.now(),
timestamp: messageCreateTimeMs,
messageId: ctx.messageId,
},
});
@@ -919,7 +927,7 @@ export async function handleFeishuMessage(params: {
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
// ID and would produce invalid reply targets downstream.
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
Timestamp: Date.now(),
Timestamp: messageCreateTimeMs,
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "feishu" as const,
@@ -929,10 +937,6 @@ export async function handleFeishuMessage(params: {
});
};
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
? parseInt(event.message.create_time, 10)
: undefined;
// Determine reply target based on group session mode:
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
// root so the bot stays in the same thread.
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { botNames, botOpenIds, stopFeishuMonitorState, wsClients } from "./monitor.state.js";
import type { ResolvedFeishuAccount } from "./types.js";
const createFeishuWSClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuWSClient: createFeishuWSClientMock,
}));
import { monitorWebSocket } from "./monitor.transport.js";
type MockWsClient = {
start: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
function createAccount(accountId: string): ResolvedFeishuAccount {
return {
accountId,
enabled: true,
configured: true,
appId: `cli_${accountId}`,
appSecret: `secret_${accountId}`, // pragma: allowlist secret
domain: "feishu",
config: {
enabled: true,
connectionMode: "websocket",
},
} as ResolvedFeishuAccount;
}
function createWsClient(): MockWsClient {
return {
start: vi.fn(),
close: vi.fn(),
};
}
afterEach(() => {
stopFeishuMonitorState();
vi.clearAllMocks();
});
describe("feishu websocket cleanup", () => {
it("closes the websocket client when the monitor aborts", async () => {
const wsClient = createWsClient();
createFeishuWSClientMock.mockReturnValue(wsClient);
const abortController = new AbortController();
const accountId = "alpha";
botOpenIds.set(accountId, "ou_alpha");
botNames.set(accountId, "Alpha");
const monitorPromise = monitorWebSocket({
account: createAccount(accountId),
accountId,
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
abortSignal: abortController.signal,
eventDispatcher: {} as never,
});
expect(wsClient.start).toHaveBeenCalledTimes(1);
expect(wsClients.get(accountId)).toBe(wsClient);
abortController.abort();
await monitorPromise;
expect(wsClient.close).toHaveBeenCalledTimes(1);
expect(wsClients.has(accountId)).toBe(false);
expect(botOpenIds.has(accountId)).toBe(false);
expect(botNames.has(accountId)).toBe(false);
});
it("closes targeted websocket clients during stop cleanup", () => {
const alphaClient = createWsClient();
const betaClient = createWsClient();
wsClients.set("alpha", alphaClient as never);
wsClients.set("beta", betaClient as never);
botOpenIds.set("alpha", "ou_alpha");
botOpenIds.set("beta", "ou_beta");
botNames.set("alpha", "Alpha");
botNames.set("beta", "Beta");
stopFeishuMonitorState("alpha");
expect(alphaClient.close).toHaveBeenCalledTimes(1);
expect(betaClient.close).not.toHaveBeenCalled();
expect(wsClients.has("alpha")).toBe(false);
expect(wsClients.has("beta")).toBe(true);
expect(botOpenIds.has("alpha")).toBe(false);
expect(botOpenIds.has("beta")).toBe(true);
expect(botNames.has("alpha")).toBe(false);
expect(botNames.has("beta")).toBe(true);
});
it("closes all websocket clients during global stop cleanup", () => {
const alphaClient = createWsClient();
const betaClient = createWsClient();
wsClients.set("alpha", alphaClient as never);
wsClients.set("beta", betaClient as never);
botOpenIds.set("alpha", "ou_alpha");
botOpenIds.set("beta", "ou_beta");
botNames.set("alpha", "Alpha");
botNames.set("beta", "Beta");
stopFeishuMonitorState();
expect(alphaClient.close).toHaveBeenCalledTimes(1);
expect(betaClient.close).toHaveBeenCalledTimes(1);
expect(wsClients.size).toBe(0);
expect(botOpenIds.size).toBe(0);
expect(botNames.size).toBe(0);
});
});
+13
View File
@@ -104,6 +104,15 @@ const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({
logEvery: feishuWebhookAnomalyDefaults.logEvery,
});
function closeWsClient(client: Lark.WSClient | undefined): void {
if (!client) return;
try {
client.close();
} catch {
/* Best-effort cleanup */
}
}
export function clearFeishuWebhookRateLimitStateForTest(): void {
feishuWebhookRateLimiter.clear();
feishuWebhookAnomalyTracker.clear();
@@ -134,6 +143,7 @@ export function recordWebhookStatus(
export function stopFeishuMonitorState(accountId?: string): void {
if (accountId) {
closeWsClient(wsClients.get(accountId));
wsClients.delete(accountId);
const server = httpServers.get(accountId);
if (server) {
@@ -145,6 +155,9 @@ export function stopFeishuMonitorState(accountId?: string): void {
return;
}
for (const client of wsClients.values()) {
closeWsClient(client);
}
wsClients.clear();
for (const server of httpServers.values()) {
server.close();
+2 -2
View File
@@ -1,11 +1,11 @@
import { vi } from "vitest";
export function createFeishuClientMockModule(): {
createFeishuWSClient: () => { start: () => void };
createFeishuWSClient: () => { start: () => void; close: () => void };
createEventDispatcher: () => { register: () => void };
} {
return {
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createFeishuWSClient: vi.fn(() => ({ start: vi.fn(), close: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
};
}
+17 -6
View File
@@ -89,23 +89,35 @@ export async function monitorWebSocket({
eventDispatcher,
}: MonitorTransportParams): Promise<void> {
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
log(`feishu[${accountId}]: starting WebSocket connection...`);
const wsClient = createFeishuWSClient(account);
wsClients.set(accountId, wsClient);
return new Promise((resolve, reject) => {
let cleanedUp = false;
const cleanup = () => {
wsClients.delete(accountId);
botOpenIds.delete(accountId);
botNames.delete(accountId);
if (cleanedUp) return;
cleanedUp = true;
abortSignal?.removeEventListener("abort", handleAbort);
try {
wsClient.close();
} catch (err) {
error(`feishu[${accountId}]: error closing WebSocket client: ${String(err)}`);
} finally {
wsClients.delete(accountId);
botOpenIds.delete(accountId);
botNames.delete(accountId);
}
};
const handleAbort = () => {
function handleAbort() {
log(`feishu[${accountId}]: abort signal received, stopping`);
cleanup();
resolve();
};
}
if (abortSignal?.aborted) {
cleanup();
@@ -120,7 +132,6 @@ export async function monitorWebSocket({
log(`feishu[${accountId}]: WebSocket client started`);
} catch (err) {
cleanup();
abortSignal?.removeEventListener("abort", handleAbort);
reject(err);
}
});
+2 -2
View File
@@ -3,7 +3,7 @@
Bundled MiniMax plugin for both:
- API-key provider setup (`minimax`)
- Coding Plan OAuth setup (`minimax-portal`)
- Token Plan OAuth setup (`minimax-portal`)
## Enable
@@ -34,4 +34,4 @@ openclaw setup --wizard --auth-choice minimax-global-api
## Notes
- MiniMax OAuth uses a user-code login flow.
- OAuth currently targets the Coding Plan path.
- OAuth currently targets the Token Plan path.
@@ -0,0 +1,176 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth";
const DEFAULT_MINIMAX_IMAGE_BASE_URL = "https://api.minimax.io";
const DEFAULT_MODEL = "image-01";
const DEFAULT_OUTPUT_MIME = "image/png";
const MINIMAX_SUPPORTED_ASPECT_RATIOS = [
"1:1",
"16:9",
"4:3",
"3:2",
"2:3",
"3:4",
"9:16",
"21:9",
] as const;
type MinimaxImageApiResponse = {
data?: {
image_base64?: string[];
};
metadata?: {
success_count?: number;
failed_count?: number;
};
id?: string;
base_resp?: {
status_code?: number;
status_msg?: string;
};
};
function resolveMinimaxImageBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string,
): string {
const direct = cfg?.models?.providers?.[providerId]?.baseUrl?.trim();
if (!direct) {
return DEFAULT_MINIMAX_IMAGE_BASE_URL;
}
// Extract origin from the configured base URL (which may include path like /anthropic)
try {
return new URL(direct).origin;
} catch {
return DEFAULT_MINIMAX_IMAGE_BASE_URL;
}
}
function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider {
return {
id: providerId,
label: "MiniMax",
defaultModel: DEFAULT_MODEL,
models: [DEFAULT_MODEL],
capabilities: {
generate: {
maxCount: 9,
supportsSize: false,
supportsAspectRatio: true,
supportsResolution: false,
},
edit: {
enabled: true,
maxCount: 9,
maxInputImages: 1,
supportsSize: false,
supportsAspectRatio: true,
supportsResolution: false,
},
geometry: {
aspectRatios: [...MINIMAX_SUPPORTED_ASPECT_RATIOS],
},
},
async generateImage(req) {
const auth = await resolveApiKeyForProvider({
provider: providerId,
cfg: req.cfg,
agentDir: req.agentDir,
store: req.authStore,
});
if (!auth.apiKey) {
throw new Error("MiniMax API key missing");
}
const baseUrl = resolveMinimaxImageBaseUrl(req.cfg, providerId);
const body: Record<string, unknown> = {
model: req.model || DEFAULT_MODEL,
prompt: req.prompt,
response_format: "base64",
n: req.count ?? 1,
};
if (req.aspectRatio?.trim()) {
body.aspect_ratio = req.aspectRatio.trim();
}
// Map input images to subject_reference for image-to-image generation
if (req.inputImages && req.inputImages.length > 0) {
const ref = req.inputImages[0];
const mime = ref.mimeType || "image/jpeg";
const dataUrl = `data:${mime};base64,${ref.buffer.toString("base64")}`;
body.subject_reference = [{ type: "character", image_file: dataUrl }];
}
const controller = new AbortController();
const timeoutMs = req.timeoutMs;
const timeout =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => controller.abort(), timeoutMs)
: undefined;
const response = await fetch(`${baseUrl}/v1/image_generation`, {
method: "POST",
headers: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => {
clearTimeout(timeout);
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`MiniMax image generation failed (${response.status}): ${text || response.statusText}`,
);
}
const data = (await response.json()) as MinimaxImageApiResponse;
const baseResp = data.base_resp;
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
const msg = baseResp.status_msg ?? "";
throw new Error(`MiniMax image generation API error (${baseResp.status_code}): ${msg}`);
}
const base64Images = data.data?.image_base64 ?? [];
const failedCount = data.metadata?.failed_count ?? 0;
if (base64Images.length === 0) {
const reason =
failedCount > 0 ? `${failedCount} image(s) failed to generate` : "no images returned";
throw new Error(`MiniMax image generation returned no images: ${reason}`);
}
const images = base64Images
.map((b64, index) => {
if (!b64) {
return null;
}
return {
buffer: Buffer.from(b64, "base64"),
mimeType: DEFAULT_OUTPUT_MIME,
fileName: `image-${index + 1}.png`,
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
return {
images,
model: req.model || DEFAULT_MODEL,
};
},
};
}
export function buildMinimaxImageGenerationProvider(): ImageGenerationProvider {
return buildMinimaxImageProvider("minimax");
}
export function buildMinimaxPortalImageGenerationProvider(): ImageGenerationProvider {
return buildMinimaxImageProvider("minimax-portal");
}
+9 -14
View File
@@ -16,6 +16,10 @@ import {
MINIMAX_DEFAULT_MODEL_ID,
} from "openclaw/plugin-sdk/provider-models";
import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage";
import {
buildMinimaxImageGenerationProvider,
buildMinimaxPortalImageGenerationProvider,
} from "./image-generation-provider.js";
import {
minimaxMediaUnderstandingProvider,
minimaxPortalMediaUnderstandingProvider,
@@ -130,22 +134,10 @@ function createOAuthHandler(region: MiniMaxRegion) {
agents: {
defaults: {
models: {
[portalModelRef("MiniMax-M2")]: { alias: "minimax-m2" },
[portalModelRef("MiniMax-M2.1")]: { alias: "minimax-m2.1" },
[portalModelRef("MiniMax-M2.1-highspeed")]: {
alias: "minimax-m2.1-highspeed",
},
[portalModelRef("MiniMax-M2.7")]: { alias: "minimax-m2.7" },
[portalModelRef("MiniMax-M2.7-highspeed")]: {
alias: "minimax-m2.7-highspeed",
},
[portalModelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" },
[portalModelRef("MiniMax-M2.5-highspeed")]: {
alias: "minimax-m2.5-highspeed",
},
[portalModelRef("MiniMax-M2.5-Lightning")]: {
alias: "minimax-m2.5-lightning",
},
},
},
},
@@ -243,6 +235,9 @@ export default definePluginEntry({
await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
});
api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider);
api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider);
api.registerProvider({
id: PORTAL_PROVIDER_ID,
label: PROVIDER_LABEL,
@@ -285,7 +280,7 @@ export default definePluginEntry({
],
isModernModelRef: ({ modelId }) => isMiniMaxModernModelId(modelId),
});
api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider);
api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider);
api.registerImageGenerationProvider(buildMinimaxImageGenerationProvider());
api.registerImageGenerationProvider(buildMinimaxPortalImageGenerationProvider());
},
});
+3 -3
View File
@@ -26,14 +26,14 @@ describe("minimax model definitions", () => {
it("builds catalog model with name and reasoning from catalog", () => {
const model = buildMinimaxModelDefinition({
id: "MiniMax-M2.1",
id: "MiniMax-M2.7",
cost: MINIMAX_API_COST,
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
});
expect(model).toMatchObject({
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
reasoning: true,
});
});
+8 -17
View File
@@ -9,7 +9,6 @@ import {
} from "openclaw/plugin-sdk/provider-models";
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 204800;
const MINIMAX_DEFAULT_MAX_TOKENS = 131072;
const MINIMAX_API_COST = {
@@ -45,22 +44,14 @@ function buildMinimaxTextModel(params: {
}
function buildMinimaxCatalog(): ModelDefinitionConfig[] {
return [
buildMinimaxModel({
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
name: "MiniMax VL 01",
reasoning: false,
input: ["text", "image"],
}),
...MINIMAX_TEXT_MODEL_ORDER.map((id) => {
const model = MINIMAX_TEXT_MODEL_CATALOG[id];
return buildMinimaxTextModel({
id,
name: model.name,
reasoning: model.reasoning,
});
}),
];
return MINIMAX_TEXT_MODEL_ORDER.map((id) => {
const model = MINIMAX_TEXT_MODEL_CATALOG[id];
return buildMinimaxTextModel({
id,
name: model.name,
reasoning: model.reasoning,
});
});
}
export function buildMinimaxProvider(): ModelProviderConfig {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.3.24-beta.2",
"version": "2026.3.24",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -686,7 +686,7 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "pnpm config:docs:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts",
"release:check": "pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
@@ -329,8 +329,6 @@ describe("models-config", () => {
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
}>();
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-VL-01");
});
});
});
@@ -36,18 +36,12 @@ describe("minimax provider catalog", () => {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([
"MiniMax-VL-01",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
]);
expect(providers?.["minimax-portal"]?.models?.map((model) => model.id)).toEqual([
"MiniMax-VL-01",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
]);
});
});
@@ -94,9 +94,6 @@ describe("MiniMax implicit provider (#15275)", () => {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["minimax-portal"]).toBeDefined();
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
expect(providers?.["minimax-portal"]?.models?.some((m) => m.id === "MiniMax-VL-01")).toBe(
true,
);
});
});
});
@@ -207,7 +207,7 @@ describe("models-config", () => {
providerKey: "minimax",
expectedBaseUrl: "https://api.minimax.io/anthropic",
expectedApiKeyRef: "MINIMAX_API_KEY", // pragma: allowlist secret
expectedModelIds: ["MiniMax-M2.7", "MiniMax-VL-01"],
expectedModelIds: ["MiniMax-M2.7"],
});
});
});
+2 -2
View File
@@ -13,8 +13,8 @@ export {
isCloudflareOrHtmlErrorPage,
parseApiErrorInfo,
} from "../../shared/assistant-error-format.js";
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox/runtime-status.js";
import { stableStringify } from "../stable-stringify.js";
import { formatEffectiveSandboxToolPolicyBlockedMessage } from "../tool-policy-sandbox.js";
import {
isAuthErrorMessage,
isAuthPermanentErrorMessage,
@@ -563,7 +563,7 @@ export function formatAssistantErrorText(
raw.match(/unknown tool[:\s]+["']?([a-z0-9_-]+)["']?/i) ??
raw.match(/tool\s+["']?([a-z0-9_-]+)["']?\s+(?:not found|is not available)/i);
if (unknownTool?.[1]) {
const rewritten = formatSandboxToolPolicyBlockedMessage({
const rewritten = formatEffectiveSandboxToolPolicyBlockedMessage({
cfg: opts?.cfg,
sessionKey: opts?.sessionKey,
toolName: unknownTool[1],
@@ -1940,20 +1940,20 @@ describe("applyExtraParamsToAgent", () => {
expect(resolvedModelId).toBe("MiniMax-M2.7-highspeed");
});
it("maps MiniMax M2.1 /fast to the matching highspeed model", () => {
it("maps MiniMax M2.7 /fast to the matching highspeed model", () => {
const resolvedModelId = runResolvedModelIdCase({
applyProvider: "minimax",
applyModelId: "MiniMax-M2.1",
applyModelId: "MiniMax-M2.7",
extraParamsOverride: { fastMode: true },
model: {
api: "anthropic-messages",
provider: "minimax",
id: "MiniMax-M2.1",
id: "MiniMax-M2.7",
baseUrl: "https://api.minimax.io/anthropic",
} as Model<"anthropic-messages">,
});
expect(resolvedModelId).toBe("MiniMax-M2.1-highspeed");
expect(resolvedModelId).toBe("MiniMax-M2.7-highspeed");
});
it("keeps explicit MiniMax highspeed models unchanged when /fast is off", () => {
@@ -2,8 +2,6 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
const MINIMAX_FAST_MODEL_IDS = new Map<string, string>([
["MiniMax-M2.1", "MiniMax-M2.1-highspeed"],
["MiniMax-M2.5", "MiniMax-M2.5-highspeed"],
["MiniMax-M2.7", "MiniMax-M2.7-highspeed"],
]);
+164
View File
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
import { resolveSandboxConfigForAgent } from "./sandbox/config.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
function listToolNames(params: {
cfg: OpenClawConfig;
agentId?: string;
sessionKey?: string;
sandboxAgentId?: string;
}): string[] {
const workspaceDir = "/tmp/openclaw-sandbox-policy";
const sessionKey = params.sessionKey ?? "agent:tavern:main";
const sandboxAgentId = params.sandboxAgentId ?? params.agentId ?? "tavern";
const sandbox = createPiToolsSandboxContext({
workspaceDir,
fsBridge: createHostSandboxFsBridge(workspaceDir),
sessionKey,
tools: resolveSandboxConfigForAgent(params.cfg, sandboxAgentId).tools,
});
return createOpenClawCodingTools({
config: params.cfg,
agentId: params.agentId,
sessionKey,
sandbox,
workspaceDir,
})
.map((tool) => tool.name)
.toSorted();
}
describe("pi-tools sandbox policy", () => {
it("re-exposes omitted sandbox tools via sandbox alsoAllow", () => {
const names = listToolNames({
cfg: {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [
{
id: "tavern",
tools: {
sandbox: {
tools: {
alsoAllow: ["message", "tts"],
},
},
},
},
],
},
} as OpenClawConfig,
});
expect(names).toContain("message");
expect(names).toContain("tts");
});
it("re-enables default-denied sandbox tools when explicitly allowed", () => {
const names = listToolNames({
cfg: {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [{ id: "tavern" }],
},
tools: {
sandbox: {
tools: {
allow: ["browser"],
},
},
},
} as OpenClawConfig,
});
expect(names).toContain("browser");
});
it("prefers the resolved sandbox context policy for legacy main session aliases", () => {
const cfg = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [
{
id: "tavern",
default: true,
tools: {
sandbox: {
tools: {
allow: ["browser"],
alsoAllow: ["message"],
},
},
},
},
],
},
} as OpenClawConfig;
const names = listToolNames({
cfg,
sessionKey: "main",
sandboxAgentId: "tavern",
});
expect(names).toContain("browser");
expect(names).toContain("message");
});
it("preserves allow-all semantics for allow: [] plus alsoAllow", () => {
const names = listToolNames({
cfg: {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [{ id: "tavern" }],
},
tools: {
sandbox: {
tools: {
allow: [],
alsoAllow: ["browser"],
},
},
},
} as OpenClawConfig,
});
expect(names).toContain("browser");
expect(names).toContain("read");
});
it("keeps explicit sandbox deny precedence over explicit allow", () => {
const names = listToolNames({
cfg: {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [{ id: "tavern" }],
},
tools: {
sandbox: {
tools: {
allow: ["browser", "message"],
deny: ["browser"],
},
},
},
} as OpenClawConfig,
});
expect(names).not.toContain("browser");
expect(names).toContain("message");
});
});
+7 -3
View File
@@ -300,6 +300,10 @@ export function createOpenClawCodingTools(options?: {
modelProvider: options?.modelProvider,
modelId: options?.modelId,
});
// Prefer the already-resolved sandbox context policy. Recomputing from
// sessionKey/config can lose the real sandbox agent when callers pass a
// legacy alias like `main` instead of an agent session key.
const sandboxToolPolicy = sandbox?.tools;
const groupPolicy = resolveGroupToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
@@ -338,7 +342,7 @@ export function createOpenClawCodingTools(options?: {
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
sandboxToolPolicy,
subagentPolicy,
]);
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
@@ -526,7 +530,7 @@ export function createOpenClawCodingTools(options?: {
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
sandboxToolPolicy,
subagentPolicy,
]),
currentChannelId: options?.currentChannelId,
@@ -594,7 +598,7 @@ export function createOpenClawCodingTools(options?: {
groupPolicy,
agentId,
}),
{ policy: sandbox?.tools, label: "sandbox tools.allow" },
{ policy: sandboxToolPolicy, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
+7 -99
View File
@@ -1,109 +1,17 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveAgentConfig } from "../agent-scope.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "../glob-pattern.js";
import { expandToolGroups } from "../tool-policy.js";
import { DEFAULT_TOOL_ALLOW, DEFAULT_TOOL_DENY } from "./constants.js";
import type {
SandboxToolPolicy,
SandboxToolPolicyResolved,
SandboxToolPolicySource,
} from "./types.js";
function normalizeGlob(value: string) {
return value.trim().toLowerCase();
}
import {
isToolAllowedBySandboxToolPolicy,
resolveEffectiveSandboxToolPolicyForAgent,
} from "../tool-policy-sandbox.js";
import type { SandboxToolPolicy, SandboxToolPolicyResolved } from "./types.js";
export function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const normalized = normalizeGlob(name);
const deny = compileGlobPatterns({
raw: expandToolGroups(policy.deny ?? []),
normalize: normalizeGlob,
});
if (matchesAnyGlobPattern(normalized, deny)) {
return false;
}
const allow = compileGlobPatterns({
raw: expandToolGroups(policy.allow ?? []),
normalize: normalizeGlob,
});
if (allow.length === 0) {
return true;
}
return matchesAnyGlobPattern(normalized, allow);
return isToolAllowedBySandboxToolPolicy(name, policy);
}
export function resolveSandboxToolPolicyForAgent(
cfg?: OpenClawConfig,
agentId?: string,
): SandboxToolPolicyResolved {
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentAllow = agentConfig?.tools?.sandbox?.tools?.allow;
const agentDeny = agentConfig?.tools?.sandbox?.tools?.deny;
const globalAllow = cfg?.tools?.sandbox?.tools?.allow;
const globalDeny = cfg?.tools?.sandbox?.tools?.deny;
const allowSource = Array.isArray(agentAllow)
? ({
source: "agent",
key: "agents.list[].tools.sandbox.tools.allow",
} satisfies SandboxToolPolicySource)
: Array.isArray(globalAllow)
? ({
source: "global",
key: "tools.sandbox.tools.allow",
} satisfies SandboxToolPolicySource)
: ({
source: "default",
key: "tools.sandbox.tools.allow",
} satisfies SandboxToolPolicySource);
const denySource = Array.isArray(agentDeny)
? ({
source: "agent",
key: "agents.list[].tools.sandbox.tools.deny",
} satisfies SandboxToolPolicySource)
: Array.isArray(globalDeny)
? ({
source: "global",
key: "tools.sandbox.tools.deny",
} satisfies SandboxToolPolicySource)
: ({
source: "default",
key: "tools.sandbox.tools.deny",
} satisfies SandboxToolPolicySource);
const deny = Array.isArray(agentDeny)
? agentDeny
: Array.isArray(globalDeny)
? globalDeny
: [...DEFAULT_TOOL_DENY];
const allow = Array.isArray(agentAllow)
? agentAllow
: Array.isArray(globalAllow)
? globalAllow
: [...DEFAULT_TOOL_ALLOW];
const expandedDeny = expandToolGroups(deny);
let expandedAllow = expandToolGroups(allow);
// `image` is essential for multimodal workflows; always include it in sandboxed
// sessions unless explicitly denied.
if (
// Empty allowlist means "allow all" for `isToolAllowed`, so don't inject a
// single tool that would accidentally turn it into an explicit allowlist.
expandedAllow.length > 0 &&
!expandedDeny.map((v) => v.toLowerCase()).includes("image") &&
!expandedAllow.map((v) => v.toLowerCase()).includes("image")
) {
expandedAllow = [...expandedAllow, "image"];
}
return {
allow: expandedAllow,
deny: expandedDeny,
sources: {
allow: allowSource,
deny: denySource,
},
};
return resolveEffectiveSandboxToolPolicyForAgent(cfg, agentId);
}
+209
View File
@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSandboxConfigForAgent } from "./sandbox/config.js";
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
import {
formatEffectiveSandboxToolPolicyBlockedMessage,
isToolAllowedBySandboxToolPolicy,
resolveEffectiveSandboxToolPolicyForAgent,
} from "./tool-policy-sandbox.js";
describe("tool-policy-sandbox", () => {
it("merges sandbox alsoAllow into the default sandbox allowlist", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [
{
id: "tavern",
tools: {
sandbox: {
tools: {
alsoAllow: ["message", "tts"],
},
},
},
},
],
},
};
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "tavern");
expect(resolved.allow).toContain("message");
expect(resolved.allow).toContain("tts");
expect(resolved.sources.allow).toEqual({
source: "agent",
key: "agents.list[].tools.sandbox.tools.alsoAllow",
});
});
it("lets explicit sandbox allow remove entries from the default sandbox denylist", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
},
tools: {
sandbox: {
tools: {
allow: ["browser"],
},
},
},
};
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "main");
expect(resolved.allow).toContain("browser");
expect(resolved.deny).not.toContain("browser");
expect(
isToolAllowedBySandboxToolPolicy("browser", {
allow: resolved.allow,
deny: resolved.deny,
}),
).toBe(true);
});
it("preserves allow-all semantics for allow: [] plus alsoAllow", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
},
tools: {
sandbox: {
tools: {
allow: [],
alsoAllow: ["browser"],
},
},
},
};
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "main");
expect(resolved.allow).toEqual([]);
expect(resolved.deny).not.toContain("browser");
expect(
isToolAllowedBySandboxToolPolicy("read", {
allow: resolved.allow,
deny: resolved.deny,
}),
).toBe(true);
expect(
isToolAllowedBySandboxToolPolicy("browser", {
allow: resolved.allow,
deny: resolved.deny,
}),
).toBe(true);
});
it("keeps canonical sandbox config and runtime status aligned with the effective resolver", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
list: [
{
id: "tavern",
tools: {
sandbox: {
tools: {
alsoAllow: ["message", "tts"],
},
},
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["browser"],
},
},
},
};
const sandbox = resolveSandboxConfigForAgent(cfg, "tavern");
expect(sandbox.tools.allow).toEqual(expect.arrayContaining(["browser", "message", "tts"]));
expect(sandbox.tools.deny).not.toContain("browser");
const runtime = resolveSandboxRuntimeStatus({
cfg,
sessionKey: "agent:tavern:main",
});
expect(runtime.toolPolicy.allow).toEqual(expect.arrayContaining(["browser", "message", "tts"]));
expect(runtime.toolPolicy.deny).not.toContain("browser");
});
it("keeps explicit sandbox deny precedence over allow and alsoAllow", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
},
tools: {
sandbox: {
tools: {
allow: ["browser"],
alsoAllow: ["message"],
deny: ["browser", "message"],
},
},
},
};
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "main");
expect(resolved.deny).toContain("browser");
expect(resolved.deny).toContain("message");
expect(
isToolAllowedBySandboxToolPolicy("browser", {
allow: resolved.allow,
deny: resolved.deny,
}),
).toBe(false);
expect(
isToolAllowedBySandboxToolPolicy("message", {
allow: resolved.allow,
deny: resolved.deny,
}),
).toBe(false);
});
it("uses the effective sandbox policy when formatting blocked-tool guidance", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent" },
},
},
tools: {
sandbox: {
tools: {
alsoAllow: ["message"],
},
},
},
};
const browserMessage = formatEffectiveSandboxToolPolicyBlockedMessage({
cfg,
sessionKey: "agent:main:main",
toolName: "browser",
});
expect(browserMessage).toContain('Tool "browser" blocked by sandbox tool policy');
expect(browserMessage).toContain("tools.sandbox.tools.deny");
const messageToolMessage = formatEffectiveSandboxToolPolicyBlockedMessage({
cfg,
sessionKey: "agent:main:main",
toolName: "message",
});
expect(messageToolMessage).toBeUndefined();
});
});
+358
View File
@@ -0,0 +1,358 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import {
canonicalizeMainSessionAlias,
resolveAgentMainSessionKey,
resolveMainSessionKey,
} from "../config/sessions/main-session.js";
import { resolveAgentConfig, resolveSessionAgentId } from "./agent-scope.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import { DEFAULT_TOOL_ALLOW, DEFAULT_TOOL_DENY } from "./sandbox/constants.js";
import type {
SandboxToolPolicy,
SandboxToolPolicyResolved,
SandboxToolPolicySource,
} from "./sandbox/types.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
type SandboxToolPolicyConfig = {
allow?: string[];
alsoAllow?: string[];
deny?: string[];
};
function buildSource(params: {
scope: "agent" | "global" | "default";
key: string;
}): SandboxToolPolicySource {
return {
source: params.scope,
key: params.key,
} satisfies SandboxToolPolicySource;
}
function pickConfiguredList(params: { agent?: string[]; global?: string[] }): {
values?: string[];
source: SandboxToolPolicySource;
} {
if (Array.isArray(params.agent)) {
return {
values: params.agent,
source: buildSource({ scope: "agent", key: "agents.list[].tools.sandbox.tools.allow" }),
};
}
if (Array.isArray(params.global)) {
return {
values: params.global,
source: buildSource({ scope: "global", key: "tools.sandbox.tools.allow" }),
};
}
return {
values: undefined,
source: buildSource({ scope: "default", key: "tools.sandbox.tools.allow" }),
};
}
function pickConfiguredDeny(params: { agent?: string[]; global?: string[] }): {
values?: string[];
source: SandboxToolPolicySource;
} {
if (Array.isArray(params.agent)) {
return {
values: params.agent,
source: buildSource({ scope: "agent", key: "agents.list[].tools.sandbox.tools.deny" }),
};
}
if (Array.isArray(params.global)) {
return {
values: params.global,
source: buildSource({ scope: "global", key: "tools.sandbox.tools.deny" }),
};
}
return {
values: undefined,
source: buildSource({ scope: "default", key: "tools.sandbox.tools.deny" }),
};
}
function pickConfiguredAlsoAllow(params: { agent?: string[]; global?: string[] }): {
values?: string[];
source?: SandboxToolPolicySource;
} {
if (Array.isArray(params.agent)) {
return {
values: params.agent,
source: buildSource({
scope: "agent",
key: "agents.list[].tools.sandbox.tools.alsoAllow",
}),
};
}
if (Array.isArray(params.global)) {
return {
values: params.global,
source: buildSource({ scope: "global", key: "tools.sandbox.tools.alsoAllow" }),
};
}
return { values: undefined, source: undefined };
}
function mergeAllowlist(base: string[] | undefined, extra: string[] | undefined): string[] {
if (Array.isArray(base)) {
// Preserve the existing sandbox meaning of `allow: []` => allow all.
if (base.length === 0) {
return [];
}
if (!Array.isArray(extra) || extra.length === 0) {
return [...base];
}
return Array.from(new Set([...base, ...extra]));
}
if (Array.isArray(extra) && extra.length > 0) {
return Array.from(new Set([...DEFAULT_TOOL_ALLOW, ...extra]));
}
return [...DEFAULT_TOOL_ALLOW];
}
function pickAllowSource(params: {
allow: SandboxToolPolicySource;
allowDefined: boolean;
alsoAllow?: SandboxToolPolicySource;
}): SandboxToolPolicySource {
if (params.allowDefined && params.allow.source === "agent") {
return params.allow;
}
if (params.alsoAllow?.source === "agent") {
return params.alsoAllow;
}
if (params.allowDefined && params.allow.source === "global") {
return params.allow;
}
if (params.alsoAllow?.source === "global") {
return params.alsoAllow;
}
return params.allow;
}
function resolveExplicitSandboxReAllowPatterns(params: {
allow?: string[];
alsoAllow?: string[];
}): string[] {
return Array.from(new Set([...(params.allow ?? []), ...(params.alsoAllow ?? [])]));
}
function filterDefaultDenyForExplicitAllows(params: {
deny: string[];
explicitAllowPatterns: string[];
}): string[] {
if (params.explicitAllowPatterns.length === 0) {
return [...params.deny];
}
const allowPatterns = compileGlobPatterns({
raw: expandToolGroups(params.explicitAllowPatterns),
normalize: normalizeToolName,
});
if (allowPatterns.length === 0) {
return [...params.deny];
}
return params.deny.filter(
(toolName) => !matchesAnyGlobPattern(normalizeToolName(toolName), allowPatterns),
);
}
function expandResolvedPolicy(policy: SandboxToolPolicy): SandboxToolPolicy {
const expandedDeny = expandToolGroups(policy.deny ?? []);
let expandedAllow = expandToolGroups(policy.allow ?? []);
// `image` is essential for multimodal workflows; keep the existing sandbox
// behavior that auto-includes it for explicit allowlists unless it is denied.
if (
expandedAllow.length > 0 &&
!expandedDeny.map((value) => value.toLowerCase()).includes("image") &&
!expandedAllow.map((value) => value.toLowerCase()).includes("image")
) {
expandedAllow = [...expandedAllow, "image"];
}
return {
allow: expandedAllow,
deny: expandedDeny,
};
}
export function resolveEffectiveSandboxToolPolicyForAgent(
cfg?: OpenClawConfig,
agentId?: string,
): SandboxToolPolicyResolved {
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentPolicy = agentConfig?.tools?.sandbox?.tools as SandboxToolPolicyConfig | undefined;
const globalPolicy = cfg?.tools?.sandbox?.tools as SandboxToolPolicyConfig | undefined;
const allowConfig = pickConfiguredList({
agent: agentPolicy?.allow,
global: globalPolicy?.allow,
});
const alsoAllowConfig = pickConfiguredAlsoAllow({
agent: agentPolicy?.alsoAllow,
global: globalPolicy?.alsoAllow,
});
const denyConfig = pickConfiguredDeny({
agent: agentPolicy?.deny,
global: globalPolicy?.deny,
});
const explicitAllowPatterns = resolveExplicitSandboxReAllowPatterns({
allow: allowConfig.values,
alsoAllow: alsoAllowConfig.values,
});
const resolvedAllow = mergeAllowlist(allowConfig.values, alsoAllowConfig.values);
const resolvedDeny = Array.isArray(denyConfig.values)
? [...denyConfig.values]
: filterDefaultDenyForExplicitAllows({
deny: [...DEFAULT_TOOL_DENY],
explicitAllowPatterns,
});
const expanded = expandResolvedPolicy({
allow: resolvedAllow,
deny: resolvedDeny,
});
return {
allow: expanded.allow ?? [],
deny: expanded.deny ?? [],
sources: {
allow: pickAllowSource({
allow: allowConfig.source,
allowDefined: Array.isArray(allowConfig.values),
alsoAllow: alsoAllowConfig.source,
}),
deny: denyConfig.source,
},
};
}
function classifyToolAgainstSandboxToolPolicy(name: string, policy?: SandboxToolPolicy) {
if (!policy) {
return {
blockedByDeny: false,
blockedByAllow: false,
};
}
const normalized = normalizeToolName(name);
const deny = compileGlobPatterns({
raw: expandToolGroups(policy.deny ?? []),
normalize: normalizeToolName,
});
const blockedByDeny = matchesAnyGlobPattern(normalized, deny);
const allow = compileGlobPatterns({
raw: expandToolGroups(policy.allow ?? []),
normalize: normalizeToolName,
});
const blockedByAllow =
!blockedByDeny && allow.length > 0 && !matchesAnyGlobPattern(normalized, allow);
return {
blockedByDeny,
blockedByAllow,
};
}
export function isToolAllowedBySandboxToolPolicy(
name: string,
policy?: SandboxToolPolicy,
): boolean {
const { blockedByDeny, blockedByAllow } = classifyToolAgainstSandboxToolPolicy(name, policy);
return !blockedByDeny && !blockedByAllow;
}
function resolveSandboxModeForAgent(
cfg?: OpenClawConfig,
agentId?: string,
): "off" | "non-main" | "all" {
const defaults = cfg?.agents?.defaults?.sandbox;
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
return agentConfig?.sandbox?.mode ?? defaults?.mode ?? "off";
}
function shouldSandboxSession(
mode: "off" | "non-main" | "all",
sessionKey: string,
mainKey: string,
) {
if (mode === "off") {
return false;
}
if (mode === "all") {
return true;
}
return sessionKey.trim() !== mainKey.trim();
}
export function formatEffectiveSandboxToolPolicyBlockedMessage(params: {
cfg?: OpenClawConfig;
sessionKey?: string;
toolName: string;
}): string | undefined {
const tool = params.toolName.trim().toLowerCase();
if (!tool) {
return undefined;
}
const sessionKey = params.sessionKey?.trim() ?? "";
const agentId = resolveSessionAgentId({
sessionKey,
config: params.cfg,
});
const mainSessionKey =
params.cfg?.session?.scope === "global"
? resolveMainSessionKey(params.cfg)
: resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
const comparableSessionKey = canonicalizeMainSessionAlias({
cfg: params.cfg,
agentId,
sessionKey,
});
const sandboxMode = resolveSandboxModeForAgent(params.cfg, agentId);
const sandboxed = sessionKey
? shouldSandboxSession(sandboxMode, comparableSessionKey, mainSessionKey)
: false;
if (!sandboxed) {
return undefined;
}
const policy = resolveEffectiveSandboxToolPolicyForAgent(params.cfg, agentId);
const { blockedByDeny, blockedByAllow } = classifyToolAgainstSandboxToolPolicy(tool, policy);
if (!blockedByDeny && !blockedByAllow) {
return undefined;
}
const reasons: string[] = [];
const fixes: string[] = [];
if (blockedByDeny) {
reasons.push("deny list");
fixes.push(`Remove "${tool}" from ${policy.sources.deny.key}.`);
}
if (blockedByAllow) {
reasons.push("allow list");
fixes.push(`Add "${tool}" to ${policy.sources.allow.key} (or set it to [] to allow all).`);
}
const lines = [
`Tool "${tool}" blocked by sandbox tool policy (mode=${sandboxMode}).`,
`Session: ${sessionKey || "(unknown)"}`,
`Reason: ${reasons.join(" + ")}`,
"Fix:",
"- agents.defaults.sandbox.mode=off (disable sandbox)",
...fixes.map((fix) => `- ${fix}`),
];
if (sandboxMode === "non-main") {
lines.push(`- Use main session key (direct): ${mainSessionKey}`);
}
lines.push(`- See: ${formatCliCommand(`openclaw sandbox explain --session ${sessionKey}`)}`);
return lines.join("\n");
}
+1 -12
View File
@@ -63,18 +63,7 @@ export function resolveProviderVisionModelFromConfig(params: {
| { models?: Array<{ id?: string; input?: string[] }> }
| undefined;
const models = providerCfg?.models ?? [];
const preferMinimaxVl =
params.provider === "minimax"
? models.find(
(m) =>
(m?.id ?? "").trim() === "MiniMax-VL-01" &&
Array.isArray(m?.input) &&
m.input.includes("image"),
)
: null;
const picked =
preferMinimaxVl ??
models.find((m) => Boolean((m?.id ?? "").trim()) && m.input?.includes("image"));
const picked = models.find((m) => Boolean((m?.id ?? "").trim()) && m.input?.includes("image"));
const id = (picked?.id ?? "").trim();
return id ? `${params.provider}/${id}` : null;
}
@@ -244,7 +244,7 @@ describe("directive behavior", () => {
api: "anthropic-messages",
models: [
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
],
},
},
@@ -124,8 +124,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "openclaw"),
models: {
"minimax/MiniMax-M2.7": {},
"minimax/MiniMax-M2.5": {},
"minimax/MiniMax-M2.5-highspeed": {},
"minimax/MiniMax-M2.7-highspeed": {},
"lmstudio/minimax-m2.5-gs32": {},
},
},
@@ -139,7 +138,7 @@ describe("directive behavior", () => {
api: "anthropic-messages",
models: [
makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"),
makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"),
makeModelDefinition("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed"),
],
},
lmstudio: {
@@ -153,11 +152,11 @@ describe("directive behavior", () => {
},
},
{
body: "/model minimax/m2.5",
body: "/model minimax/highspeed",
storePath: path.join(home, "sessions-provider-fuzzy.json"),
expectedSelection: {
provider: "minimax",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7-highspeed",
},
config: {
agents: {
@@ -166,8 +165,7 @@ describe("directive behavior", () => {
workspace: path.join(home, "openclaw"),
models: {
"minimax/MiniMax-M2.7": {},
"minimax/MiniMax-M2.5": {},
"minimax/MiniMax-M2.5-highspeed": {},
"minimax/MiniMax-M2.7-highspeed": {},
},
},
},
@@ -180,8 +178,7 @@ describe("directive behavior", () => {
api: "anthropic-messages",
models: [
makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"),
makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"),
makeModelDefinition("MiniMax-M2.5-highspeed", "MiniMax M2.5 Highspeed"),
makeModelDefinition("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed"),
],
},
},
+24 -24
View File
@@ -231,7 +231,7 @@ describe("buildStatusMessage", () => {
models: {
providers: {
"minimax-portal": {
models: [{ id: "MiniMax-M2.5", contextWindow: 200_000 }],
models: [{ id: "MiniMax-M2.7", contextWindow: 200_000 }],
},
xiaomi: {
models: [{ id: "mimo-v2-flash", contextWindow: 1_048_576 }],
@@ -248,9 +248,9 @@ describe("buildStatusMessage", () => {
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.5",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
contextTokens: 1_048_576,
@@ -263,7 +263,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.5");
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/200k");
expect(normalized).not.toContain("Context: 49k/1.0m");
});
@@ -274,7 +274,7 @@ describe("buildStatusMessage", () => {
models: {
providers: {
"minimax-portal": {
models: [{ id: "MiniMax-M2.5", contextWindow: 200_000 }],
models: [{ id: "MiniMax-M2.7", contextWindow: 200_000 }],
},
xiaomi: {
models: [{ id: "mimo-v2-flash", contextWindow: 1_048_576 }],
@@ -292,9 +292,9 @@ describe("buildStatusMessage", () => {
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.5",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
contextTokens: 1_048_576,
@@ -307,7 +307,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.5");
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/123k");
expect(normalized).not.toContain("Context: 49k/1.0m");
expect(normalized).not.toContain("Context: 49k/200k");
@@ -319,7 +319,7 @@ describe("buildStatusMessage", () => {
models: {
providers: {
"minimax-portal": {
models: [{ id: "MiniMax-M2.5", contextWindow: 200_000 }],
models: [{ id: "MiniMax-M2.7", contextWindow: 200_000 }],
},
xiaomi: {
models: [{ id: "mimo-v2-flash", contextWindow: 1_048_576 }],
@@ -336,9 +336,9 @@ describe("buildStatusMessage", () => {
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.5",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
contextTokens: 123_456,
@@ -351,7 +351,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.5");
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/123k");
expect(normalized).not.toContain("Context: 49k/1.0m");
expect(normalized).not.toContain("Context: 49k/200k");
@@ -363,7 +363,7 @@ describe("buildStatusMessage", () => {
models: {
providers: {
"minimax-portal": {
models: [{ id: "MiniMax-M2.5", contextWindow: 200_000 }],
models: [{ id: "MiniMax-M2.7", contextWindow: 200_000 }],
},
xiaomi: {
models: [{ id: "mimo-v2-flash", contextWindow: 1_048_576 }],
@@ -382,9 +382,9 @@ describe("buildStatusMessage", () => {
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.5",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
},
@@ -396,7 +396,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.5");
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/120k");
expect(normalized).not.toContain("Context: 49k/200k");
expect(normalized).not.toContain("Context: 49k/1.0m");
@@ -408,7 +408,7 @@ describe("buildStatusMessage", () => {
models: {
providers: {
"minimax-portal": {
models: [{ id: "MiniMax-M2.5", contextWindow: 200_000 }],
models: [{ id: "MiniMax-M2.7", contextWindow: 200_000 }],
},
xiaomi: {
models: [{ id: "mimo-v2-flash", contextWindow: 128_000 }],
@@ -427,9 +427,9 @@ describe("buildStatusMessage", () => {
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.5",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
},
@@ -441,7 +441,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.5");
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/128k");
expect(normalized).not.toContain("Context: 49k/200k");
});
@@ -452,7 +452,7 @@ describe("buildStatusMessage", () => {
models: {
providers: {
"minimax-portal": {
models: [{ id: "MiniMax-M2.5", contextWindow: 200_000 }],
models: [{ id: "MiniMax-M2.7", contextWindow: 200_000 }],
},
xiaomi: {
models: [{ id: "mimo-v2-flash", contextWindow: 1_048_576 }],
@@ -471,9 +471,9 @@ describe("buildStatusMessage", () => {
providerOverride: "xiaomi",
modelOverride: "mimo-v2-flash",
modelProvider: "minimax-portal",
model: "MiniMax-M2.5",
model: "MiniMax-M2.7",
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.5",
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
fallbackNoticeReason: "model not allowed",
totalTokens: 49_000,
},
@@ -485,7 +485,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.5");
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
expect(normalized).toContain("Context: 49k/200k");
expect(normalized).not.toContain("Context: 49k/1.0m");
});
+2 -2
View File
@@ -551,8 +551,8 @@ describe("primary model defaults", () => {
it("sets correct primary model", () => {
const configCases = [
{
getConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.5-highspeed"),
primaryModel: "minimax/MiniMax-M2.5-highspeed",
getConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7-highspeed"),
primaryModel: "minimax/MiniMax-M2.7-highspeed",
},
{
getConfig: () => applyZaiConfig({}, { modelId: "glm-5" }),
+48
View File
@@ -43,6 +43,54 @@ describe("sandbox explain command", () => {
expect(parsed).toHaveProperty("sandbox.tools.sources.allow.source");
expect(Array.isArray(parsed.fixIt)).toBe(true);
expect(parsed.fixIt).toContain("agents.defaults.sandbox.mode=off");
expect(parsed.fixIt).toContain("tools.sandbox.tools.alsoAllow");
expect(parsed.fixIt).toContain("tools.sandbox.tools.deny");
});
it("shows effective sandbox alsoAllow grants and default-deny removals", async () => {
mockCfg = {
agents: {
defaults: {
sandbox: { mode: "all", scope: "agent", workspaceAccess: "none" },
},
list: [
{
id: "tavern",
tools: {
sandbox: {
tools: {
alsoAllow: ["message", "tts"],
},
},
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["browser"],
},
},
},
session: { store: "/tmp/openclaw-test-sessions-{agentId}.json" },
};
const logs: string[] = [];
await sandboxExplainCommand({ json: true, agent: "tavern" }, {
log: (msg: string) => logs.push(msg),
error: (msg: string) => logs.push(msg),
exit: (_code: number) => {},
} as unknown as Parameters<typeof sandboxExplainCommand>[1]);
const parsed = JSON.parse(logs.join(""));
expect(parsed.sandbox.tools.allow).toEqual(
expect.arrayContaining(["browser", "message", "tts"]),
);
expect(parsed.sandbox.tools.deny).not.toContain("browser");
expect(parsed.sandbox.tools.sources.allow).toEqual({
source: "agent",
key: "agents.list[].tools.sandbox.tools.alsoAllow",
});
});
});
+5 -5
View File
@@ -1,8 +1,6 @@
import { resolveAgentConfig } from "../agents/agent-scope.js";
import {
resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js";
import { resolveSandboxConfigForAgent } from "../agents/sandbox.js";
import { resolveEffectiveSandboxToolPolicyForAgent } from "../agents/tool-policy-sandbox.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
@@ -148,7 +146,7 @@ export async function sandboxExplainCommand(
});
const sandboxCfg = resolveSandboxConfigForAgent(cfg, resolvedAgentId);
const toolPolicy = resolveSandboxToolPolicyForAgent(cfg, resolvedAgentId);
const toolPolicy = resolveEffectiveSandboxToolPolicyForAgent(cfg, resolvedAgentId);
const mainSessionKey = resolveAgentMainSessionKey({
cfg,
agentId: resolvedAgentId,
@@ -221,8 +219,10 @@ export async function sandboxExplainCommand(
fixIt.push("agents.list[].sandbox.mode=off");
}
fixIt.push("tools.sandbox.tools.allow");
fixIt.push("tools.sandbox.tools.alsoAllow");
fixIt.push("tools.sandbox.tools.deny");
fixIt.push("agents.list[].tools.sandbox.tools.allow");
fixIt.push("agents.list[].tools.sandbox.tools.alsoAllow");
fixIt.push("agents.list[].tools.sandbox.tools.deny");
fixIt.push("tools.elevated.enabled");
if (channel) {
+1 -1
View File
@@ -16289,6 +16289,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
tags: ["security", "auth"],
},
},
version: "2026.3.24-beta.2",
version: "2026.3.24",
generatedAt: "2026-03-22T21:17:33.302Z",
} as const satisfies BaseConfigSchemaResponse;
+4
View File
@@ -312,6 +312,8 @@ export type AgentToolsConfig = {
sandbox?: {
tools?: {
allow?: string[];
/** Additional allowlist entries merged into allow and/or the sandbox default allowlist. */
alsoAllow?: string[];
deny?: string[];
};
};
@@ -604,6 +606,8 @@ export type ToolsConfig = {
sandbox?: {
tools?: {
allow?: string[];
/** Additional allowlist entries merged into allow and/or the sandbox default allowlist. */
alsoAllow?: string[];
deny?: string[];
};
};
+60
View File
@@ -21,6 +21,11 @@ async function listMatchingDirs(root: string, prefix: string): Promise<string[]>
.map((entry) => entry.name);
}
async function listMatchingEntries(root: string, prefix: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name);
}
function normalizeDarwinTmpPath(filePath: string): string {
return process.platform === "darwin" && filePath.startsWith("/private/var/")
? filePath.slice("/private".length)
@@ -317,4 +322,59 @@ describe("installPackageDir", () => {
}),
);
});
it("hides the staged project .npmrc while npm install runs and restores it afterward", async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-"));
const sourceDir = path.join(fixtureRoot, "source");
const targetDir = path.join(fixtureRoot, "plugins", "demo");
const npmrcContent = "git=calc.exe\n";
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(
path.join(sourceDir, "package.json"),
JSON.stringify({
name: "demo-plugin",
version: "1.0.0",
dependencies: {
zod: "^4.0.0",
},
}),
"utf-8",
);
await fs.writeFile(path.join(sourceDir, ".npmrc"), npmrcContent, "utf-8");
vi.mocked(runCommandWithTimeout).mockImplementation(async (_argv, optionsOrTimeout) => {
const cwd = typeof optionsOrTimeout === "number" ? undefined : optionsOrTimeout.cwd;
expect(cwd).toBeTruthy();
await expect(fs.stat(path.join(cwd ?? "", ".npmrc"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
listMatchingEntries(cwd ?? "", ".openclaw-install-hidden-npmrc-"),
).resolves.toHaveLength(1);
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
const result = await installPackageDir({
sourceDir,
targetDir,
mode: "install",
timeoutMs: 1_000,
copyErrorPrefix: "failed to copy plugin",
hasDeps: true,
depsLogMessage: "Installing deps…",
});
expect(result).toEqual({ ok: true });
await expect(fs.readFile(path.join(targetDir, ".npmrc"), "utf8")).resolves.toBe(npmrcContent);
await expect(
listMatchingEntries(targetDir, ".openclaw-install-hidden-npmrc-"),
).resolves.toHaveLength(0);
});
});
+61 -13
View File
@@ -9,6 +9,14 @@ const INSTALL_BASE_CHANGED_ABORT_WARNING =
"Install base directory changed during install; aborting staged publish.";
const INSTALL_BASE_CHANGED_BACKUP_WARNING =
"Install base directory changed before backup cleanup; leaving backup in place.";
const STAGED_NPM_PROJECT_CONFIG_NAME = ".npmrc";
const STAGED_NPM_PROJECT_CONFIG_PREFIX = ".openclaw-install-hidden-npmrc-";
type HiddenProjectConfigFile = {
hiddenDir: string;
originalPath: string;
hiddenPath: string;
} | null;
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -55,6 +63,35 @@ async function sanitizeManifestForNpmInstall(targetDir: string): Promise<void> {
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
}
async function hideProjectNpmConfigForInstall(targetDir: string): Promise<HiddenProjectConfigFile> {
const originalPath = path.join(targetDir, STAGED_NPM_PROJECT_CONFIG_NAME);
let hiddenDir = "";
try {
hiddenDir = await fs.mkdtemp(path.join(targetDir, STAGED_NPM_PROJECT_CONFIG_PREFIX));
const hiddenPath = path.join(hiddenDir, STAGED_NPM_PROJECT_CONFIG_NAME);
await fs.rename(originalPath, hiddenPath);
return { hiddenDir, originalPath, hiddenPath };
} catch (error) {
if (hiddenDir) {
await fs.rm(hiddenDir, { recursive: true, force: true }).catch(() => undefined);
}
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
async function restoreProjectNpmConfigAfterInstall(
hiddenConfig: HiddenProjectConfigFile,
): Promise<void> {
if (!hiddenConfig) {
return;
}
await fs.rename(hiddenConfig.hiddenPath, hiddenConfig.originalPath);
await fs.rm(hiddenConfig.hiddenDir, { recursive: true, force: true });
}
async function assertInstallBoundaryPaths(params: {
installBaseDir: string;
candidatePaths: string[];
@@ -186,19 +223,30 @@ export async function installPackageDir(params: {
}
if (params.hasDeps) {
await sanitizeManifestForNpmInstall(stageDir);
params.logger?.info?.(params.depsLogMessage);
const npmRes = await runCommandWithTimeout(
// Plugins install into isolated directories, so omitting peer deps can strip
// runtime requirements that npm would otherwise materialize for the package.
["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"],
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
cwd: stageDir,
},
);
if (npmRes.code !== 0) {
return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`);
try {
await sanitizeManifestForNpmInstall(stageDir);
const hiddenProjectNpmConfig = await hideProjectNpmConfigForInstall(stageDir);
params.logger?.info?.(params.depsLogMessage);
const npmRes = await (async () => {
try {
return await runCommandWithTimeout(
// Plugins install into isolated directories, so omitting peer deps can strip
// runtime requirements that npm would otherwise materialize for the package.
["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"],
{
timeoutMs: Math.max(params.timeoutMs, 300_000),
cwd: stageDir,
},
);
} finally {
await restoreProjectNpmConfigAfterInstall(hiddenProjectNpmConfig);
}
})();
if (npmRes.code !== 0) {
return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`);
}
} catch (error) {
return await fail(`npm install failed: ${String(error)}`, error);
}
}
@@ -457,7 +457,7 @@ describe("provider discovery contract", () => {
apiKey: "minimax-key",
models: expect.arrayContaining([
expect.objectContaining({ id: "MiniMax-M2.7" }),
expect.objectContaining({ id: "MiniMax-VL-01" }),
expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }),
]),
},
});
+3 -1
View File
@@ -725,7 +725,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
const getJiti = (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const aliasMap = buildPluginLoaderAliasMap(modulePath);
// Pass loader's moduleUrl so the openclaw root can always be resolved even when
// loading external plugins from outside the installation directory (e.g. ~/.openclaw/extensions/).
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
+2 -20
View File
@@ -3,36 +3,18 @@ import { matchesExactOrPrefix } from "./provider-model-helpers.js";
export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.7";
export const MINIMAX_DEFAULT_MODEL_REF = `minimax/${MINIMAX_DEFAULT_MODEL_ID}`;
export const MINIMAX_TEXT_MODEL_ORDER = [
"MiniMax-M2",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
] as const;
export const MINIMAX_TEXT_MODEL_ORDER = ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"] as const;
export const MINIMAX_TEXT_MODEL_CATALOG = {
"MiniMax-M2": { name: "MiniMax M2", reasoning: true },
"MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: true },
"MiniMax-M2.1-highspeed": { name: "MiniMax M2.1 Highspeed", reasoning: true },
"MiniMax-M2.7": { name: "MiniMax M2.7", reasoning: true },
"MiniMax-M2.7-highspeed": { name: "MiniMax M2.7 Highspeed", reasoning: true },
"MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true },
"MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true },
} as const;
export const MINIMAX_TEXT_MODEL_REFS = MINIMAX_TEXT_MODEL_ORDER.map(
(modelId) => `minimax/${modelId}`,
);
export const MINIMAX_MODERN_MODEL_MATCHERS = [
"minimax-m2",
"minimax-m2.1",
"minimax-m2.5",
"minimax-m2.7",
] as const;
export const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
export function isMiniMaxModernModelId(modelId: string): boolean {
return matchesExactOrPrefix(modelId, MINIMAX_MODERN_MODEL_MATCHERS);
+47
View File
@@ -498,6 +498,53 @@ describe("plugin sdk alias helpers", () => {
);
});
it("resolves plugin-sdk aliases for user-installed plugins via moduleUrl hint", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
mkdirSafe(externalPluginRoot);
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
// Simulate loader.ts passing its own import.meta.url as the moduleUrl hint.
// This covers installations where argv1 does not resolve to the openclaw root
// (e.g. single-binary distributions or custom process launchers).
// Use openclaw.mjs which is created by createPluginSdkAliasFixture (bin+marker mode).
// Use fixture.root as cwd so process.cwd() fallback also resolves to fixture, not the
// real openclaw repo root in the test runner environment.
const loaderModuleUrl = pathToFileURL(path.join(fixture.root, "openclaw.mjs")).href;
// Use externalPluginRoot as cwd so process.cwd() fallback cannot accidentally
// resolve to the fixture root — only the moduleUrl hint can bridge the gap.
// Pass "" for argv1: undefined would trigger the STARTUP_ARGV1 default (the vitest
// runner binary, inside the openclaw repo), which resolves before moduleUrl is checked.
// An empty string is falsy so resolveTrustedOpenClawRootFromArgvHint returns null,
// meaning only the moduleUrl hint can bridge the gap.
const aliases = withCwd(externalPluginRoot, () =>
withEnv({ NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(
externalPluginEntry,
"", // explicitly disable argv1 (empty string bypasses STARTUP_ARGV1 default)
loaderModuleUrl,
),
),
);
expect(fs.realpathSync(aliases["openclaw/plugin-sdk"] ?? "")).toBe(
fs.realpathSync(sourceRootAlias),
);
expect(fs.realpathSync(aliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe(
fs.realpathSync(path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.ts")),
);
});
it("does not resolve plugin-sdk alias files from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
+20 -6
View File
@@ -235,10 +235,14 @@ const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
const cachedPluginSdkScopedAliasMaps = new Map<string, Record<string, string>>();
export function listPluginSdkExportedSubpaths(
params: { modulePath?: string; argv1?: string } = {},
params: { modulePath?: string; argv1?: string; moduleUrl?: string } = {},
): string[] {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath, argv1: params.argv1 });
const packageRoot = resolveLoaderPluginSdkPackageRoot({
modulePath,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
});
if (!packageRoot) {
return [];
}
@@ -252,10 +256,14 @@ export function listPluginSdkExportedSubpaths(
}
export function resolvePluginSdkScopedAliasMap(
params: { modulePath?: string; argv1?: string } = {},
params: { modulePath?: string; argv1?: string; moduleUrl?: string } = {},
): Record<string, string> {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath, argv1: params.argv1 });
const packageRoot = resolveLoaderPluginSdkPackageRoot({
modulePath,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
});
if (!packageRoot) {
return {};
}
@@ -269,7 +277,11 @@ export function resolvePluginSdkScopedAliasMap(
return cached;
}
const aliasMap: Record<string, string> = {};
for (const subpath of listPluginSdkExportedSubpaths({ modulePath, argv1: params.argv1 })) {
for (const subpath of listPluginSdkExportedSubpaths({
modulePath,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
})) {
const candidateMap = {
src: path.join(packageRoot, "src", "plugin-sdk", `${subpath}.ts`),
dist: path.join(packageRoot, "dist", "plugin-sdk", `${subpath}.js`),
@@ -317,18 +329,20 @@ export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {})
export function buildPluginLoaderAliasMap(
modulePath: string,
argv1: string | undefined = STARTUP_ARGV1,
moduleUrl?: string,
): Record<string, string> {
const pluginSdkAlias = resolvePluginSdkAliasFile({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
modulePath,
argv1,
moduleUrl,
});
const extensionApiAlias = resolveExtensionApiAlias({ modulePath });
return {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap({ modulePath, argv1 }),
...resolvePluginSdkScopedAliasMap({ modulePath, argv1, moduleUrl }),
};
}
+4 -2
View File
@@ -9,9 +9,9 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js";
import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js";
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
import { resolveEffectiveSandboxToolPolicyForAgent } from "../agents/tool-policy-sandbox.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -151,7 +151,9 @@ function resolveToolPolicies(params: {
pickSandboxToolPolicy(params.agentTools),
];
if (params.sandboxMode === "all") {
policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined));
policies.push(
resolveEffectiveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined),
);
}
return policies;
}
+5 -2
View File
@@ -5,10 +5,10 @@ import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/
*
* These functions analyze config-based security properties without I/O.
*/
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js";
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
import { resolveEffectiveSandboxToolPolicyForAgent } from "../agents/tool-policy-sandbox.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { resolveBrowserConfig } from "../browser/config.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -318,7 +318,10 @@ function resolveToolPolicies(params: {
}
if (params.sandboxMode === "all") {
const sandboxPolicy = resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined);
const sandboxPolicy = resolveEffectiveSandboxToolPolicyForAgent(
params.cfg,
params.agentId ?? undefined,
);
policies.push(sandboxPolicy);
}