Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cff6dc94e3 | |||
| 97a7e93db4 | |||
| e2e9f979ca | |||
| 7cc86e9685 | |||
| c2a2edb329 | |||
| a0b9dc0078 | |||
| bd4237c16c | |||
| edb5123f26 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 **case‑sensitive**:
|
||||
|
||||
- `minimax/MiniMax-M2.7`
|
||||
- `minimax/MiniMax-M2.7-highspeed`
|
||||
- `minimax/MiniMax-M2.5`
|
||||
- `minimax/MiniMax-M2.5-highspeed`
|
||||
|
||||
Then recheck with:
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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() })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+6
-9
@@ -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"),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user