mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 11:08:43 +08:00
Compare commits
18 Commits
codex/plug
...
v2026.3.24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff6dc94e3 | ||
|
|
97a7e93db4 | ||
|
|
e2e9f979ca | ||
|
|
7cc86e9685 | ||
|
|
c2a2edb329 | ||
|
|
a0b9dc0078 | ||
|
|
bd4237c16c | ||
|
|
edb5123f26 | ||
|
|
e9ac2860c1 | ||
|
|
da60aff17a | ||
|
|
ee714f5a42 | ||
|
|
ea08f2eb8c | ||
|
|
3c3fd8c386 | ||
|
|
436aa838fe | ||
|
|
284084672a | ||
|
|
66c88b4c77 | ||
|
|
c92002e1de | ||
|
|
39ad51426c |
65
CHANGELOG.md
65
CHANGELOG.md
@@ -8,8 +8,72 @@ 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
|
||||
|
||||
### Changes
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- Tests/security audit: isolate audit-test home and personal skill resolution so local `~/.agents/skills` installs no longer make maintainer prep runs fail nondeterministically. (#54473) thanks @huntharo
|
||||
|
||||
## 2026.3.24-beta.1
|
||||
|
||||
### Breaking
|
||||
@@ -74,6 +138,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Marketplace/agents: correct the ClawHub skill URL in agent docs and stream marketplace archive downloads to disk so installs avoid excess memory use and fail cleanly on empty responses. (#54160) Thanks @QuinnH496.
|
||||
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
|
||||
- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924.
|
||||
- Discord/commands: trim overlong slash-command descriptions to Discord's 100-character limit and map rejected deploy indexes from Discord validation payloads back to command names/descriptions, so deploys stop failing on long descriptions and startup logs identify the rejected commands. (#54118) thanks @huntharo
|
||||
|
||||
## 2026.3.23
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.24-beta.1
|
||||
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>
|
||||
|
||||
@@ -165,6 +165,30 @@ Set `stream: true` to receive Server-Sent Events (SSE):
|
||||
- Each event line is `data: <json>`
|
||||
- Stream ends with `data: [DONE]`
|
||||
|
||||
## Open WebUI quick setup
|
||||
|
||||
For a basic Open WebUI connection:
|
||||
|
||||
- Base URL: `http://127.0.0.1:18789/v1`
|
||||
- Docker on macOS base URL: `http://host.docker.internal:18789/v1`
|
||||
- API key: your Gateway bearer token
|
||||
- Model: `openclaw/default`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- `GET /v1/models` should list `openclaw/default`
|
||||
- Open WebUI should use `openclaw/default` as the chat model id
|
||||
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
|
||||
|
||||
Quick smoke:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/v1/models \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN'
|
||||
```
|
||||
|
||||
If that returns `openclaw/default`, most Open WebUI setups can connect with the same base URL and token.
|
||||
|
||||
## Examples
|
||||
|
||||
Non-streaming:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -424,10 +424,16 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
|
||||
## Docker runners (optional "works in Linux" checks)
|
||||
|
||||
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
These Docker runners split into two buckets:
|
||||
|
||||
- Live-model runners: `test:docker:live-models` and `test:docker:live-gateway` run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted).
|
||||
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
|
||||
|
||||
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
|
||||
- Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`)
|
||||
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
|
||||
- Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`)
|
||||
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
|
||||
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
|
||||
- Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
|
||||
@@ -440,6 +446,17 @@ real Telegram/Discord/etc. channel workers inside the container.
|
||||
`test:docker:live-models` still runs `pnpm test:live`, so pass through
|
||||
`OPENCLAW_LIVE_GATEWAY_*` as well when you need to narrow or exclude gateway
|
||||
live coverage from that Docker lane.
|
||||
`test:docker:openwebui` is a higher-level compatibility smoke: it starts an
|
||||
OpenClaw gateway container with the OpenAI-compatible HTTP endpoints enabled,
|
||||
starts a pinned Open WebUI container against that gateway, signs in through
|
||||
Open WebUI, verifies `/api/models` exposes `openclaw/default`, then sends a
|
||||
real chat request through Open WebUI's `/api/chat/completions` proxy.
|
||||
The first run can be noticeably slower because Docker may need to pull the
|
||||
Open WebUI image and Open WebUI may need to finish its own cold-start setup.
|
||||
This lane expects a usable live model key, and `OPENCLAW_PROFILE_FILE`
|
||||
(`~/.profile` by default) is the primary way to provide it in Dockerized runs.
|
||||
Successful runs print a small JSON payload like `{ "ok": true, "model":
|
||||
"openclaw/default", ... }`.
|
||||
|
||||
Manual ACP plain-language thread smoke (not CI):
|
||||
|
||||
@@ -458,6 +475,9 @@ Useful env vars:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run
|
||||
- `OPENCLAW_LIVE_GATEWAY_PROVIDERS=...` / `OPENCLAW_LIVE_PROVIDERS=...` to filter providers in-container
|
||||
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env)
|
||||
- `OPENCLAW_OPENWEBUI_MODEL=...` to choose the model exposed by the gateway for the Open WebUI smoke
|
||||
- `OPENCLAW_OPENWEBUI_PROMPT=...` to override the nonce-check prompt used by the Open WebUI smoke
|
||||
- `OPENWEBUI_IMAGE=...` to override the pinned Open WebUI image tag
|
||||
|
||||
## Docs sanity
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
|
||||
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
|
||||
- **Open source**: MIT licensed, community-driven
|
||||
|
||||
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.16+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.14+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ The Ansible playbook installs and configures:
|
||||
1. **Tailscale** -- mesh VPN for secure remote access
|
||||
2. **UFW firewall** -- SSH + Tailscale ports only
|
||||
3. **Docker CE + Compose V2** -- for agent sandboxes
|
||||
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.16+`, remains supported)
|
||||
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.14+`, remains supported)
|
||||
5. **OpenClaw** -- host-based, not containerized
|
||||
6. **Systemd service** -- auto-start with security hardening
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ Bun is an optional local runtime for running TypeScript directly (`bun run ...`,
|
||||
|
||||
Bun blocks dependency lifecycle scripts unless explicitly trusted. For this repo, the commonly blocked scripts are not required:
|
||||
|
||||
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`)
|
||||
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.14+`)
|
||||
- `protobufjs` `postinstall` -- emits warnings about incompatible version schemes (no build artifacts)
|
||||
|
||||
If you hit a runtime issue that requires these scripts, trust them explicitly:
|
||||
|
||||
@@ -45,7 +45,7 @@ For all flags and CI/automation options, see [Installer internals](/install/inst
|
||||
|
||||
## System requirements
|
||||
|
||||
- **Node 24** (recommended) or Node 22.16+ — the installer script handles this automatically
|
||||
- **Node 24** (recommended) or Node 22.14+ — the installer script handles this automatically
|
||||
- **macOS, Linux, or Windows** — both native Windows and WSL2 are supported; WSL2 is more stable. See [Windows](/platforms/windows).
|
||||
- `pnpm` is only needed if you build from source
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Recommended for most interactive installs on macOS/Linux/WSL.
|
||||
Supports macOS and Linux (including WSL). If macOS is detected, installs Homebrew if missing.
|
||||
</Step>
|
||||
<Step title="Ensure Node.js 24 by default">
|
||||
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility.
|
||||
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.14+`, for compatibility.
|
||||
</Step>
|
||||
<Step title="Ensure Git">
|
||||
Installs Git if missing.
|
||||
@@ -257,7 +257,7 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
Requires PowerShell 5+.
|
||||
</Step>
|
||||
<Step title="Ensure Node.js 24 by default">
|
||||
If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
|
||||
If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
|
||||
</Step>
|
||||
<Step title="Install OpenClaw">
|
||||
- `npm` method (default): global npm install using selected `-Tag`
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
|
||||
# Node.js
|
||||
|
||||
OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
|
||||
OpenClaw requires **Node 22.14 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
|
||||
|
||||
## Check your version
|
||||
|
||||
@@ -17,7 +17,7 @@ OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommen
|
||||
node -v
|
||||
```
|
||||
|
||||
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.16.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
|
||||
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.14.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
|
||||
|
||||
## Install Node
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t
|
||||
|
||||
## Beginner quick path (VPS)
|
||||
|
||||
1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility)
|
||||
1. Install Node 24 (recommended; Node 22 LTS, currently `22.14+`, still works for compatibility)
|
||||
2. `npm i -g openclaw@latest`
|
||||
3. `openclaw onboard --install-daemon`
|
||||
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
|
||||
|
||||
@@ -16,7 +16,7 @@ running (or attaches to an existing local Gateway if one is already running).
|
||||
|
||||
## Install the CLI (required for local mode)
|
||||
|
||||
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.16+`, still works for compatibility. Then install `openclaw` globally:
|
||||
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.14+`, still works for compatibility. Then install `openclaw` globally:
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@<version>
|
||||
|
||||
@@ -14,7 +14,7 @@ This guide covers the necessary steps to build and run the OpenClaw macOS applic
|
||||
Before building the app, ensure you have the following installed:
|
||||
|
||||
1. **Xcode 26.2+**: Required for Swift development.
|
||||
2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
|
||||
2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
||||
- calls [`scripts/codesign-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
|
||||
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
|
||||
- inject build metadata into Info.plist: `OpenClawBuildTimestamp` (UTC) and `OpenClawGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||
- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.16+`, remains supported for compatibility.
|
||||
- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.14+`, remains supported for compatibility.
|
||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ title: "Tests"
|
||||
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `forks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
|
||||
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
|
||||
|
||||
## Local PR gate
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ and a working chat session.
|
||||
|
||||
## What you need
|
||||
|
||||
- **Node.js** — Node 24 recommended (Node 22.16+ also supported)
|
||||
- **Node.js** — Node 24 recommended (Node 22.14+ also supported)
|
||||
- **An API key** from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you
|
||||
|
||||
<Tip>
|
||||
|
||||
@@ -21,7 +21,7 @@ For onboarding details, see [Onboarding (CLI)](/start/wizard).
|
||||
|
||||
## Prereqs (from source)
|
||||
|
||||
- Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported)
|
||||
- Node 24 recommended (Node 22 LTS, currently `22.14+`, still supported)
|
||||
- `pnpm`
|
||||
- Docker (optional; only for containerized setup/e2e — see [Docker](/install/docker))
|
||||
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
|
||||
|
||||
const { logVerboseMock } = vi.hoisted(() => ({
|
||||
logVerboseMock: vi.fn(),
|
||||
}));
|
||||
const { loggerWarnMock } = vi.hoisted(() => ({
|
||||
loggerWarnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
||||
"openclaw/plugin-sdk/runtime-env",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
createSubsystemLogger: () => ({
|
||||
child: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: loggerWarnMock,
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
logVerbose: logVerboseMock,
|
||||
};
|
||||
});
|
||||
|
||||
let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
|
||||
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
|
||||
let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager;
|
||||
@@ -77,6 +102,11 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
logVerboseMock.mockReset();
|
||||
loggerWarnMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses autocomplete for /acp action so inline action values are accepted", async () => {
|
||||
const command = createNativeCommand("acp");
|
||||
const action = requireOption(command, "action");
|
||||
@@ -168,4 +198,42 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
|
||||
expect(respond).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("truncates Discord command and option descriptions to Discord's limit", () => {
|
||||
const longDescription = "x".repeat(140);
|
||||
const cfg = {} as ReturnType<typeof loadConfig>;
|
||||
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
const command = createDiscordNativeCommand({
|
||||
command: {
|
||||
name: "longdesc",
|
||||
description: longDescription,
|
||||
acceptsArgs: true,
|
||||
args: [
|
||||
{
|
||||
name: "input",
|
||||
description: longDescription,
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
cfg,
|
||||
discordConfig,
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
|
||||
expect(command.description).toHaveLength(100);
|
||||
expect(command.description).toBe("x".repeat(100));
|
||||
expect(requireOption(command, "input").description).toHaveLength(100);
|
||||
expect(requireOption(command, "input").description).toBe("x".repeat(100));
|
||||
expect(loggerWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("truncating native command description (command:longdesc)"),
|
||||
);
|
||||
expect(loggerWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("truncating native command description (command:longdesc arg:input)"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,27 @@ import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
const log = createSubsystemLogger("discord/native-command");
|
||||
// Discord application command and option descriptions are limited to 1-100 chars.
|
||||
// https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure
|
||||
const DISCORD_COMMAND_DESCRIPTION_MAX = 100;
|
||||
|
||||
function truncateDiscordCommandDescription(params: { value: string; label: string }): string {
|
||||
const { value, label } = params;
|
||||
if (value.length <= DISCORD_COMMAND_DESCRIPTION_MAX) {
|
||||
return value;
|
||||
}
|
||||
log.warn(
|
||||
`discord: truncating native command description (${label}) from ${value.length} to ${DISCORD_COMMAND_DESCRIPTION_MAX}: ${JSON.stringify(value)}`,
|
||||
);
|
||||
return value.slice(0, DISCORD_COMMAND_DESCRIPTION_MAX);
|
||||
}
|
||||
|
||||
function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string {
|
||||
if (typeof command.nativeName === "string" && command.nativeName.trim().length > 0) {
|
||||
return command.nativeName;
|
||||
}
|
||||
return command.key;
|
||||
}
|
||||
|
||||
function resolveDiscordNativeCommandAllowlistAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -124,6 +145,7 @@ function buildDiscordCommandOptions(params: {
|
||||
) => Promise<{ provider?: string; model?: string } | null>;
|
||||
}): CommandOptions | undefined {
|
||||
const { command, cfg, authorizeChoiceContext, resolveChoiceContext } = params;
|
||||
const commandLabel = resolveDiscordCommandLogLabel(command);
|
||||
const args = command.args;
|
||||
if (!args || args.length === 0) {
|
||||
return undefined;
|
||||
@@ -133,7 +155,10 @@ function buildDiscordCommandOptions(params: {
|
||||
if (arg.type === "number") {
|
||||
return {
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
description: truncateDiscordCommandDescription({
|
||||
value: arg.description,
|
||||
label: `command:${commandLabel} arg:${arg.name}`,
|
||||
}),
|
||||
type: ApplicationCommandOptionType.Number,
|
||||
required,
|
||||
};
|
||||
@@ -141,7 +166,10 @@ function buildDiscordCommandOptions(params: {
|
||||
if (arg.type === "boolean") {
|
||||
return {
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
description: truncateDiscordCommandDescription({
|
||||
value: arg.description,
|
||||
label: `command:${commandLabel} arg:${arg.name}`,
|
||||
}),
|
||||
type: ApplicationCommandOptionType.Boolean,
|
||||
required,
|
||||
};
|
||||
@@ -192,7 +220,10 @@ function buildDiscordCommandOptions(params: {
|
||||
: undefined;
|
||||
return {
|
||||
name: arg.name,
|
||||
description: arg.description,
|
||||
description: truncateDiscordCommandDescription({
|
||||
value: arg.description,
|
||||
label: `command:${commandLabel} arg:${arg.name}`,
|
||||
}),
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required,
|
||||
choices,
|
||||
@@ -517,7 +548,10 @@ export function createDiscordNativeCommand(params: {
|
||||
|
||||
return new (class extends Command {
|
||||
name = command.name;
|
||||
description = command.description;
|
||||
description = truncateDiscordCommandDescription({
|
||||
value: command.description,
|
||||
label: `command:${command.name}`,
|
||||
});
|
||||
defer = true;
|
||||
ephemeral = ephemeralDefault;
|
||||
options = options;
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
} = getProviderMonitorTestMocks();
|
||||
|
||||
let monitorDiscordProvider: typeof import("./provider.js").monitorDiscordProvider;
|
||||
let providerTesting: typeof import("./provider.js").__testing;
|
||||
|
||||
function createConfigWithDiscordAccount(overrides: Record<string, unknown> = {}): OpenClawConfig {
|
||||
return {
|
||||
@@ -129,7 +130,7 @@ describe("monitorDiscordProvider", () => {
|
||||
vi.doMock("../token.js", () => ({
|
||||
normalizeDiscordToken: (value?: string) => value,
|
||||
}));
|
||||
({ monitorDiscordProvider } = await import("./provider.js"));
|
||||
({ monitorDiscordProvider, __testing: providerTesting } = await import("./provider.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -552,6 +553,57 @@ describe("monitorDiscordProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("formats rejected Discord deploy entries with command details", () => {
|
||||
const details = providerTesting.formatDiscordDeployErrorDetails({
|
||||
status: 400,
|
||||
discordCode: 50035,
|
||||
rawBody: {
|
||||
code: 50035,
|
||||
message: "Invalid Form Body",
|
||||
errors: {
|
||||
63: {
|
||||
description: {
|
||||
_errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }],
|
||||
},
|
||||
},
|
||||
65: {
|
||||
description: {
|
||||
_errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }],
|
||||
},
|
||||
},
|
||||
66: {
|
||||
description: {
|
||||
_errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }],
|
||||
},
|
||||
},
|
||||
67: {
|
||||
description: {
|
||||
_errors: [{ code: "BASE_TYPE_MAX_LENGTH", message: "Must be 100 or fewer." }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deployRequestBody: Array.from({ length: 68 }, (_entry, index) => ({
|
||||
name: `command-${index}`,
|
||||
description: `description-${index}`,
|
||||
})),
|
||||
});
|
||||
|
||||
expect(details).toContain("status=400");
|
||||
expect(details).toContain("code=50035");
|
||||
expect(details).toContain("rejected=");
|
||||
expect(details).toContain(
|
||||
'#63 fields=description name=command-63 description="description-63"',
|
||||
);
|
||||
expect(details).toContain(
|
||||
'#65 fields=description name=command-65 description="description-65"',
|
||||
);
|
||||
expect(details).toContain(
|
||||
'#66 fields=description name=command-66 description="description-66"',
|
||||
);
|
||||
expect(details).not.toContain("command-67");
|
||||
});
|
||||
|
||||
it("configures Carbon native deploy by default", async () => {
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
|
||||
@@ -312,8 +312,10 @@ async function deployDiscordCommands(params: {
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
attachDiscordDeployRequestBody(err, body);
|
||||
const details = formatDiscordDeployErrorDetails(err);
|
||||
params.runtime.error?.(
|
||||
`discord startup [${accountId}] deploy-rest:put:error ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt} error=${formatErrorMessage(err)}`,
|
||||
`discord startup [${accountId}] deploy-rest:put:error ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt} error=${formatErrorMessage(err)}${details}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
@@ -400,13 +402,108 @@ function logDiscordStartupPhase(params: {
|
||||
`discord startup [${params.accountId}] ${params.phase} ${elapsedMs}ms${suffix ? ` ${suffix}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT = 3;
|
||||
|
||||
type DiscordDeployErrorLike = {
|
||||
status?: unknown;
|
||||
discordCode?: unknown;
|
||||
rawBody?: unknown;
|
||||
deployRequestBody?: unknown;
|
||||
};
|
||||
|
||||
function attachDiscordDeployRequestBody(err: unknown, body: unknown) {
|
||||
if (!err || typeof err !== "object" || body === undefined) {
|
||||
return;
|
||||
}
|
||||
const deployErr = err as DiscordDeployErrorLike;
|
||||
if (deployErr.deployRequestBody === undefined) {
|
||||
deployErr.deployRequestBody = body;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyDiscordDeployField(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return inspect(value, { depth: 2, breakLength: 120 });
|
||||
}
|
||||
}
|
||||
|
||||
function readDiscordDeployRejectedFields(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string").slice(0, 6);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(value).slice(0, 6);
|
||||
}
|
||||
|
||||
function resolveDiscordRejectedDeployEntriesSource(
|
||||
rawBody: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (!rawBody || typeof rawBody !== "object") {
|
||||
return null;
|
||||
}
|
||||
const payload = rawBody as { errors?: unknown };
|
||||
const errors = payload.errors && typeof payload.errors === "object" ? payload.errors : undefined;
|
||||
const source = errors ?? rawBody;
|
||||
return source && typeof source === "object" ? (source as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function formatDiscordRejectedDeployEntries(params: {
|
||||
rawBody: unknown;
|
||||
requestBody: unknown;
|
||||
}): string[] {
|
||||
const requestBody = Array.isArray(params.requestBody) ? params.requestBody : null;
|
||||
const rejectedEntriesSource = resolveDiscordRejectedDeployEntriesSource(params.rawBody);
|
||||
if (!rejectedEntriesSource || !requestBody || requestBody.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rawEntries = Object.entries(rejectedEntriesSource).filter(([key]) => /^\d+$/.test(key));
|
||||
return rawEntries.slice(0, DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT).flatMap(([key, value]) => {
|
||||
const index = Number.parseInt(key, 10);
|
||||
if (!Number.isFinite(index) || index < 0 || index >= requestBody.length) {
|
||||
return [];
|
||||
}
|
||||
const command = requestBody[index];
|
||||
if (!command || typeof command !== "object") {
|
||||
return [`#${index} fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`];
|
||||
}
|
||||
const payload = command as {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
options?: unknown;
|
||||
};
|
||||
const parts = [
|
||||
`#${index}`,
|
||||
`fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`,
|
||||
];
|
||||
if (typeof payload.name === "string" && payload.name.trim().length > 0) {
|
||||
parts.push(`name=${payload.name}`);
|
||||
}
|
||||
if (payload.description !== undefined) {
|
||||
parts.push(`description=${stringifyDiscordDeployField(payload.description)}`);
|
||||
}
|
||||
if (Array.isArray(payload.options) && payload.options.length > 0) {
|
||||
parts.push(`options=${payload.options.length}`);
|
||||
}
|
||||
return [parts.join(" ")];
|
||||
});
|
||||
}
|
||||
|
||||
function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
if (!err || typeof err !== "object") {
|
||||
return "";
|
||||
}
|
||||
const status = (err as { status?: unknown }).status;
|
||||
const discordCode = (err as { discordCode?: unknown }).discordCode;
|
||||
const rawBody = (err as { rawBody?: unknown }).rawBody;
|
||||
const status = (err as DiscordDeployErrorLike).status;
|
||||
const discordCode = (err as DiscordDeployErrorLike).discordCode;
|
||||
const rawBody = (err as DiscordDeployErrorLike).rawBody;
|
||||
const requestBody = (err as DiscordDeployErrorLike).deployRequestBody;
|
||||
const details: string[] = [];
|
||||
if (typeof status === "number") {
|
||||
details.push(`status=${status}`);
|
||||
@@ -428,6 +525,10 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
details.push(`body=${trimmed}`);
|
||||
}
|
||||
}
|
||||
const rejectedEntries = formatDiscordRejectedDeployEntries({ rawBody, requestBody });
|
||||
if (rejectedEntries.length > 0) {
|
||||
details.push(`rejected=${rejectedEntries.join("; ")}`);
|
||||
}
|
||||
return details.length > 0 ? ` (${details.join(", ")})` : "";
|
||||
}
|
||||
|
||||
@@ -1058,4 +1159,5 @@ export const __testing = {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDiscordRestFetch,
|
||||
resolveThreadBindingsEnabled: resolveThreadBindingsEnabledForTesting,
|
||||
formatDiscordDeployErrorDetails,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
122
extensions/feishu/src/monitor.cleanup.test.ts
Normal file
122
extensions/feishu/src/monitor.cleanup.test.ts
Normal file
@@ -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.
|
||||
|
||||
176
extensions/minimax/image-generation-provider.ts
Normal file
176
extensions/minimax/image-generation-provider.ts
Normal file
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.3.24-beta.1",
|
||||
"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",
|
||||
@@ -704,13 +704,14 @@
|
||||
"test:contracts:plugins": "OPENCLAW_TEST_PROFILE=low pnpm test -- src/plugins/contracts",
|
||||
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
|
||||
"test:coverage:changed": "vitest run --config vitest.unit.config.ts --coverage --changed origin/main",
|
||||
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
|
||||
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
|
||||
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
|
||||
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
|
||||
"test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh",
|
||||
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
|
||||
"test:docker:live-models": "bash scripts/test-live-models-docker.sh",
|
||||
"test:docker:onboard": "bash scripts/e2e/onboard-docker.sh",
|
||||
"test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh",
|
||||
"test:docker:plugins": "bash scripts/e2e/plugins-docker.sh",
|
||||
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
@@ -836,7 +837,7 @@
|
||||
"openshell": "0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.16.0"
|
||||
"node": ">=22.14.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"pnpm": {
|
||||
|
||||
@@ -18,7 +18,7 @@ ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
USER appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc openclaw.mjs ./
|
||||
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
|
||||
COPY --chown=appuser:appuser extensions ./extensions
|
||||
COPY --chown=appuser:appuser patches ./patches
|
||||
@@ -26,7 +26,7 @@ COPY --chown=appuser:appuser patches ./patches
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts ./
|
||||
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts openclaw.mjs ./
|
||||
COPY --chown=appuser:appuser src ./src
|
||||
COPY --chown=appuser:appuser test ./test
|
||||
COPY --chown=appuser:appuser scripts ./scripts
|
||||
|
||||
184
scripts/e2e/openwebui-docker.sh
Executable file
184
scripts/e2e/openwebui-docker.sh
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/live-docker-auth.sh"
|
||||
|
||||
IMAGE_NAME="openclaw-openwebui-e2e"
|
||||
OPENWEBUI_IMAGE="${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.8.10}"
|
||||
PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}"
|
||||
MODEL="${OPENCLAW_OPENWEBUI_MODEL:-openai/gpt-5.4}"
|
||||
PROMPT_NONCE="OPENWEBUI_DOCKER_E2E_$(date +%s)_$$"
|
||||
PROMPT="${OPENCLAW_OPENWEBUI_PROMPT:-Reply with exactly this token and nothing else: ${PROMPT_NONCE}}"
|
||||
PORT="${OPENCLAW_OPENWEBUI_GATEWAY_PORT:-18789}"
|
||||
WEBUI_PORT="${OPENCLAW_OPENWEBUI_PORT:-8080}"
|
||||
TOKEN="openwebui-e2e-$(date +%s)-$$"
|
||||
ADMIN_EMAIL="${OPENCLAW_OPENWEBUI_ADMIN_EMAIL:-openwebui-e2e@example.com}"
|
||||
ADMIN_PASSWORD="${OPENCLAW_OPENWEBUI_ADMIN_PASSWORD:-OpenWebUI-E2E-Password-$(date +%s)-$$}"
|
||||
NET_NAME="openclaw-openwebui-e2e-$$"
|
||||
GW_NAME="openclaw-openwebui-gateway-$$"
|
||||
OW_NAME="openclaw-openwebui-$$"
|
||||
|
||||
PROFILE_MOUNT=()
|
||||
if [[ -f "$PROFILE_FILE" ]]; then
|
||||
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro)
|
||||
fi
|
||||
|
||||
AUTH_DIRS=()
|
||||
if [[ -n "${OPENCLAW_DOCKER_AUTH_DIRS:-}" ]]; then
|
||||
while IFS= read -r auth_dir; do
|
||||
[[ -n "$auth_dir" ]] || continue
|
||||
AUTH_DIRS+=("$auth_dir")
|
||||
done < <(openclaw_live_collect_auth_dirs)
|
||||
fi
|
||||
AUTH_DIRS_CSV="$(openclaw_live_join_csv "${AUTH_DIRS[@]}")"
|
||||
|
||||
EXTERNAL_AUTH_MOUNTS=()
|
||||
for auth_dir in "${AUTH_DIRS[@]}"; do
|
||||
host_path="$HOME/$auth_dir"
|
||||
if [[ -d "$host_path" ]]; then
|
||||
EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro)
|
||||
fi
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$OW_NAME" >/dev/null 2>&1 || true
|
||||
docker rm -f "$GW_NAME" >/dev/null 2>&1 || true
|
||||
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Pulling Open WebUI image: $OPENWEBUI_IMAGE"
|
||||
docker pull "$OPENWEBUI_IMAGE" >/dev/null
|
||||
|
||||
echo "Creating Docker network..."
|
||||
docker network create "$NET_NAME" >/dev/null
|
||||
|
||||
echo "Starting gateway container..."
|
||||
docker run -d \
|
||||
--name "$GW_NAME" \
|
||||
--network "$NET_NAME" \
|
||||
-e "OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED=$AUTH_DIRS_CSV" \
|
||||
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
||||
-e "OPENCLAW_OPENWEBUI_MODEL=$MODEL" \
|
||||
-e "OPENCLAW_SKIP_CHANNELS=1" \
|
||||
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
||||
-e "OPENCLAW_SKIP_CRON=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
"${EXTERNAL_AUTH_MOUNTS[@]}" \
|
||||
"${PROFILE_MOUNT[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
[ -f "$HOME/.profile" ] && source "$HOME/.profile" || true
|
||||
IFS="," read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}"
|
||||
for auth_dir in "${auth_dirs[@]}"; do
|
||||
[ -n "$auth_dir" ] || continue
|
||||
if [ -d "/host-auth/$auth_dir" ]; then
|
||||
mkdir -p "$HOME/$auth_dir"
|
||||
cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir"
|
||||
chmod -R u+rwX "$HOME/$auth_dir" || true
|
||||
fi
|
||||
done
|
||||
|
||||
entry=dist/index.mjs
|
||||
[ -f "$entry" ] || entry=dist/index.js
|
||||
|
||||
node "$entry" config set gateway.controlUi.enabled false >/dev/null
|
||||
node "$entry" config set gateway.mode local >/dev/null
|
||||
node "$entry" config set gateway.bind lan >/dev/null
|
||||
node "$entry" config set gateway.auth.mode token >/dev/null
|
||||
node "$entry" config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" >/dev/null
|
||||
node "$entry" config set gateway.http.endpoints.chatCompletions.enabled true --strict-json >/dev/null
|
||||
node "$entry" config set agents.defaults.model.primary "$OPENCLAW_OPENWEBUI_MODEL" >/dev/null
|
||||
|
||||
exec node "$entry" gateway --port '"$PORT"' --bind lan --allow-unconfigured > /tmp/openwebui-gateway.log 2>&1
|
||||
'
|
||||
|
||||
echo "Waiting for gateway HTTP surface..."
|
||||
gateway_ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$GW_NAME" 2>/dev/null || echo false)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
if docker exec "$GW_NAME" bash -lc "node --input-type=module -e '
|
||||
const res = await fetch(\"http://127.0.0.1:$PORT/v1/models\", {
|
||||
headers: { authorization: \"Bearer $TOKEN\" },
|
||||
}).catch(() => null);
|
||||
process.exit(res?.status === 200 ? 0 : 1);
|
||||
' >/dev/null 2>&1"; then
|
||||
gateway_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$gateway_ready" -ne 1 ]; then
|
||||
echo "Gateway failed to start"
|
||||
docker logs "$GW_NAME" 2>&1 | tail -n 200 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting Open WebUI container..."
|
||||
docker run -d \
|
||||
--name "$OW_NAME" \
|
||||
--network "$NET_NAME" \
|
||||
-e ENV=prod \
|
||||
-e WEBUI_NAME="OpenClaw E2E" \
|
||||
-e WEBUI_SECRET_KEY="openclaw-openwebui-e2e-secret" \
|
||||
-e OFFLINE_MODE=True \
|
||||
-e ENABLE_VERSION_UPDATE_CHECK=False \
|
||||
-e ENABLE_PERSISTENT_CONFIG=False \
|
||||
-e ENABLE_OLLAMA_API=False \
|
||||
-e ENABLE_OPENAI_API=True \
|
||||
-e OPENAI_API_BASE_URLS="http://$GW_NAME:$PORT/v1" \
|
||||
-e OPENAI_API_KEY="$TOKEN" \
|
||||
-e OPENAI_API_KEYS="$TOKEN" \
|
||||
-e RAG_EMBEDDING_MODEL_AUTO_UPDATE=False \
|
||||
-e RAG_RERANKING_MODEL_AUTO_UPDATE=False \
|
||||
-e WEBUI_ADMIN_EMAIL="$ADMIN_EMAIL" \
|
||||
-e WEBUI_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
|
||||
-e WEBUI_ADMIN_NAME="OpenClaw E2E" \
|
||||
-e ENABLE_SIGNUP=False \
|
||||
-e DEFAULT_MODELS="openclaw/default" \
|
||||
"$OPENWEBUI_IMAGE" >/dev/null
|
||||
|
||||
echo "Waiting for Open WebUI..."
|
||||
ow_ready=0
|
||||
for _ in $(seq 1 90); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$OW_NAME" 2>/dev/null || echo false)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
if docker exec "$GW_NAME" bash -lc "node --input-type=module -e '
|
||||
const res = await fetch(\"http://$OW_NAME:$WEBUI_PORT/\").catch(() => null);
|
||||
process.exit(res && res.status < 500 ? 0 : 1);
|
||||
' >/dev/null 2>&1"; then
|
||||
ow_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ow_ready" -ne 1 ]; then
|
||||
echo "Open WebUI failed to start"
|
||||
docker logs "$OW_NAME" 2>&1 | tail -n 200 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Open WebUI -> OpenClaw smoke..."
|
||||
docker exec \
|
||||
-e "OPENWEBUI_BASE_URL=http://$OW_NAME:$WEBUI_PORT" \
|
||||
-e "OPENWEBUI_ADMIN_EMAIL=$ADMIN_EMAIL" \
|
||||
-e "OPENWEBUI_ADMIN_PASSWORD=$ADMIN_PASSWORD" \
|
||||
-e "OPENWEBUI_EXPECTED_NONCE=$PROMPT_NONCE" \
|
||||
-e "OPENWEBUI_PROMPT=$PROMPT" \
|
||||
"$GW_NAME" \
|
||||
node /app/scripts/e2e/openwebui-probe.mjs
|
||||
|
||||
echo "Open WebUI container logs:"
|
||||
docker logs "$OW_NAME" 2>&1 | tail -n 80 || true
|
||||
|
||||
echo "OK"
|
||||
95
scripts/e2e/openwebui-probe.mjs
Normal file
95
scripts/e2e/openwebui-probe.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
const baseUrl = process.env.OPENWEBUI_BASE_URL ?? "";
|
||||
const email = process.env.OPENWEBUI_ADMIN_EMAIL ?? "";
|
||||
const password = process.env.OPENWEBUI_ADMIN_PASSWORD ?? "";
|
||||
const expectedNonce = process.env.OPENWEBUI_EXPECTED_NONCE ?? "";
|
||||
const prompt = process.env.OPENWEBUI_PROMPT ?? "";
|
||||
|
||||
if (!baseUrl || !email || !password || !expectedNonce || !prompt) {
|
||||
throw new Error("Missing required OPENWEBUI_* environment variables");
|
||||
}
|
||||
|
||||
function getCookieHeader(res) {
|
||||
const raw = res.headers.get("set-cookie");
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return raw
|
||||
.split(/,(?=[^;]+=[^;]+)/g)
|
||||
.map((part) => part.split(";", 1)[0]?.trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function buildAuthHeaders(token, cookie) {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (cookie) {
|
||||
headers.cookie = cookie;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
const signinRes = await fetch(`${baseUrl}/api/v1/auths/signin`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!signinRes.ok) {
|
||||
const body = await signinRes.text();
|
||||
throw new Error(`signin failed: HTTP ${signinRes.status} ${body}`);
|
||||
}
|
||||
|
||||
const signinJson = await signinRes.json();
|
||||
const token =
|
||||
signinJson?.token ?? signinJson?.access_token ?? signinJson?.jwt ?? signinJson?.data?.token ?? "";
|
||||
const cookie = getCookieHeader(signinRes);
|
||||
const authHeaders = {
|
||||
...buildAuthHeaders(token, cookie),
|
||||
accept: "application/json",
|
||||
};
|
||||
|
||||
const modelsRes = await fetch(`${baseUrl}/api/models`, { headers: authHeaders });
|
||||
if (!modelsRes.ok) {
|
||||
throw new Error(`/api/models failed: HTTP ${modelsRes.status} ${await modelsRes.text()}`);
|
||||
}
|
||||
const modelsJson = await modelsRes.json();
|
||||
const models = Array.isArray(modelsJson)
|
||||
? modelsJson
|
||||
: Array.isArray(modelsJson?.data)
|
||||
? modelsJson.data
|
||||
: Array.isArray(modelsJson?.models)
|
||||
? modelsJson.models
|
||||
: [];
|
||||
const modelIds = models
|
||||
.map((entry) => entry?.id ?? entry?.model ?? entry?.name)
|
||||
.filter((value) => typeof value === "string");
|
||||
const targetModel =
|
||||
modelIds.find((id) => id === "openclaw/default") ?? modelIds.find((id) => id === "openclaw");
|
||||
if (!targetModel) {
|
||||
throw new Error(`openclaw model missing from Open WebUI model list: ${JSON.stringify(modelIds)}`);
|
||||
}
|
||||
|
||||
const chatRes = await fetch(`${baseUrl}/api/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: targetModel,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
if (!chatRes.ok) {
|
||||
throw new Error(`/api/chat/completions failed: HTTP ${chatRes.status} ${await chatRes.text()}`);
|
||||
}
|
||||
const chatJson = await chatRes.json();
|
||||
const reply =
|
||||
chatJson?.choices?.[0]?.message?.content ?? chatJson?.message?.content ?? chatJson?.content ?? "";
|
||||
if (typeof reply !== "string" || !reply.includes(expectedNonce)) {
|
||||
throw new Error(`chat reply missing nonce: ${JSON.stringify(reply)}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, model: targetModel, reply }, null, 2));
|
||||
@@ -18,7 +18,7 @@ NC='\033[0m' # No Color
|
||||
DEFAULT_TAGLINE="All your chats, one OpenClaw."
|
||||
NODE_DEFAULT_MAJOR=24
|
||||
NODE_MIN_MAJOR=22
|
||||
NODE_MIN_MINOR=16
|
||||
NODE_MIN_MINOR=14
|
||||
NODE_MIN_VERSION="${NODE_MIN_MAJOR}.${NODE_MIN_MINOR}"
|
||||
|
||||
ORIGINAL_PATH="${PATH:-}"
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
|
||||
164
src/agents/pi-tools.sandbox-policy.test.ts
Normal file
164
src/agents/pi-tools.sandbox-policy.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
import { resolveSandboxConfigForAgent } from "./sandbox/config.js";
|
||||
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
|
||||
|
||||
function listToolNames(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sandboxAgentId?: string;
|
||||
}): string[] {
|
||||
const workspaceDir = "/tmp/openclaw-sandbox-policy";
|
||||
const sessionKey = params.sessionKey ?? "agent:tavern:main";
|
||||
const sandboxAgentId = params.sandboxAgentId ?? params.agentId ?? "tavern";
|
||||
const sandbox = createPiToolsSandboxContext({
|
||||
workspaceDir,
|
||||
fsBridge: createHostSandboxFsBridge(workspaceDir),
|
||||
sessionKey,
|
||||
tools: resolveSandboxConfigForAgent(params.cfg, sandboxAgentId).tools,
|
||||
});
|
||||
return createOpenClawCodingTools({
|
||||
config: params.cfg,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
sandbox,
|
||||
workspaceDir,
|
||||
})
|
||||
.map((tool) => tool.name)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
describe("pi-tools sandbox policy", () => {
|
||||
it("re-exposes omitted sandbox tools via sandbox alsoAllow", () => {
|
||||
const names = listToolNames({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "tavern",
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
alsoAllow: ["message", "tts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(names).toContain("message");
|
||||
expect(names).toContain("tts");
|
||||
});
|
||||
|
||||
it("re-enables default-denied sandbox tools when explicitly allowed", () => {
|
||||
const names = listToolNames({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [{ id: "tavern" }],
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(names).toContain("browser");
|
||||
});
|
||||
|
||||
it("prefers the resolved sandbox context policy for legacy main session aliases", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "tavern",
|
||||
default: true,
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
alsoAllow: ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const names = listToolNames({
|
||||
cfg,
|
||||
sessionKey: "main",
|
||||
sandboxAgentId: "tavern",
|
||||
});
|
||||
|
||||
expect(names).toContain("browser");
|
||||
expect(names).toContain("message");
|
||||
});
|
||||
|
||||
it("preserves allow-all semantics for allow: [] plus alsoAllow", () => {
|
||||
const names = listToolNames({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [{ id: "tavern" }],
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: [],
|
||||
alsoAllow: ["browser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(names).toContain("browser");
|
||||
expect(names).toContain("read");
|
||||
});
|
||||
|
||||
it("keeps explicit sandbox deny precedence over explicit allow", () => {
|
||||
const names = listToolNames({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [{ id: "tavern" }],
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["browser", "message"],
|
||||
deny: ["browser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(names).not.toContain("browser");
|
||||
expect(names).toContain("message");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
209
src/agents/tool-policy-sandbox.test.ts
Normal file
209
src/agents/tool-policy-sandbox.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSandboxConfigForAgent } from "./sandbox/config.js";
|
||||
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
|
||||
import {
|
||||
formatEffectiveSandboxToolPolicyBlockedMessage,
|
||||
isToolAllowedBySandboxToolPolicy,
|
||||
resolveEffectiveSandboxToolPolicyForAgent,
|
||||
} from "./tool-policy-sandbox.js";
|
||||
|
||||
describe("tool-policy-sandbox", () => {
|
||||
it("merges sandbox alsoAllow into the default sandbox allowlist", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "tavern",
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
alsoAllow: ["message", "tts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "tavern");
|
||||
expect(resolved.allow).toContain("message");
|
||||
expect(resolved.allow).toContain("tts");
|
||||
expect(resolved.sources.allow).toEqual({
|
||||
source: "agent",
|
||||
key: "agents.list[].tools.sandbox.tools.alsoAllow",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit sandbox allow remove entries from the default sandbox denylist", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "main");
|
||||
expect(resolved.allow).toContain("browser");
|
||||
expect(resolved.deny).not.toContain("browser");
|
||||
expect(
|
||||
isToolAllowedBySandboxToolPolicy("browser", {
|
||||
allow: resolved.allow,
|
||||
deny: resolved.deny,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves allow-all semantics for allow: [] plus alsoAllow", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: [],
|
||||
alsoAllow: ["browser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "main");
|
||||
expect(resolved.allow).toEqual([]);
|
||||
expect(resolved.deny).not.toContain("browser");
|
||||
expect(
|
||||
isToolAllowedBySandboxToolPolicy("read", {
|
||||
allow: resolved.allow,
|
||||
deny: resolved.deny,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isToolAllowedBySandboxToolPolicy("browser", {
|
||||
allow: resolved.allow,
|
||||
deny: resolved.deny,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps canonical sandbox config and runtime status aligned with the effective resolver", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "tavern",
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
alsoAllow: ["message", "tts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sandbox = resolveSandboxConfigForAgent(cfg, "tavern");
|
||||
expect(sandbox.tools.allow).toEqual(expect.arrayContaining(["browser", "message", "tts"]));
|
||||
expect(sandbox.tools.deny).not.toContain("browser");
|
||||
|
||||
const runtime = resolveSandboxRuntimeStatus({
|
||||
cfg,
|
||||
sessionKey: "agent:tavern:main",
|
||||
});
|
||||
expect(runtime.toolPolicy.allow).toEqual(expect.arrayContaining(["browser", "message", "tts"]));
|
||||
expect(runtime.toolPolicy.deny).not.toContain("browser");
|
||||
});
|
||||
|
||||
it("keeps explicit sandbox deny precedence over allow and alsoAllow", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
allow: ["browser"],
|
||||
alsoAllow: ["message"],
|
||||
deny: ["browser", "message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveEffectiveSandboxToolPolicyForAgent(cfg, "main");
|
||||
expect(resolved.deny).toContain("browser");
|
||||
expect(resolved.deny).toContain("message");
|
||||
expect(
|
||||
isToolAllowedBySandboxToolPolicy("browser", {
|
||||
allow: resolved.allow,
|
||||
deny: resolved.deny,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isToolAllowedBySandboxToolPolicy("message", {
|
||||
allow: resolved.allow,
|
||||
deny: resolved.deny,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the effective sandbox policy when formatting blocked-tool guidance", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: { mode: "all", scope: "agent" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
sandbox: {
|
||||
tools: {
|
||||
alsoAllow: ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const browserMessage = formatEffectiveSandboxToolPolicyBlockedMessage({
|
||||
cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
toolName: "browser",
|
||||
});
|
||||
expect(browserMessage).toContain('Tool "browser" blocked by sandbox tool policy');
|
||||
expect(browserMessage).toContain("tools.sandbox.tools.deny");
|
||||
|
||||
const messageToolMessage = formatEffectiveSandboxToolPolicyBlockedMessage({
|
||||
cfg,
|
||||
sessionKey: "agent:main:main",
|
||||
toolName: "message",
|
||||
});
|
||||
expect(messageToolMessage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
358
src/agents/tool-policy-sandbox.ts
Normal file
358
src/agents/tool-policy-sandbox.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
canonicalizeMainSessionAlias,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveMainSessionKey,
|
||||
} from "../config/sessions/main-session.js";
|
||||
import { resolveAgentConfig, resolveSessionAgentId } from "./agent-scope.js";
|
||||
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
|
||||
import { DEFAULT_TOOL_ALLOW, DEFAULT_TOOL_DENY } from "./sandbox/constants.js";
|
||||
import type {
|
||||
SandboxToolPolicy,
|
||||
SandboxToolPolicyResolved,
|
||||
SandboxToolPolicySource,
|
||||
} from "./sandbox/types.js";
|
||||
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
|
||||
|
||||
type SandboxToolPolicyConfig = {
|
||||
allow?: string[];
|
||||
alsoAllow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
|
||||
function buildSource(params: {
|
||||
scope: "agent" | "global" | "default";
|
||||
key: string;
|
||||
}): SandboxToolPolicySource {
|
||||
return {
|
||||
source: params.scope,
|
||||
key: params.key,
|
||||
} satisfies SandboxToolPolicySource;
|
||||
}
|
||||
|
||||
function pickConfiguredList(params: { agent?: string[]; global?: string[] }): {
|
||||
values?: string[];
|
||||
source: SandboxToolPolicySource;
|
||||
} {
|
||||
if (Array.isArray(params.agent)) {
|
||||
return {
|
||||
values: params.agent,
|
||||
source: buildSource({ scope: "agent", key: "agents.list[].tools.sandbox.tools.allow" }),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(params.global)) {
|
||||
return {
|
||||
values: params.global,
|
||||
source: buildSource({ scope: "global", key: "tools.sandbox.tools.allow" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
values: undefined,
|
||||
source: buildSource({ scope: "default", key: "tools.sandbox.tools.allow" }),
|
||||
};
|
||||
}
|
||||
|
||||
function pickConfiguredDeny(params: { agent?: string[]; global?: string[] }): {
|
||||
values?: string[];
|
||||
source: SandboxToolPolicySource;
|
||||
} {
|
||||
if (Array.isArray(params.agent)) {
|
||||
return {
|
||||
values: params.agent,
|
||||
source: buildSource({ scope: "agent", key: "agents.list[].tools.sandbox.tools.deny" }),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(params.global)) {
|
||||
return {
|
||||
values: params.global,
|
||||
source: buildSource({ scope: "global", key: "tools.sandbox.tools.deny" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
values: undefined,
|
||||
source: buildSource({ scope: "default", key: "tools.sandbox.tools.deny" }),
|
||||
};
|
||||
}
|
||||
|
||||
function pickConfiguredAlsoAllow(params: { agent?: string[]; global?: string[] }): {
|
||||
values?: string[];
|
||||
source?: SandboxToolPolicySource;
|
||||
} {
|
||||
if (Array.isArray(params.agent)) {
|
||||
return {
|
||||
values: params.agent,
|
||||
source: buildSource({
|
||||
scope: "agent",
|
||||
key: "agents.list[].tools.sandbox.tools.alsoAllow",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (Array.isArray(params.global)) {
|
||||
return {
|
||||
values: params.global,
|
||||
source: buildSource({ scope: "global", key: "tools.sandbox.tools.alsoAllow" }),
|
||||
};
|
||||
}
|
||||
return { values: undefined, source: undefined };
|
||||
}
|
||||
|
||||
function mergeAllowlist(base: string[] | undefined, extra: string[] | undefined): string[] {
|
||||
if (Array.isArray(base)) {
|
||||
// Preserve the existing sandbox meaning of `allow: []` => allow all.
|
||||
if (base.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(extra) || extra.length === 0) {
|
||||
return [...base];
|
||||
}
|
||||
return Array.from(new Set([...base, ...extra]));
|
||||
}
|
||||
if (Array.isArray(extra) && extra.length > 0) {
|
||||
return Array.from(new Set([...DEFAULT_TOOL_ALLOW, ...extra]));
|
||||
}
|
||||
return [...DEFAULT_TOOL_ALLOW];
|
||||
}
|
||||
|
||||
function pickAllowSource(params: {
|
||||
allow: SandboxToolPolicySource;
|
||||
allowDefined: boolean;
|
||||
alsoAllow?: SandboxToolPolicySource;
|
||||
}): SandboxToolPolicySource {
|
||||
if (params.allowDefined && params.allow.source === "agent") {
|
||||
return params.allow;
|
||||
}
|
||||
if (params.alsoAllow?.source === "agent") {
|
||||
return params.alsoAllow;
|
||||
}
|
||||
if (params.allowDefined && params.allow.source === "global") {
|
||||
return params.allow;
|
||||
}
|
||||
if (params.alsoAllow?.source === "global") {
|
||||
return params.alsoAllow;
|
||||
}
|
||||
return params.allow;
|
||||
}
|
||||
|
||||
function resolveExplicitSandboxReAllowPatterns(params: {
|
||||
allow?: string[];
|
||||
alsoAllow?: string[];
|
||||
}): string[] {
|
||||
return Array.from(new Set([...(params.allow ?? []), ...(params.alsoAllow ?? [])]));
|
||||
}
|
||||
|
||||
function filterDefaultDenyForExplicitAllows(params: {
|
||||
deny: string[];
|
||||
explicitAllowPatterns: string[];
|
||||
}): string[] {
|
||||
if (params.explicitAllowPatterns.length === 0) {
|
||||
return [...params.deny];
|
||||
}
|
||||
const allowPatterns = compileGlobPatterns({
|
||||
raw: expandToolGroups(params.explicitAllowPatterns),
|
||||
normalize: normalizeToolName,
|
||||
});
|
||||
if (allowPatterns.length === 0) {
|
||||
return [...params.deny];
|
||||
}
|
||||
return params.deny.filter(
|
||||
(toolName) => !matchesAnyGlobPattern(normalizeToolName(toolName), allowPatterns),
|
||||
);
|
||||
}
|
||||
|
||||
function expandResolvedPolicy(policy: SandboxToolPolicy): SandboxToolPolicy {
|
||||
const expandedDeny = expandToolGroups(policy.deny ?? []);
|
||||
let expandedAllow = expandToolGroups(policy.allow ?? []);
|
||||
|
||||
// `image` is essential for multimodal workflows; keep the existing sandbox
|
||||
// behavior that auto-includes it for explicit allowlists unless it is denied.
|
||||
if (
|
||||
expandedAllow.length > 0 &&
|
||||
!expandedDeny.map((value) => value.toLowerCase()).includes("image") &&
|
||||
!expandedAllow.map((value) => value.toLowerCase()).includes("image")
|
||||
) {
|
||||
expandedAllow = [...expandedAllow, "image"];
|
||||
}
|
||||
|
||||
return {
|
||||
allow: expandedAllow,
|
||||
deny: expandedDeny,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveEffectiveSandboxToolPolicyForAgent(
|
||||
cfg?: OpenClawConfig,
|
||||
agentId?: string,
|
||||
): SandboxToolPolicyResolved {
|
||||
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
|
||||
const agentPolicy = agentConfig?.tools?.sandbox?.tools as SandboxToolPolicyConfig | undefined;
|
||||
const globalPolicy = cfg?.tools?.sandbox?.tools as SandboxToolPolicyConfig | undefined;
|
||||
|
||||
const allowConfig = pickConfiguredList({
|
||||
agent: agentPolicy?.allow,
|
||||
global: globalPolicy?.allow,
|
||||
});
|
||||
const alsoAllowConfig = pickConfiguredAlsoAllow({
|
||||
agent: agentPolicy?.alsoAllow,
|
||||
global: globalPolicy?.alsoAllow,
|
||||
});
|
||||
const denyConfig = pickConfiguredDeny({
|
||||
agent: agentPolicy?.deny,
|
||||
global: globalPolicy?.deny,
|
||||
});
|
||||
|
||||
const explicitAllowPatterns = resolveExplicitSandboxReAllowPatterns({
|
||||
allow: allowConfig.values,
|
||||
alsoAllow: alsoAllowConfig.values,
|
||||
});
|
||||
|
||||
const resolvedAllow = mergeAllowlist(allowConfig.values, alsoAllowConfig.values);
|
||||
const resolvedDeny = Array.isArray(denyConfig.values)
|
||||
? [...denyConfig.values]
|
||||
: filterDefaultDenyForExplicitAllows({
|
||||
deny: [...DEFAULT_TOOL_DENY],
|
||||
explicitAllowPatterns,
|
||||
});
|
||||
|
||||
const expanded = expandResolvedPolicy({
|
||||
allow: resolvedAllow,
|
||||
deny: resolvedDeny,
|
||||
});
|
||||
|
||||
return {
|
||||
allow: expanded.allow ?? [],
|
||||
deny: expanded.deny ?? [],
|
||||
sources: {
|
||||
allow: pickAllowSource({
|
||||
allow: allowConfig.source,
|
||||
allowDefined: Array.isArray(allowConfig.values),
|
||||
alsoAllow: alsoAllowConfig.source,
|
||||
}),
|
||||
deny: denyConfig.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function classifyToolAgainstSandboxToolPolicy(name: string, policy?: SandboxToolPolicy) {
|
||||
if (!policy) {
|
||||
return {
|
||||
blockedByDeny: false,
|
||||
blockedByAllow: false,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizeToolName(name);
|
||||
const deny = compileGlobPatterns({
|
||||
raw: expandToolGroups(policy.deny ?? []),
|
||||
normalize: normalizeToolName,
|
||||
});
|
||||
const blockedByDeny = matchesAnyGlobPattern(normalized, deny);
|
||||
const allow = compileGlobPatterns({
|
||||
raw: expandToolGroups(policy.allow ?? []),
|
||||
normalize: normalizeToolName,
|
||||
});
|
||||
const blockedByAllow =
|
||||
!blockedByDeny && allow.length > 0 && !matchesAnyGlobPattern(normalized, allow);
|
||||
return {
|
||||
blockedByDeny,
|
||||
blockedByAllow,
|
||||
};
|
||||
}
|
||||
|
||||
export function isToolAllowedBySandboxToolPolicy(
|
||||
name: string,
|
||||
policy?: SandboxToolPolicy,
|
||||
): boolean {
|
||||
const { blockedByDeny, blockedByAllow } = classifyToolAgainstSandboxToolPolicy(name, policy);
|
||||
return !blockedByDeny && !blockedByAllow;
|
||||
}
|
||||
|
||||
function resolveSandboxModeForAgent(
|
||||
cfg?: OpenClawConfig,
|
||||
agentId?: string,
|
||||
): "off" | "non-main" | "all" {
|
||||
const defaults = cfg?.agents?.defaults?.sandbox;
|
||||
const agentConfig = cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
|
||||
return agentConfig?.sandbox?.mode ?? defaults?.mode ?? "off";
|
||||
}
|
||||
|
||||
function shouldSandboxSession(
|
||||
mode: "off" | "non-main" | "all",
|
||||
sessionKey: string,
|
||||
mainKey: string,
|
||||
) {
|
||||
if (mode === "off") {
|
||||
return false;
|
||||
}
|
||||
if (mode === "all") {
|
||||
return true;
|
||||
}
|
||||
return sessionKey.trim() !== mainKey.trim();
|
||||
}
|
||||
|
||||
export function formatEffectiveSandboxToolPolicyBlockedMessage(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
toolName: string;
|
||||
}): string | undefined {
|
||||
const tool = params.toolName.trim().toLowerCase();
|
||||
if (!tool) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionKey = params.sessionKey?.trim() ?? "";
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey,
|
||||
config: params.cfg,
|
||||
});
|
||||
const mainSessionKey =
|
||||
params.cfg?.session?.scope === "global"
|
||||
? resolveMainSessionKey(params.cfg)
|
||||
: resolveAgentMainSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
});
|
||||
const comparableSessionKey = canonicalizeMainSessionAlias({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
});
|
||||
const sandboxMode = resolveSandboxModeForAgent(params.cfg, agentId);
|
||||
const sandboxed = sessionKey
|
||||
? shouldSandboxSession(sandboxMode, comparableSessionKey, mainSessionKey)
|
||||
: false;
|
||||
if (!sandboxed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const policy = resolveEffectiveSandboxToolPolicyForAgent(params.cfg, agentId);
|
||||
const { blockedByDeny, blockedByAllow } = classifyToolAgainstSandboxToolPolicy(tool, policy);
|
||||
if (!blockedByDeny && !blockedByAllow) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
const fixes: string[] = [];
|
||||
if (blockedByDeny) {
|
||||
reasons.push("deny list");
|
||||
fixes.push(`Remove "${tool}" from ${policy.sources.deny.key}.`);
|
||||
}
|
||||
if (blockedByAllow) {
|
||||
reasons.push("allow list");
|
||||
fixes.push(`Add "${tool}" to ${policy.sources.allow.key} (or set it to [] to allow all).`);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Tool "${tool}" blocked by sandbox tool policy (mode=${sandboxMode}).`,
|
||||
`Session: ${sessionKey || "(unknown)"}`,
|
||||
`Reason: ${reasons.join(" + ")}`,
|
||||
"Fix:",
|
||||
"- agents.defaults.sandbox.mode=off (disable sandbox)",
|
||||
...fixes.map((fix) => `- ${fix}`),
|
||||
];
|
||||
if (sandboxMode === "non-main") {
|
||||
lines.push(`- Use main session key (direct): ${mainSessionKey}`);
|
||||
}
|
||||
lines.push(`- See: ${formatCliCommand(`openclaw sandbox explain --session ${sessionKey}`)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -342,7 +342,7 @@ type ImageGenerateSandboxConfig = {
|
||||
async function loadReferenceImages(params: {
|
||||
imageInputs: string[];
|
||||
maxBytes?: number;
|
||||
localRoots: string[];
|
||||
workspaceDir?: string;
|
||||
sandboxConfig: { root: string; bridge: SandboxFsBridge; workspaceOnly: boolean } | null;
|
||||
}): Promise<
|
||||
Array<{
|
||||
@@ -402,6 +402,14 @@ async function loadReferenceImages(params: {
|
||||
};
|
||||
const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved;
|
||||
|
||||
const localRoots = resolveMediaToolLocalRoots(
|
||||
params.workspaceDir,
|
||||
{
|
||||
workspaceOnly: params.sandboxConfig?.workspaceOnly === true,
|
||||
},
|
||||
resolvedPath ? [resolvedPath] : undefined,
|
||||
);
|
||||
|
||||
const media = isDataUrl
|
||||
? decodeDataUrl(resolvedImage)
|
||||
: params.sandboxConfig
|
||||
@@ -412,7 +420,7 @@ async function loadReferenceImages(params: {
|
||||
})
|
||||
: await loadWebMedia(resolvedPath ?? resolvedImage, {
|
||||
maxBytes: params.maxBytes,
|
||||
localRoots: params.localRoots,
|
||||
localRoots,
|
||||
});
|
||||
if (media.kind !== "image") {
|
||||
throw new ToolInputError(`Unsupported media type: ${media.kind}`);
|
||||
@@ -471,9 +479,6 @@ export function createImageGenerateTool(options?: {
|
||||
}
|
||||
const effectiveCfg =
|
||||
applyImageGenerationModelConfigDefaults(cfg, imageGenerationModelConfig) ?? cfg;
|
||||
const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, {
|
||||
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
|
||||
});
|
||||
const sandboxConfig =
|
||||
options?.sandbox && options.sandbox.root.trim()
|
||||
? {
|
||||
@@ -549,7 +554,7 @@ export function createImageGenerateTool(options?: {
|
||||
const count = resolveRequestedCount(params);
|
||||
const loadedReferenceImages = await loadReferenceImages({
|
||||
imageInputs,
|
||||
localRoots,
|
||||
workspaceDir: options?.workspaceDir,
|
||||
sandboxConfig,
|
||||
});
|
||||
const inputImages = loadedReferenceImages.map((entry) => entry.sourceImage);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ vi.mock("../bash-tools.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../channel-tools.js", () => ({
|
||||
copyChannelAgentToolMeta: vi.fn((_from, to) => to),
|
||||
listChannelAgentTools: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
@@ -744,25 +745,20 @@ describe("image tool implicit imageModel config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows workspace images outside default local media roots", async () => {
|
||||
it("allows local image paths outside default media roots when workspaceOnly is off", async () => {
|
||||
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
|
||||
const fetch = stubMinimaxOkFetch();
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
const cfg = createMinimaxImageConfig();
|
||||
|
||||
const withoutWorkspace = createRequiredImageTool({ config: cfg, agentDir });
|
||||
await expect(
|
||||
withoutWorkspace.execute("t0", {
|
||||
prompt: "Describe the image.",
|
||||
image: imagePath,
|
||||
}),
|
||||
).rejects.toThrow(/Local media path is not under an allowed directory/i);
|
||||
await expectImageToolExecOk(withoutWorkspace, imagePath);
|
||||
|
||||
const withWorkspace = createRequiredImageTool({ config: cfg, agentDir, workspaceDir });
|
||||
|
||||
await expectImageToolExecOk(withWorkspace, imagePath);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -799,6 +795,28 @@ describe("image tool implicit imageModel config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows non-workspace local image paths when workspaceOnly is disabled", async () => {
|
||||
const fetch = stubMinimaxOkFetch();
|
||||
await withTempAgentDir(async (agentDir) => {
|
||||
const cfg = createMinimaxImageConfig();
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-outside-"));
|
||||
const outsideImage = path.join(outsideDir, "secret.png");
|
||||
await fs.writeFile(outsideImage, Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
|
||||
try {
|
||||
const tool = createRequiredImageTool({
|
||||
config: cfg,
|
||||
agentDir,
|
||||
fsPolicy: { workspaceOnly: false },
|
||||
});
|
||||
|
||||
await expectImageToolExecOk(tool, outsideImage);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("allows workspace images via createOpenClawCodingTools when workspace root is explicit", async () => {
|
||||
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
|
||||
const fetch = stubMinimaxOkFetch();
|
||||
|
||||
@@ -271,10 +271,6 @@ export function createImageTool(options?: {
|
||||
? "Analyze one or more images with a vision model. Use image for a single path/URL, or images for multiple (up to 20). Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you."
|
||||
: "Analyze one or more images with the configured image model (agents.defaults.imageModel). Use image for a single path/URL, or images for multiple (up to 20). Provide a prompt describing what to analyze.";
|
||||
|
||||
const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, {
|
||||
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
|
||||
});
|
||||
|
||||
return {
|
||||
label: "Image",
|
||||
name: "image",
|
||||
@@ -421,6 +417,13 @@ export function createImageTool(options?: {
|
||||
: resolvedImage,
|
||||
};
|
||||
const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved;
|
||||
const mediaLocalRoots = resolveMediaToolLocalRoots(
|
||||
options?.workspaceDir,
|
||||
{
|
||||
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
|
||||
},
|
||||
resolvedPath ? [resolvedPath] : undefined,
|
||||
);
|
||||
|
||||
const media = isDataUrl
|
||||
? decodeDataUrl(resolvedImage)
|
||||
@@ -432,7 +435,7 @@ export function createImageTool(options?: {
|
||||
})
|
||||
: await loadWebMedia(resolvedPath ?? resolvedImage, {
|
||||
maxBytes,
|
||||
localRoots,
|
||||
localRoots: mediaLocalRoots,
|
||||
});
|
||||
if (media.kind !== "image") {
|
||||
throw new Error(`Unsupported media type: ${media.kind}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Api, type Model } from "@mariozechner/pi-ai";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { appendLocalMediaParentRoots } from "../../media/local-roots.js";
|
||||
import { getDefaultLocalRoots } from "../../media/web-media.js";
|
||||
import type { ImageModelConfig } from "./image-tool.helpers.js";
|
||||
import type { ToolModelConfig } from "./model-config.helpers.js";
|
||||
@@ -55,16 +56,15 @@ function applyAgentDefaultModelConfig(
|
||||
export function resolveMediaToolLocalRoots(
|
||||
workspaceDirRaw: string | undefined,
|
||||
options?: { workspaceOnly?: boolean },
|
||||
mediaSources?: readonly string[],
|
||||
): string[] {
|
||||
const workspaceDir = normalizeWorkspaceDir(workspaceDirRaw);
|
||||
if (options?.workspaceOnly) {
|
||||
return workspaceDir ? [workspaceDir] : [];
|
||||
}
|
||||
const roots = getDefaultLocalRoots();
|
||||
if (!workspaceDir) {
|
||||
return [...roots];
|
||||
}
|
||||
return Array.from(new Set([...roots, workspaceDir]));
|
||||
const scopedRoots = workspaceDir ? Array.from(new Set([...roots, workspaceDir])) : [...roots];
|
||||
return appendLocalMediaParentRoots(scopedRoots, mediaSources);
|
||||
}
|
||||
|
||||
export function resolvePromptAndModelOverride(
|
||||
|
||||
@@ -327,10 +327,6 @@ export function createPdfTool(options?: {
|
||||
? Math.floor(maxPagesDefault)
|
||||
: DEFAULT_MAX_PAGES;
|
||||
|
||||
const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, {
|
||||
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
|
||||
});
|
||||
|
||||
const description =
|
||||
"Analyze one or more PDF documents with a model. Supports native PDF analysis for Anthropic and Google models, with text/image extraction fallback for other providers. Use pdf for a single path/URL, or pdfs for multiple (up to 10). Provide a prompt describing what to analyze.";
|
||||
|
||||
@@ -471,6 +467,13 @@ export function createPdfTool(options?: {
|
||||
? resolvedPdf.slice("file://".length)
|
||||
: resolvedPdf,
|
||||
};
|
||||
const localRoots = resolveMediaToolLocalRoots(
|
||||
options?.workspaceDir,
|
||||
{
|
||||
workspaceOnly: options?.fsPolicy?.workspaceOnly === true,
|
||||
},
|
||||
[resolvedPathInfo.resolved],
|
||||
);
|
||||
|
||||
const media = sandboxConfig
|
||||
? await loadWebMediaRaw(resolvedPathInfo.resolved, {
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("directive behavior", () => {
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -124,8 +124,7 @@ describe("directive behavior", () => {
|
||||
workspace: path.join(home, "openclaw"),
|
||||
models: {
|
||||
"minimax/MiniMax-M2.7": {},
|
||||
"minimax/MiniMax-M2.5": {},
|
||||
"minimax/MiniMax-M2.5-highspeed": {},
|
||||
"minimax/MiniMax-M2.7-highspeed": {},
|
||||
"lmstudio/minimax-m2.5-gs32": {},
|
||||
},
|
||||
},
|
||||
@@ -139,7 +138,7 @@ describe("directive behavior", () => {
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"),
|
||||
makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"),
|
||||
makeModelDefinition("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed"),
|
||||
],
|
||||
},
|
||||
lmstudio: {
|
||||
@@ -153,11 +152,11 @@ describe("directive behavior", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
body: "/model minimax/m2.5",
|
||||
body: "/model minimax/highspeed",
|
||||
storePath: path.join(home, "sessions-provider-fuzzy.json"),
|
||||
expectedSelection: {
|
||||
provider: "minimax",
|
||||
model: "MiniMax-M2.5",
|
||||
model: "MiniMax-M2.7-highspeed",
|
||||
},
|
||||
config: {
|
||||
agents: {
|
||||
@@ -166,8 +165,7 @@ describe("directive behavior", () => {
|
||||
workspace: path.join(home, "openclaw"),
|
||||
models: {
|
||||
"minimax/MiniMax-M2.7": {},
|
||||
"minimax/MiniMax-M2.5": {},
|
||||
"minimax/MiniMax-M2.5-highspeed": {},
|
||||
"minimax/MiniMax-M2.7-highspeed": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -180,8 +178,7 @@ describe("directive behavior", () => {
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"),
|
||||
makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"),
|
||||
makeModelDefinition("MiniMax-M2.5-highspeed", "MiniMax M2.5 Highspeed"),
|
||||
makeModelDefinition("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed"),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -54,4 +54,43 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps host-local media paths flexible when sandbox exists and workspaceOnly is off", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: "/Users/peter/.openclaw/media/inbound/photo.png",
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sandbox media strict when workspaceOnly is enabled", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: { tools: { fs: { workspaceOnly: true } } },
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
normalize({
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
}),
|
||||
).rejects.toThrow(/sandbox root|outside|escapes/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolvePathFromInput } from "../../agents/path-policy.js";
|
||||
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||
import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
@@ -34,6 +36,13 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
sessionKey?: string;
|
||||
workspaceDir: string;
|
||||
}): (payload: ReplyPayload) => Promise<ReplyPayload> {
|
||||
const agentId = params.sessionKey
|
||||
? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg })
|
||||
: undefined;
|
||||
const workspaceOnly = resolveEffectiveToolFsWorkspaceOnly({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
});
|
||||
let sandboxRootPromise: Promise<string | undefined> | undefined;
|
||||
|
||||
const resolveSandboxRoot = async (): Promise<string | undefined> => {
|
||||
@@ -58,10 +67,20 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
}
|
||||
const sandboxRoot = await resolveSandboxRoot();
|
||||
if (sandboxRoot) {
|
||||
return await resolveSandboxedMediaSource({
|
||||
media,
|
||||
sandboxRoot,
|
||||
});
|
||||
try {
|
||||
return await resolveSandboxedMediaSource({
|
||||
media,
|
||||
sandboxRoot,
|
||||
});
|
||||
} catch (err) {
|
||||
if (workspaceOnly || !isLikelyLocalMediaSource(media)) {
|
||||
throw err;
|
||||
}
|
||||
if (FILE_URL_RE.test(media)) {
|
||||
return media;
|
||||
}
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
}
|
||||
}
|
||||
if (!isLikelyLocalMediaSource(media)) {
|
||||
return media;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ const formatPortDiagnostics = vi.fn();
|
||||
const pathExists = vi.fn();
|
||||
const syncPluginsForUpdateChannel = vi.fn();
|
||||
const updateNpmInstalledPlugins = vi.fn();
|
||||
const nodeVersionSatisfiesEngine = vi.fn();
|
||||
const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture();
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
@@ -57,11 +58,20 @@ vi.mock("../infra/update-check.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
checkUpdateStatus: vi.fn(),
|
||||
fetchNpmPackageTargetStatus: vi.fn(),
|
||||
fetchNpmTagVersion: vi.fn(),
|
||||
resolveNpmChannelTag: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/runtime-guard.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/runtime-guard.js")>();
|
||||
return {
|
||||
...actual,
|
||||
nodeVersionSatisfiesEngine,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
@@ -140,7 +150,7 @@ vi.mock("../runtime.js", () => ({
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
|
||||
const { readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js");
|
||||
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||
const { checkUpdateStatus, fetchNpmPackageTargetStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||
await import("../infra/update-check.js");
|
||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
||||
const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js");
|
||||
@@ -298,10 +308,16 @@ describe("update-cli", () => {
|
||||
tag: "latest",
|
||||
version: "9999.0.0",
|
||||
});
|
||||
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
|
||||
target: "latest",
|
||||
version: "9999.0.0",
|
||||
nodeEngine: ">=22.14.0",
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "9999.0.0",
|
||||
});
|
||||
nodeVersionSatisfiesEngine.mockReturnValue(true);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: "/test/path",
|
||||
installKind: "git",
|
||||
@@ -567,6 +583,30 @@ describe("update-cli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks package updates when the target requires a newer Node runtime", async () => {
|
||||
mockPackageInstallStatus(createCaseDir("openclaw-update"));
|
||||
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
|
||||
target: "latest",
|
||||
version: "2026.3.23-2",
|
||||
nodeEngine: ">=22.14.0",
|
||||
});
|
||||
nodeVersionSatisfiesEngine.mockReturnValue(false);
|
||||
|
||||
await updateCommand({ yes: true });
|
||||
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(runCommandWithTimeout).not.toHaveBeenCalledWith(
|
||||
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
|
||||
expect(errors.join("\n")).toContain("Node ");
|
||||
expect(errors.join("\n")).toContain(
|
||||
"Bare `npm i -g openclaw` can silently install an older compatible release.",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves package install specs from tags and env overrides", async () => {
|
||||
for (const scenario of [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../config/config.js";
|
||||
import { formatConfigIssueLines } from "../../config/issue-format.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEFAULT_GIT_CHANNEL,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "../../infra/update-channels.js";
|
||||
import {
|
||||
compareSemverStrings,
|
||||
fetchNpmPackageTargetStatus,
|
||||
resolveNpmChannelTag,
|
||||
checkUpdateStatus,
|
||||
} from "../../infra/update-check.js";
|
||||
@@ -133,6 +135,38 @@ function tryResolveInvocationCwd(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePackageRuntimePreflightError(params: {
|
||||
tag: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<string | null> {
|
||||
if (!canResolveRegistryVersionForPackageTarget(params.tag)) {
|
||||
return null;
|
||||
}
|
||||
const target = params.tag.trim();
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const status = await fetchNpmPackageTargetStatus({
|
||||
target,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (status.error) {
|
||||
return null;
|
||||
}
|
||||
const satisfies = nodeVersionSatisfiesEngine(process.versions.node ?? null, status.nodeEngine);
|
||||
if (satisfies !== false) {
|
||||
return null;
|
||||
}
|
||||
const targetLabel = status.version ?? target;
|
||||
return [
|
||||
`Node ${process.versions.node ?? "unknown"} is too old for openclaw@${targetLabel}.`,
|
||||
`The requested package requires ${status.nodeEngine}.`,
|
||||
"Upgrade Node to 22.14+ or Node 24, then rerun `openclaw update`.",
|
||||
"Bare `npm i -g openclaw` can silently install an older compatible release.",
|
||||
"After upgrading Node, use `npm i -g openclaw@latest`.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function resolveServiceRefreshEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
invocationCwd?: string,
|
||||
@@ -881,6 +915,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (updateInstallKind === "package") {
|
||||
const runtimePreflightError = await resolvePackageRuntimePreflightError({
|
||||
tag,
|
||||
timeoutMs,
|
||||
});
|
||||
if (runtimePreflightError) {
|
||||
defaultRuntime.error(runtimePreflightError);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const showProgress = !opts.json && process.stdout.isTTY;
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.heading("Updating OpenClaw..."));
|
||||
|
||||
@@ -258,7 +258,7 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
note(warning, "Gateway runtime");
|
||||
}
|
||||
note(
|
||||
"System Node 22 LTS (22.16+) or Node 24 not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
|
||||
"System Node 22 LTS (22.14+) or Node 24 not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
|
||||
"Gateway runtime",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.1",
|
||||
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[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -154,7 +154,7 @@ async function resolveBinaryPath(binary: string): Promise<string> {
|
||||
throw new Error("Bun not found in PATH. Install bun: https://bun.sh");
|
||||
}
|
||||
throw new Error(
|
||||
"Node not found in PATH. Install Node 24 (recommended) or Node 22 LTS (22.16+).",
|
||||
"Node not found in PATH. Install Node 24 (recommended) or Node 22 LTS (22.14+).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("resolvePreferredNodePath", () => {
|
||||
const execFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }) // execPath too old
|
||||
.mockResolvedValueOnce({ stdout: "22.16.0\n", stderr: "" }); // system node ok
|
||||
.mockResolvedValueOnce({ stdout: "22.14.0\n", stderr: "" }); // system node ok
|
||||
|
||||
const result = await resolvePreferredNodePath({
|
||||
env: {},
|
||||
@@ -73,7 +73,7 @@ describe("resolvePreferredNodePath", () => {
|
||||
it("ignores execPath when it is not node", async () => {
|
||||
mockNodePathPresent(darwinNode);
|
||||
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.16.0\n", stderr: "" });
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.14.0\n", stderr: "" });
|
||||
|
||||
const result = await resolvePreferredNodePath({
|
||||
env: {},
|
||||
@@ -93,8 +93,8 @@ describe("resolvePreferredNodePath", () => {
|
||||
it("uses system node when it meets the minimum version", async () => {
|
||||
mockNodePathPresent(darwinNode);
|
||||
|
||||
// Node 22.16.0+ is the minimum required version
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.16.0\n", stderr: "" });
|
||||
// Node 22.14.0+ is the minimum required version
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.14.0\n", stderr: "" });
|
||||
|
||||
const result = await resolvePreferredNodePath({
|
||||
env: {},
|
||||
@@ -111,8 +111,8 @@ describe("resolvePreferredNodePath", () => {
|
||||
it("skips system node when it is too old", async () => {
|
||||
mockNodePathPresent(darwinNode);
|
||||
|
||||
// Node 22.15.x is below minimum 22.16.0
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.15.0\n", stderr: "" });
|
||||
// Node 22.13.x is below minimum 22.14.0
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.13.0\n", stderr: "" });
|
||||
|
||||
const result = await resolvePreferredNodePath({
|
||||
env: {},
|
||||
@@ -168,7 +168,7 @@ describe("resolveStableNodePath", () => {
|
||||
it("resolves versioned node@22 formula to opt symlink", async () => {
|
||||
mockNodePathPresent("/opt/homebrew/opt/node@22/bin/node");
|
||||
|
||||
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node@22/22.16.0/bin/node");
|
||||
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node@22/22.14.0/bin/node");
|
||||
expect(result).toBe("/opt/homebrew/opt/node@22/bin/node");
|
||||
});
|
||||
|
||||
@@ -218,8 +218,8 @@ describe("resolveSystemNodeInfo", () => {
|
||||
it("returns supported info when version is new enough", async () => {
|
||||
mockNodePathPresent(darwinNode);
|
||||
|
||||
// Node 22.16.0+ is the minimum required version
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.16.0\n", stderr: "" });
|
||||
// Node 22.14.0+ is the minimum required version
|
||||
const execFile = vi.fn().mockResolvedValue({ stdout: "22.14.0\n", stderr: "" });
|
||||
|
||||
const result = await resolveSystemNodeInfo({
|
||||
env: {},
|
||||
@@ -229,7 +229,7 @@ describe("resolveSystemNodeInfo", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
path: darwinNode,
|
||||
version: "22.16.0",
|
||||
version: "22.14.0",
|
||||
supported: true,
|
||||
});
|
||||
});
|
||||
@@ -251,7 +251,7 @@ describe("resolveSystemNodeInfo", () => {
|
||||
"/Users/me/.fnm/node-22/bin/node",
|
||||
);
|
||||
|
||||
expect(warning).toContain("below the required Node 22.16+");
|
||||
expect(warning).toContain("below the required Node 22.14+");
|
||||
expect(warning).toContain(darwinNode);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ export function renderSystemNodeWarning(
|
||||
}
|
||||
const versionLabel = systemNode.version ?? "unknown";
|
||||
const selectedLabel = selectedNodePath ? ` Using ${selectedNodePath} for the daemon.` : "";
|
||||
return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22.16+.${selectedLabel} Install Node 24 (recommended) or Node 22 LTS from nodejs.org or Homebrew.`;
|
||||
return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22.14+.${selectedLabel} Install Node 24 (recommended) or Node 22 LTS from nodejs.org or Homebrew.`;
|
||||
}
|
||||
export { resolveStableNodePath };
|
||||
|
||||
|
||||
@@ -362,7 +362,7 @@ async function auditGatewayRuntime(
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
|
||||
message:
|
||||
"System Node 22 LTS (22.16+) or Node 24 not found; install it before migrating away from version managers.",
|
||||
"System Node 22 LTS (22.14+) or Node 24 not found; install it before migrating away from version managers.",
|
||||
level: "recommended",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "../../hooks/message-hook-mappers.js";
|
||||
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import { getAgentScopedMediaLocalRootsForSources } from "../../media/local-roots.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { throwIfAborted } from "./abort.js";
|
||||
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
|
||||
@@ -277,6 +277,14 @@ type DeliverOutboundPayloadsCoreParams = {
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
function collectPayloadMediaSources(payloads: ReplyPayload[]): string[] {
|
||||
const mediaSources: string[] = [];
|
||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||
mediaSources.push(...resolveSendableOutboundReplyParts(payload).mediaUrls);
|
||||
}
|
||||
return mediaSources;
|
||||
}
|
||||
|
||||
export type DeliverOutboundPayloadsParams = DeliverOutboundPayloadsCoreParams & {
|
||||
/** @internal Skip write-ahead queue (used by crash-recovery to avoid re-enqueueing). */
|
||||
skipQueue?: boolean;
|
||||
@@ -549,10 +557,11 @@ async function deliverOutboundPayloadsCore(
|
||||
const accountId = params.accountId;
|
||||
const deps = params.deps;
|
||||
const abortSignal = params.abortSignal;
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRootsForSources({
|
||||
cfg,
|
||||
params.session?.agentId ?? params.mirror?.agentId,
|
||||
);
|
||||
agentId: params.session?.agentId ?? params.mirror?.agentId,
|
||||
mediaSources: collectPayloadMediaSources(payloads),
|
||||
});
|
||||
const results: OutboundDeliveryResult[] = [];
|
||||
const handler = await createChannelHandler({
|
||||
cfg,
|
||||
|
||||
@@ -199,6 +199,7 @@ describe("runMessageAction media behavior", () => {
|
||||
}
|
||||
|
||||
async function expectRejectsLocalAbsolutePathWithoutSandbox(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
action: "sendAttachment" | "setGroupIcon";
|
||||
target: string;
|
||||
mediaField?: "media" | "mediaUrl" | "fileUrl";
|
||||
@@ -223,7 +224,7 @@ describe("runMessageAction media behavior", () => {
|
||||
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg,
|
||||
cfg: params.cfg ?? cfg,
|
||||
action: params.action,
|
||||
params: actionParams,
|
||||
}),
|
||||
@@ -353,7 +354,10 @@ describe("runMessageAction media behavior", () => {
|
||||
tempPrefix: "msg-group-icon-",
|
||||
},
|
||||
]) {
|
||||
await expectRejectsLocalAbsolutePathWithoutSandbox(testCase);
|
||||
await expectRejectsLocalAbsolutePathWithoutSandbox({
|
||||
...testCase,
|
||||
cfg: { tools: { fs: { workspaceOnly: true } } },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@ import type {
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRootsForSources,
|
||||
} from "../../media/local-roots.js";
|
||||
import { hasPollCreationParams } from "../../poll-params.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { buildChannelAccountBindings } from "../../routing/bindings.js";
|
||||
@@ -138,6 +141,17 @@ export function getToolResult(
|
||||
return "toolResult" in result ? result.toolResult : undefined;
|
||||
}
|
||||
|
||||
function collectActionMediaSourceHints(params: Record<string, unknown>): string[] {
|
||||
const sources: string[] = [];
|
||||
for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl"] as const) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
sources.push(value);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function applyCrossContextMessageDecoration({
|
||||
params,
|
||||
message,
|
||||
@@ -720,15 +734,24 @@ export async function runMessageAction(
|
||||
params.accountId = accountId;
|
||||
}
|
||||
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId);
|
||||
const mediaPolicy = resolveAttachmentMediaPolicy({
|
||||
const normalizationPolicy = resolveAttachmentMediaPolicy({
|
||||
sandboxRoot: input.sandboxRoot,
|
||||
mediaLocalRoots,
|
||||
mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, resolvedAgentId),
|
||||
});
|
||||
|
||||
await normalizeSandboxMediaParams({
|
||||
args: params,
|
||||
mediaPolicy,
|
||||
mediaPolicy: normalizationPolicy,
|
||||
});
|
||||
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRootsForSources({
|
||||
cfg,
|
||||
agentId: resolvedAgentId,
|
||||
mediaSources: collectActionMediaSourceHints(params),
|
||||
});
|
||||
const mediaPolicy = resolveAttachmentMediaPolicy({
|
||||
sandboxRoot: input.sandboxRoot,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
|
||||
await hydrateAttachmentParamsForAction({
|
||||
|
||||
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
dispatchChannelMessageAction: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
sendPoll: vi.fn(),
|
||||
getAgentScopedMediaLocalRoots: vi.fn(() => ["/tmp/agent-roots"]),
|
||||
getAgentScopedMediaLocalRootsForSources: vi.fn(() => ["/tmp/agent-roots"]),
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock("../../media/local-roots.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getDefaultMediaLocalRoots: mocks.getDefaultMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRoots: mocks.getAgentScopedMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRootsForSources: mocks.getAgentScopedMediaLocalRootsForSources,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("executeSendAction", () => {
|
||||
mocks.sendMessage.mockClear();
|
||||
mocks.sendPoll.mockClear();
|
||||
mocks.getDefaultMediaLocalRoots.mockClear();
|
||||
mocks.getAgentScopedMediaLocalRoots.mockClear();
|
||||
mocks.getAgentScopedMediaLocalRootsForSources.mockClear();
|
||||
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||
});
|
||||
|
||||
@@ -196,7 +196,11 @@ describe("executeSendAction", () => {
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(mocks.getAgentScopedMediaLocalRoots).toHaveBeenCalledWith({}, "agent-1");
|
||||
expect(mocks.getAgentScopedMediaLocalRootsForSources).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
agentId: "agent-1",
|
||||
mediaSources: [],
|
||||
});
|
||||
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: ["/tmp/agent-roots"],
|
||||
@@ -204,6 +208,33 @@ describe("executeSendAction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes concrete media sources when widening plugin dispatch roots", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin"));
|
||||
|
||||
await executeSendAction({
|
||||
ctx: {
|
||||
cfg: {},
|
||||
channel: "discord",
|
||||
params: {
|
||||
to: "channel:123",
|
||||
message: "hello",
|
||||
media: "/Users/peter/Pictures/photo.png",
|
||||
},
|
||||
agentId: "agent-1",
|
||||
dryRun: false,
|
||||
},
|
||||
to: "channel:123",
|
||||
message: "hello",
|
||||
mediaUrl: "/Users/peter/Pictures/photo.png",
|
||||
});
|
||||
|
||||
expect(mocks.getAgentScopedMediaLocalRootsForSources).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
agentId: "agent-1",
|
||||
mediaSources: ["/Users/peter/Pictures/photo.png"],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes mirror idempotency keys through plugin-handled sends", async () => {
|
||||
await executePluginMirroredSend({
|
||||
mirror: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dispatchChannelMessageAction } from "../../channels/plugins/message-act
|
||||
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
import { getAgentScopedMediaLocalRootsForSources } from "../../media/local-roots.js";
|
||||
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
|
||||
import { throwIfAborted } from "./abort.js";
|
||||
import type { OutboundSendDeps } from "./deliver.js";
|
||||
@@ -43,6 +43,17 @@ type PluginHandledResult = {
|
||||
toolResult: AgentToolResult<unknown>;
|
||||
};
|
||||
|
||||
function collectActionMediaSources(params: Record<string, unknown>): string[] {
|
||||
const sources: string[] = [];
|
||||
for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl"] as const) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
sources.push(value);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function tryHandleWithPluginAction(params: {
|
||||
ctx: OutboundSendContext;
|
||||
action: "send" | "poll";
|
||||
@@ -51,10 +62,11 @@ async function tryHandleWithPluginAction(params: {
|
||||
if (params.ctx.dryRun) {
|
||||
return null;
|
||||
}
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(
|
||||
params.ctx.cfg,
|
||||
params.ctx.agentId ?? params.ctx.mirror?.agentId,
|
||||
);
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRootsForSources({
|
||||
cfg: params.ctx.cfg,
|
||||
agentId: params.ctx.agentId ?? params.ctx.mirror?.agentId,
|
||||
mediaSources: collectActionMediaSources(params.ctx.params),
|
||||
});
|
||||
const handled = await dispatchChannelMessageAction({
|
||||
channel: params.ctx.channel,
|
||||
action: params.action,
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
assertSupportedRuntime,
|
||||
detectRuntime,
|
||||
isAtLeast,
|
||||
parseSemver,
|
||||
isSupportedNodeVersion,
|
||||
nodeVersionSatisfiesEngine,
|
||||
parseMinimumNodeEngine,
|
||||
parseSemver,
|
||||
type RuntimeDetails,
|
||||
runtimeSatisfies,
|
||||
} from "./runtime-guard.js";
|
||||
@@ -13,21 +15,21 @@ describe("runtime-guard", () => {
|
||||
it("parses semver with or without leading v", () => {
|
||||
expect(parseSemver("v22.1.3")).toEqual({ major: 22, minor: 1, patch: 3 });
|
||||
expect(parseSemver("1.3.0")).toEqual({ major: 1, minor: 3, patch: 0 });
|
||||
expect(parseSemver("22.16.0-beta.1")).toEqual({ major: 22, minor: 16, patch: 0 });
|
||||
expect(parseSemver("22.14.0-beta.1")).toEqual({ major: 22, minor: 14, patch: 0 });
|
||||
expect(parseSemver("invalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("compares versions correctly", () => {
|
||||
expect(isAtLeast({ major: 22, minor: 16, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
|
||||
expect(isAtLeast({ major: 22, minor: 14, patch: 0 }, { major: 22, minor: 14, patch: 0 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAtLeast({ major: 22, minor: 17, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
|
||||
expect(isAtLeast({ major: 22, minor: 15, patch: 0 }, { major: 22, minor: 14, patch: 0 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAtLeast({ major: 22, minor: 15, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
|
||||
expect(isAtLeast({ major: 22, minor: 13, patch: 0 }, { major: 22, minor: 14, patch: 0 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAtLeast({ major: 21, minor: 9, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
|
||||
expect(isAtLeast({ major: 21, minor: 9, patch: 0 }, { major: 22, minor: 14, patch: 0 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -35,11 +37,11 @@ describe("runtime-guard", () => {
|
||||
it("validates runtime thresholds", () => {
|
||||
const nodeOk: RuntimeDetails = {
|
||||
kind: "node",
|
||||
version: "22.16.0",
|
||||
version: "22.14.0",
|
||||
execPath: "/usr/bin/node",
|
||||
pathEnv: "/usr/bin",
|
||||
};
|
||||
const nodeOld: RuntimeDetails = { ...nodeOk, version: "22.15.0" };
|
||||
const nodeOld: RuntimeDetails = { ...nodeOk, version: "22.13.0" };
|
||||
const nodeTooOld: RuntimeDetails = { ...nodeOk, version: "21.9.0" };
|
||||
const unknown: RuntimeDetails = {
|
||||
kind: "unknown",
|
||||
@@ -51,11 +53,24 @@ describe("runtime-guard", () => {
|
||||
expect(runtimeSatisfies(nodeOld)).toBe(false);
|
||||
expect(runtimeSatisfies(nodeTooOld)).toBe(false);
|
||||
expect(runtimeSatisfies(unknown)).toBe(false);
|
||||
expect(isSupportedNodeVersion("22.16.0")).toBe(true);
|
||||
expect(isSupportedNodeVersion("22.15.9")).toBe(false);
|
||||
expect(isSupportedNodeVersion("22.14.0")).toBe(true);
|
||||
expect(isSupportedNodeVersion("22.13.9")).toBe(false);
|
||||
expect(isSupportedNodeVersion(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("parses simple minimum node engine ranges", () => {
|
||||
expect(parseMinimumNodeEngine(">=22.14.0")).toEqual({ major: 22, minor: 14, patch: 0 });
|
||||
expect(parseMinimumNodeEngine(" >=v24.0.0 ")).toEqual({ major: 24, minor: 0, patch: 0 });
|
||||
expect(parseMinimumNodeEngine("^22.14.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("checks node versions against simple engine ranges", () => {
|
||||
expect(nodeVersionSatisfiesEngine("22.14.0", ">=22.14.0")).toBe(true);
|
||||
expect(nodeVersionSatisfiesEngine("22.13.9", ">=22.14.0")).toBe(false);
|
||||
expect(nodeVersionSatisfiesEngine("24.0.0", ">=22.14.0")).toBe(true);
|
||||
expect(nodeVersionSatisfiesEngine("22.14.0", "^22.14.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws via exit when runtime is too old", () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
@@ -84,7 +99,7 @@ describe("runtime-guard", () => {
|
||||
const details: RuntimeDetails = {
|
||||
...detectRuntime(),
|
||||
kind: "node",
|
||||
version: "22.16.0",
|
||||
version: "22.14.0",
|
||||
execPath: "/usr/bin/node",
|
||||
};
|
||||
expect(() => assertSupportedRuntime(runtime, details)).not.toThrow();
|
||||
|
||||
@@ -9,7 +9,8 @@ type Semver = {
|
||||
patch: number;
|
||||
};
|
||||
|
||||
const MIN_NODE: Semver = { major: 22, minor: 16, patch: 0 };
|
||||
const MIN_NODE: Semver = { major: 22, minor: 14, patch: 0 };
|
||||
const MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i;
|
||||
|
||||
export type RuntimeDetails = {
|
||||
kind: RuntimeKind;
|
||||
@@ -73,6 +74,28 @@ export function isSupportedNodeVersion(version: string | null): boolean {
|
||||
return isAtLeast(parseSemver(version), MIN_NODE);
|
||||
}
|
||||
|
||||
export function parseMinimumNodeEngine(engine: string | null): Semver | null {
|
||||
if (!engine) {
|
||||
return null;
|
||||
}
|
||||
const match = engine.match(MINIMUM_ENGINE_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseSemver(match[1] ?? null);
|
||||
}
|
||||
|
||||
export function nodeVersionSatisfiesEngine(
|
||||
version: string | null,
|
||||
engine: string | null,
|
||||
): boolean | null {
|
||||
const minimum = parseMinimumNodeEngine(engine);
|
||||
if (!minimum) {
|
||||
return null;
|
||||
}
|
||||
return isAtLeast(parseSemver(version), minimum);
|
||||
}
|
||||
|
||||
export function assertSupportedRuntime(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
details: RuntimeDetails = detectRuntime(),
|
||||
@@ -88,7 +111,7 @@ export function assertSupportedRuntime(
|
||||
|
||||
runtime.error(
|
||||
[
|
||||
"openclaw requires Node >=22.16.0.",
|
||||
"openclaw requires Node >=22.14.0.",
|
||||
`Detected: ${runtimeLabel} (exec: ${execLabel}).`,
|
||||
`PATH searched: ${details.pathEnv}`,
|
||||
"Install Node: https://nodejs.org/en/download",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
checkUpdateStatus,
|
||||
compareSemverStrings,
|
||||
fetchNpmLatestVersion,
|
||||
fetchNpmPackageTargetStatus,
|
||||
fetchNpmTagVersion,
|
||||
formatGitInstallLabel,
|
||||
resolveNpmChannelTag,
|
||||
@@ -47,7 +48,10 @@ describe("resolveNpmChannelTag", () => {
|
||||
return {
|
||||
ok: version != null,
|
||||
status: version != null ? 200 : 404,
|
||||
json: async () => ({ version }),
|
||||
json: async () => ({
|
||||
version,
|
||||
engines: version != null ? { node: ">=22.14.0" } : undefined,
|
||||
}),
|
||||
} as Response;
|
||||
}),
|
||||
);
|
||||
@@ -96,6 +100,13 @@ describe("resolveNpmChannelTag", () => {
|
||||
it("exposes tag fetch helpers for success and http failures", async () => {
|
||||
versionByTag.latest = "1.0.4";
|
||||
|
||||
await expect(
|
||||
fetchNpmPackageTargetStatus({ target: "latest", timeoutMs: 1000 }),
|
||||
).resolves.toEqual({
|
||||
target: "latest",
|
||||
version: "1.0.4",
|
||||
nodeEngine: ">=22.14.0",
|
||||
});
|
||||
await expect(fetchNpmTagVersion({ tag: "latest", timeoutMs: 1000 })).resolves.toEqual({
|
||||
tag: "latest",
|
||||
version: "1.0.4",
|
||||
|
||||
@@ -40,6 +40,13 @@ export type NpmTagStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type NpmPackageTargetStatus = {
|
||||
target: string;
|
||||
version: string | null;
|
||||
nodeEngine: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateCheckResult = {
|
||||
root: string | null;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
@@ -295,29 +302,48 @@ export async function fetchNpmLatestVersion(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNpmTagVersion(params: {
|
||||
tag: string;
|
||||
export async function fetchNpmPackageTargetStatus(params: {
|
||||
target: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<NpmTagStatus> {
|
||||
const timeoutMs = params?.timeoutMs ?? 3500;
|
||||
const tag = params.tag;
|
||||
}): Promise<NpmPackageTargetStatus> {
|
||||
const timeoutMs = params.timeoutMs ?? 3500;
|
||||
const target = params.target;
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://registry.npmjs.org/openclaw/${encodeURIComponent(tag)}`,
|
||||
`https://registry.npmjs.org/openclaw/${encodeURIComponent(target)}`,
|
||||
{},
|
||||
Math.max(250, timeoutMs),
|
||||
);
|
||||
if (!res.ok) {
|
||||
return { tag, version: null, error: `HTTP ${res.status}` };
|
||||
return { target, version: null, nodeEngine: null, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const json = (await res.json()) as { version?: unknown };
|
||||
const json = (await res.json()) as {
|
||||
version?: unknown;
|
||||
engines?: { node?: unknown };
|
||||
};
|
||||
const version = typeof json?.version === "string" ? json.version : null;
|
||||
return { tag, version };
|
||||
const nodeEngine = typeof json?.engines?.node === "string" ? json.engines.node : null;
|
||||
return { target, version, nodeEngine };
|
||||
} catch (err) {
|
||||
return { tag, version: null, error: String(err) };
|
||||
return { target, version: null, nodeEngine: null, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNpmTagVersion(params: {
|
||||
tag: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<NpmTagStatus> {
|
||||
const res = await fetchNpmPackageTargetStatus({
|
||||
target: params.tag,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return {
|
||||
tag: params.tag,
|
||||
version: res.version,
|
||||
error: res.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveNpmChannelTag(params: {
|
||||
channel: UpdateChannel;
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAgentScopedMediaLocalRoots, getDefaultMediaLocalRoots } from "./local-roots.js";
|
||||
import {
|
||||
appendLocalMediaParentRoots,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRootsForSources,
|
||||
getDefaultMediaLocalRoots,
|
||||
} from "./local-roots.js";
|
||||
|
||||
function normalizeHostPath(value: string): string {
|
||||
return path.normalize(path.resolve(value));
|
||||
@@ -36,4 +42,53 @@ describe("local media roots", () => {
|
||||
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "sandboxes")));
|
||||
expect(normalizedRoots).not.toContain(normalizeHostPath(path.join(stateDir, "agents")));
|
||||
});
|
||||
|
||||
it("adds concrete parent roots for local media sources without widening to filesystem root", () => {
|
||||
const picturesDir =
|
||||
process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
|
||||
const moviesDir =
|
||||
process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
|
||||
|
||||
const roots = appendLocalMediaParentRoots(
|
||||
["/tmp/base"],
|
||||
[
|
||||
path.join(picturesDir, "photo.png"),
|
||||
pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
|
||||
"https://example.com/remote.png",
|
||||
"/top-level-file.png",
|
||||
],
|
||||
);
|
||||
|
||||
expect(roots.map(normalizeHostPath)).toEqual(
|
||||
expect.arrayContaining([
|
||||
normalizeHostPath("/tmp/base"),
|
||||
normalizeHostPath(picturesDir),
|
||||
normalizeHostPath(moviesDir),
|
||||
]),
|
||||
);
|
||||
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
|
||||
});
|
||||
|
||||
it("widens agent media roots for concrete local sources only when workspaceOnly is disabled", () => {
|
||||
const stateDir = path.join("/tmp", "openclaw-flexible-media-roots-state");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
const flexibleRoots = getAgentScopedMediaLocalRootsForSources({
|
||||
cfg: {},
|
||||
agentId: "ops",
|
||||
mediaSources: ["/Users/peter/Pictures/photo.png"],
|
||||
});
|
||||
expect(flexibleRoots.map(normalizeHostPath)).toContain(
|
||||
normalizeHostPath("/Users/peter/Pictures"),
|
||||
);
|
||||
|
||||
const strictRoots = getAgentScopedMediaLocalRootsForSources({
|
||||
cfg: { tools: { fs: { workspaceOnly: true } } },
|
||||
agentId: "ops",
|
||||
mediaSources: ["/Users/peter/Pictures/photo.png"],
|
||||
});
|
||||
expect(strictRoots.map(normalizeHostPath)).not.toContain(
|
||||
normalizeHostPath("/Users/peter/Pictures"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { resolveEffectiveToolFsWorkspaceOnly } from "../agents/tool-fs-policy.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { safeFileURLToPath } from "../infra/local-file-access.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
type BuildMediaLocalRootsOptions = {
|
||||
preferredTmpDir?: string;
|
||||
};
|
||||
|
||||
let cachedPreferredTmpDir: string | undefined;
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
const DATA_URL_RE = /^data:/i;
|
||||
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
|
||||
|
||||
function resolveCachedPreferredTmpDir(): string {
|
||||
if (!cachedPreferredTmpDir) {
|
||||
@@ -53,3 +59,58 @@ export function getAgentScopedMediaLocalRoots(
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function resolveLocalMediaPath(source: string): string | undefined {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed || HTTP_URL_RE.test(trimmed) || DATA_URL_RE.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.startsWith("file://")) {
|
||||
try {
|
||||
return safeFileURLToPath(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
return resolveUserPath(trimmed);
|
||||
}
|
||||
if (path.isAbsolute(trimmed) || WINDOWS_DRIVE_RE.test(trimmed)) {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function appendLocalMediaParentRoots(
|
||||
roots: readonly string[],
|
||||
mediaSources?: readonly string[],
|
||||
): string[] {
|
||||
const appended = Array.from(new Set(roots.map((root) => path.resolve(root))));
|
||||
for (const source of mediaSources ?? []) {
|
||||
const localPath = resolveLocalMediaPath(source);
|
||||
if (!localPath) {
|
||||
continue;
|
||||
}
|
||||
const parentDir = path.dirname(localPath);
|
||||
if (parentDir === path.parse(parentDir).root) {
|
||||
continue;
|
||||
}
|
||||
const normalizedParent = path.resolve(parentDir);
|
||||
if (!appended.includes(normalizedParent)) {
|
||||
appended.push(normalizedParent);
|
||||
}
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
export function getAgentScopedMediaLocalRootsForSources(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
mediaSources?: readonly string[];
|
||||
}): readonly string[] {
|
||||
const roots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
|
||||
if (resolveEffectiveToolFsWorkspaceOnly({ cfg: params.cfg, agentId: params.agentId })) {
|
||||
return roots;
|
||||
}
|
||||
return appendLocalMediaParentRoots(roots, params.mediaSources);
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ function formatLocalSetupError(err: unknown): string {
|
||||
: undefined,
|
||||
missing && detail ? `Detail: ${detail}` : null,
|
||||
"To enable local embeddings:",
|
||||
"1) Use Node 24 (recommended for installs/updates; Node 22 LTS, currently 22.16+, remains supported)",
|
||||
"1) Use Node 24 (recommended for installs/updates; Node 22 LTS, currently 22.14+, remains supported)",
|
||||
missing
|
||||
? "2) Reinstall OpenClaw (this should install node-llama-cpp): npm i -g openclaw@latest"
|
||||
: null,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { saveExecApprovals } from "../infra/exec-approvals.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js";
|
||||
import {
|
||||
collectInstalledSkillsCodeSafetyFindings,
|
||||
collectPluginsCodeSafetyFindings,
|
||||
@@ -19,6 +19,15 @@ const windowsAuditEnv = {
|
||||
USERNAME: "Tester",
|
||||
USERDOMAIN: "DESKTOP-TEST",
|
||||
};
|
||||
const pathResolutionEnvKeys = [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_BUNDLED_PLUGINS_DIR",
|
||||
] as const;
|
||||
const execDockerRawUnavailable: NonNullable<SecurityAuditOptions["execDockerRawFn"]> = async () => {
|
||||
return {
|
||||
stdout: Buffer.alloc(0),
|
||||
@@ -271,7 +280,10 @@ describe("security audit", () => {
|
||||
let sharedCodeSafetyWorkspaceDir = "";
|
||||
let sharedExtensionsStateDir = "";
|
||||
let sharedInstallMetadataStateDir = "";
|
||||
let previousOpenClawHome: string | undefined;
|
||||
let isolatedHome = "";
|
||||
let homedirSpy: { mockRestore(): void } | undefined;
|
||||
const previousPathResolutionEnv: Partial<Record<(typeof pathResolutionEnvKeys)[number], string>> =
|
||||
{};
|
||||
|
||||
const makeTmpDir = async (label: string) => {
|
||||
const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
|
||||
@@ -353,9 +365,19 @@ description: test skill
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
|
||||
previousOpenClawHome = process.env.OPENCLAW_HOME;
|
||||
process.env.OPENCLAW_HOME = path.join(fixtureRoot, "home");
|
||||
await fs.mkdir(process.env.OPENCLAW_HOME, { recursive: true, mode: 0o700 });
|
||||
isolatedHome = path.join(fixtureRoot, "home");
|
||||
const isolatedEnv = createPathResolutionEnv(isolatedHome, { OPENCLAW_HOME: isolatedHome });
|
||||
for (const key of pathResolutionEnvKeys) {
|
||||
previousPathResolutionEnv[key] = process.env[key];
|
||||
const value = isolatedEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(isolatedHome);
|
||||
await fs.mkdir(isolatedHome, { recursive: true, mode: 0o700 });
|
||||
channelSecurityRoot = path.join(fixtureRoot, "channel-security");
|
||||
await fs.mkdir(channelSecurityRoot, { recursive: true, mode: 0o700 });
|
||||
sharedChannelSecurityStateDir = path.join(channelSecurityRoot, "state-shared");
|
||||
@@ -376,10 +398,14 @@ description: test skill
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousOpenClawHome === undefined) {
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
} else {
|
||||
process.env.OPENCLAW_HOME = previousOpenClawHome;
|
||||
homedirSpy?.mockRestore();
|
||||
for (const key of pathResolutionEnvKeys) {
|
||||
const value = previousPathResolutionEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
if (!fixtureRoot) {
|
||||
return;
|
||||
|
||||
@@ -225,7 +225,7 @@ describe("collectReleasePackageMetadataErrors", () => {
|
||||
license: "MIT",
|
||||
repository: { url: "git+https://github.com/openclaw/openclaw.git" },
|
||||
bin: { openclaw: "openclaw.mjs" },
|
||||
peerDependencies: { "node-llama-cpp": "3.16.2" },
|
||||
peerDependencies: { "node-llama-cpp": "3.18.1" },
|
||||
peerDependenciesMeta: { "node-llama-cpp": { optional: true } },
|
||||
}),
|
||||
).toEqual([]);
|
||||
@@ -239,7 +239,7 @@ describe("collectReleasePackageMetadataErrors", () => {
|
||||
license: "MIT",
|
||||
repository: { url: "git+https://github.com/openclaw/openclaw.git" },
|
||||
bin: { openclaw: "openclaw.mjs" },
|
||||
peerDependencies: { "node-llama-cpp": "3.16.2" },
|
||||
peerDependencies: { "node-llama-cpp": "3.18.1" },
|
||||
}),
|
||||
).toContain('package.json peerDependenciesMeta["node-llama-cpp"].optional must be true.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user