Merge branch 'main' into improve/edge-tts-followup

This commit is contained in:
Hiago Silva 2026-03-15 18:21:56 -03:00 committed by GitHub
commit 37e71c0176
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 3313 additions and 1116 deletions

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
docs/.generated/

View File

@ -72,6 +72,8 @@
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
- See `docs/.i18n/README.md`.
- The pipeline can be slow/inefficient; if its dragging, ping @jospalmbier on Discord instead of hacking around it.
@ -97,7 +99,7 @@
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch.
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
@ -179,7 +181,7 @@
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
## GHSA (Repo Advisory) Patch/Publish
@ -256,14 +258,13 @@
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary keys are managed outside the repo; follow internal release docs.
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
@ -290,35 +291,12 @@
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## NPM + 1Password (publish/verify)
## Release Auth
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`).
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Plugin Release Fast Path (no core `openclaw` publish)
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
- `eval "$(op signin --account my.1password.com)"`
- 1Password helpers:
- password used by `npm login`:
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
- OTP:
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
- compare local plugin `version` to `npm view <name> version`
- only run `npm publish --access public --otp="<otp>"` when versions differ
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
## Changelog Release Notes

View File

@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
### Fixes
@ -30,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
@ -62,6 +65,11 @@ Docs: https://docs.openclaw.ai
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
## 2026.3.13
@ -97,6 +105,7 @@ Docs: https://docs.openclaw.ai
- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
- Commands/onboarding: split static auth-choice help from the plugin-backed onboarding catalog so `openclaw onboard` registration no longer pulls provider-wizard imports just to describe `--auth-choice`. (#47545) Thanks @vincentkoc.
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart.
- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`.

View File

@ -103,7 +103,7 @@ pnpm build
pnpm openclaw onboard --install-daemon
# Dev loop (auto-reload on TS changes)
# Dev loop (auto-reload on source/config changes)
pnpm gateway:watch
```

View File

@ -123,6 +123,22 @@
"source": "Network model",
"target": "网络模型"
},
{
"source": "Doctor",
"target": "Doctor"
},
{
"source": "Polls",
"target": "投票"
},
{
"source": "Release Policy",
"target": "发布策略"
},
{
"source": "Release policy",
"target": "发布策略"
},
{
"source": "for full details",
"target": "了解详情"

View File

@ -7,7 +7,7 @@ title: "Zalo"
# Zalo (Bot API)
Status: experimental. DMs are supported; group handling is available with explicit group policy controls.
Status: experimental. DMs are supported. The [Capabilities](#capabilities) section below reflects current Marketplace-bot behavior.
## Plugin required
@ -25,7 +25,7 @@ Zalo ships as a plugin and is not bundled with the core install.
- Or pick **Zalo** in onboarding and confirm the install prompt
2. Set the token:
- Env: `ZALO_BOT_TOKEN=...`
- Or config: `channels.zalo.botToken: "..."`.
- Or config: `channels.zalo.accounts.default.botToken: "..."`.
3. Restart the gateway (or finish onboarding).
4. DM access is pairing by default; approve the pairing code on first contact.
@ -36,8 +36,12 @@ Minimal config:
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
accounts: {
default: {
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
},
},
},
},
}
@ -48,10 +52,13 @@ Minimal config:
Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations.
It is a good fit for support or notifications where you want deterministic routing back to Zalo.
This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplace bots**.
**Zalo Official Account (OA) bots** are a different Zalo product surface and may behave differently.
- A Zalo Bot API channel owned by the Gateway.
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
- DMs share the agent's main session.
- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior.
- The [Capabilities](#capabilities) section below shows current Marketplace-bot support.
## Setup (fast path)
@ -59,7 +66,7 @@ It is a good fit for support or notifications where you want deterministic routi
1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in.
2. Create a new bot and configure its settings.
3. Copy the bot token (format: `12345689:abc-xyz`).
3. Copy the full bot token (typically `numeric_id:secret`). For Marketplace bots, the usable runtime token may appear in the bot's welcome message after creation.
### 2) Configure the token (env or config)
@ -70,13 +77,19 @@ Example:
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
accounts: {
default: {
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
},
},
},
},
}
```
If you later move to a Zalo bot surface where groups are available, you can add group-specific config such as `groupPolicy` and `groupAllowFrom` explicitly. For current Marketplace-bot behavior, see [Capabilities](#capabilities).
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
@ -109,14 +122,23 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Access control (Groups)
For **Zalo Bot Creator / Marketplace bots**, group support was not available in practice because the bot could not be added to a group at all.
That means the group-related config keys below exist in the schema, but were not usable for Marketplace bots:
- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`.
- Default behavior is fail-closed: `allowlist`.
- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups.
- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks.
- `groupPolicy: "disabled"` blocks all group messages.
- `groupPolicy: "open"` allows any group member (mention-gated).
- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety.
The group policy values (when group access is available on your bot surface) are:
- `groupPolicy: "disabled"` — blocks all group messages.
- `groupPolicy: "open"` — allows any group member (mention-gated).
- `groupPolicy: "allowlist"` — fail-closed default; only allowed senders are accepted.
If you are using a different Zalo bot product surface and have verified working group behavior, document that separately rather than assuming it matches the Marketplace-bot flow.
## Long-polling vs webhook
- Default: long-polling (no public URL required).
@ -133,23 +155,36 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Supported message types
For a quick support snapshot, see [Capabilities](#capabilities). The notes below add detail where the behavior needs extra context.
- **Text messages**: Full support with 2000 character chunking.
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
- **Stickers**: Logged but not fully processed (no agent response).
- **Unsupported types**: Logged (e.g., messages from protected users).
- **Plain URLs in text**: Behave like normal text input.
- **Link previews / rich link cards**: See the Marketplace-bot status in [Capabilities](#capabilities); they did not reliably trigger a reply.
- **Image messages**: See the Marketplace-bot status in [Capabilities](#capabilities); inbound image handling was unreliable (typing indicator without a final reply).
- **Stickers**: See the Marketplace-bot status in [Capabilities](#capabilities).
- **Voice notes / audio files / video / generic file attachments**: See the Marketplace-bot status in [Capabilities](#capabilities).
- **Unsupported types**: Logged (for example, messages from protected users).
## Capabilities
| Feature | Status |
| --------------- | -------------------------------------------------------- |
| Direct messages | ✅ Supported |
| Groups | ⚠️ Supported with policy controls (allowlist by default) |
| Media (images) | ✅ Supported |
| Reactions | ❌ Not supported |
| Threads | ❌ Not supported |
| Polls | ❌ Not supported |
| Native commands | ❌ Not supported |
| Streaming | ⚠️ Blocked (2000 char limit) |
This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in OpenClaw.
| Feature | Status |
| --------------------------- | --------------------------------------- |
| Direct messages | ✅ Supported |
| Groups | ❌ Not available for Marketplace bots |
| Media (inbound images) | ⚠️ Limited / verify in your environment |
| Media (outbound images) | ⚠️ Not re-tested for Marketplace bots |
| Plain URLs in text | ✅ Supported |
| Link previews | ⚠️ Unreliable for Marketplace bots |
| Reactions | ❌ Not supported |
| Stickers | ⚠️ No agent reply for Marketplace bots |
| Voice notes / audio / video | ⚠️ No agent reply for Marketplace bots |
| File attachments | ⚠️ No agent reply for Marketplace bots |
| Threads | ❌ Not supported |
| Polls | ❌ Not supported |
| Native commands | ❌ Not supported |
| Streaming | ⚠️ Blocked (2000 char limit) |
## Delivery targets (CLI/cron)
@ -175,6 +210,8 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
Full configuration: [Configuration](/gateway/configuration)
The flat top-level keys (`channels.zalo.botToken`, `channels.zalo.dmPolicy`, and similar) are a legacy single-account shorthand. Prefer `channels.zalo.accounts.<id>.*` for new configs. Both forms are still documented here because they exist in the schema.
Provider options:
- `channels.zalo.enabled`: enable/disable channel startup.
@ -182,7 +219,7 @@ Provider options:
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset.
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
@ -198,7 +235,7 @@ Multi-account options:
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy.
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy. Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
- `channels.zalo.accounts.<id>.groupAllowFrom`: per-account group sender allowlist.
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.

View File

@ -21,6 +21,7 @@ openclaw update wizard
openclaw update --channel beta
openclaw update --channel dev
openclaw update --tag beta
openclaw update --tag main
openclaw update --dry-run
openclaw update --no-restart
openclaw update --json
@ -31,7 +32,7 @@ openclaw --update
- `--no-restart`: skip restarting the Gateway service after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).

View File

@ -469,7 +469,7 @@
},
{
"source": "/mac/release",
"destination": "/platforms/mac/release"
"destination": "/reference/RELEASING"
},
{
"source": "/mac/remote",
@ -1166,7 +1166,6 @@
"platforms/mac/permissions",
"platforms/mac/remote",
"platforms/mac/signing",
"platforms/mac/release",
"platforms/mac/bundled-gateway",
"platforms/mac/xpc",
"platforms/mac/skills",
@ -1351,7 +1350,7 @@
"pages": ["reference/credits"]
},
{
"group": "Release notes",
"group": "Release policy",
"pages": ["reference/RELEASING", "reference/test"]
},
{
@ -1750,7 +1749,6 @@
"zh-CN/platforms/mac/permissions",
"zh-CN/platforms/mac/remote",
"zh-CN/platforms/mac/signing",
"zh-CN/platforms/mac/release",
"zh-CN/platforms/mac/bundled-gateway",
"zh-CN/platforms/mac/xpc",
"zh-CN/platforms/mac/skills",
@ -1933,7 +1931,7 @@
"pages": ["zh-CN/reference/credits"]
},
{
"group": "发布说明",
"group": "发布策略",
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
},
{

View File

@ -40,11 +40,17 @@ pnpm gateway:watch
This maps to:
```bash
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
node scripts/watch-node.mjs gateway --force
```
Add any gateway CLI flags after `gateway:watch` and they will be passed through
on each restart.
The watcher restarts on build-relevant files under `src/`, extension source files,
extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`,
`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the
gateway without forcing a `tsdown` rebuild; source and config changes still
rebuild `dist` first.
Add any gateway CLI flags after `gateway:watch` and they will be passed through on
each restart.
## Dev profile + dev gateway (--dev)

View File

@ -102,6 +102,16 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
</Tab>
</Tabs>
Want the current GitHub `main` head with a package-manager install?
```bash
npm install -g github:openclaw/openclaw#main
```
```bash
pnpm add -g github:openclaw/openclaw#main
```
</Accordion>
<Accordion title="From source" icon="github">

View File

@ -116,6 +116,11 @@ The script exits with code `2` for invalid method selection or invalid `--instal
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git
```
</Tab>
<Tab title="GitHub main via npm">
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
```
</Tab>
<Tab title="Dry run">
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --dry-run
@ -126,39 +131,39 @@ The script exits with code `2` for invalid method selection or invalid `--instal
<AccordionGroup>
<Accordion title="Flags reference">
| Flag | Description |
| ------------------------------- | ---------------------------------------------------------- |
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
| `--npm` | Shortcut for npm method |
| `--git` | Shortcut for git method. Alias: `--github` |
| `--version <version\|dist-tag>` | npm version or dist-tag (default: `latest`) |
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
| `--no-git-update` | Skip `git pull` for existing checkout |
| `--no-prompt` | Disable prompts |
| `--no-onboard` | Skip onboarding |
| `--onboard` | Enable onboarding |
| `--dry-run` | Print actions without applying changes |
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
| `--help` | Show usage (`-h`) |
| Flag | Description |
| ------------------------------------- | ---------------------------------------------------------- |
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
| `--npm` | Shortcut for npm method |
| `--git` | Shortcut for git method. Alias: `--github` |
| `--version <version\|dist-tag\|spec>` | npm version, dist-tag, or package spec (default: `latest`) |
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
| `--no-git-update` | Skip `git pull` for existing checkout |
| `--no-prompt` | Disable prompts |
| `--no-onboard` | Skip onboarding |
| `--onboard` | Enable onboarding |
| `--dry-run` | Print actions without applying changes |
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
| `--help` | Show usage (`-h`) |
</Accordion>
<Accordion title="Environment variables reference">
| Variable | Description |
| ------------------------------------------- | --------------------------------------------- |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=latest\|next\|<semver>` | npm version or dist-tag |
| `OPENCLAW_BETA=0\|1` | Use beta if available |
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
| `OPENCLAW_VERBOSE=1` | Debug mode |
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
| Variable | Description |
| ------------------------------------------------------- | --------------------------------------------- |
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
| `OPENCLAW_VERSION=latest\|next\|main\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
| `OPENCLAW_BETA=0\|1` | Use beta if available |
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
| `OPENCLAW_VERBOSE=1` | Debug mode |
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
</Accordion>
</AccordionGroup>
@ -276,6 +281,11 @@ Designed for environments where you want everything under a local prefix (defaul
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git
```
</Tab>
<Tab title="GitHub main via npm">
```powershell
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main
```
</Tab>
<Tab title="Custom git directory">
```powershell
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -GitDir "C:\openclaw"
@ -299,14 +309,14 @@ Designed for environments where you want everything under a local prefix (defaul
<AccordionGroup>
<Accordion title="Flags reference">
| Flag | Description |
| ------------------------- | ------------------------------------------------------ |
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
| `-Tag <tag>` | npm dist-tag (default: `latest`) |
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
| `-NoOnboard` | Skip onboarding |
| `-NoGitUpdate` | Skip `git pull` |
| `-DryRun` | Print actions only |
| Flag | Description |
| --------------------------- | ---------------------------------------------------------- |
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
| `-Tag <tag\|version\|spec>` | npm dist-tag, version, or package spec (default: `latest`) |
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
| `-NoOnboard` | Skip onboarding |
| `-NoGitUpdate` | Skip `git pull` |
| `-DryRun` | Print actions only |
</Accordion>

View File

@ -65,7 +65,25 @@ openclaw update --channel dev
openclaw update --channel stable
```
Use `--tag <dist-tag|version>` for a one-off install tag/version.
Use `--tag <dist-tag|version|spec>` for a one-off package target override.
For the current GitHub `main` head via a package-manager install:
```bash
openclaw update --tag main
```
Manual equivalents:
```bash
npm i -g github:openclaw/openclaw#main
```
```bash
pnpm add -g github:openclaw/openclaw#main
```
You can also pass an explicit package spec to `--tag` for one-off updates (for example a GitHub ref or tarball URL).
See [Development channels](/install/development-channels) for channel semantics and release notes.

View File

@ -1,90 +0,0 @@
---
summary: "OpenClaw macOS release checklist (Sparkle feed, packaging, signing)"
read_when:
- Cutting or validating a OpenClaw macOS release
- Updating the Sparkle appcast or feed assets
title: "macOS Release"
---
# OpenClaw macOS release (Sparkle)
This app now ships Sparkle auto-updates. Release builds must be Developer IDsigned, zipped, and published with a signed appcast entry.
## Prereqs
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
- We use a Keychain profile named `openclaw-notary`, created from App Store Connect API key env vars in your shell profile:
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`).
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
## Build & package
Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
# This command builds release artifacts without notarization.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.13 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip
# Optional: build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
# xcrun notarytool store-credentials "openclaw-notary" \
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.13 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
- `curl -I <enclosure url>` returns 200 after assets upload.
- On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly.
Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release.

View File

@ -1,161 +1,42 @@
---
title: "Release Checklist"
summary: "Step-by-step release checklist for npm + macOS app"
title: "Release Policy"
summary: "Public release channels, version naming, and cadence"
read_when:
- Cutting a new npm release
- Cutting a new macOS app release
- Verifying metadata before publishing
- Looking for public release channel definitions
- Looking for version naming and cadence
---
# Release Checklist (npm + macOS)
# Release Policy
Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing.
OpenClaw has three public release lanes:
## Operator trigger
- stable: tagged releases that publish to npm `latest`
- beta: prerelease tags that publish to npm `beta`
- dev: the moving head of `main`
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
## Versioning
Current OpenClaw releases use date-based versioning.
## Version naming
- Stable release version: `YYYY.M.D`
- Git tag: `vYYYY.M.D`
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
- Beta prerelease version: `YYYY.M.D-beta.N`
- Git tag: `vYYYY.M.D-beta.N`
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
- Fallback correction tag: `vYYYY.M.D-N`
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
- `package.json`: `2026.3.8`
- Git tag: `v2026.3.8`
- GitHub release title: `openclaw 2026.3.8`
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
- Stable and beta are npm dist-tags, not separate release lines:
- `latest` = stable
- `beta` = prerelease/testing
- Dev is the moving head of `main`, not a normal git-tagged release.
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
- Do not zero-pad month or day
- `latest` means the current stable npm release
- `beta` means the current prerelease npm release
- Beta releases may ship before the macOS app catches up
Historical note:
## Release cadence
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
- Releases move beta-first
- Stable follows only after the latest beta is validated
- Detailed release procedure, approvals, credentials, and recovery notes are
maintainer-only
1. **Version & metadata**
## Public references
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`.
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
2. **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
3. **Changelog & docs**
- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version.
- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options).
4. **Validation**
- [ ] `pnpm build`
- [ ] `pnpm check`
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://openclaw.ai/install.sh | bash`, onboards, then runs real tool calls):
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
- `pnpm test:install:e2e` (requires both keys; runs both providers)
- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths.
5. **macOS app (Sparkle)**
- [ ] Build + sign the macOS app, then zip it for distribution.
- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`.
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
- [ ] Follow [macOS release](/platforms/mac/release) for the exact commands and required env vars.
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
- If notarizing, use the `openclaw-notary` keychain profile created from App Store Connect API env vars (see [macOS release](/platforms/mac/release)).
6. **Publish (npm)**
- [ ] Confirm git status is clean; commit and push as needed.
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing.
- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`.
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
- Stable tags publish to npm `latest`.
- Beta tags publish to npm `beta`.
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
- If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release)
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/OpenClaw.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/OpenClaw.app` is not listed.
- **npm auth web loop for dist-tags**: use legacy auth to get an OTP prompt:
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
7. **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- Pushing the tag also triggers the npm release workflow.
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
- [ ] From a clean temp directory (no `package.json`), run `npx -y openclaw@X.Y.Z send --help` to confirm install/CLI entrypoints work.
- [ ] Announce/share release notes.
## Plugin publish scope (npm)
We only publish **existing npm plugins** under the `@openclaw/*` scope. Bundled
plugins that are not on npm stay **disk-tree only** (still shipped in
`extensions/**`).
Process to derive the list:
1. `npm search @openclaw --json` and capture the package names.
2. Compare with `extensions/*/package.json` names.
3. Publish only the **intersection** (already on npm).
Current npm plugin list (update as needed):
- @openclaw/bluebubbles
- @openclaw/diagnostics-otel
- @openclaw/discord
- @openclaw/feishu
- @openclaw/lobster
- @openclaw/matrix
- @openclaw/msteams
- @openclaw/nextcloud-talk
- @openclaw/nostr
- @openclaw/voice-call
- @openclaw/zalo
- @openclaw/zalouser
Release notes must also call out **new optional bundled plugins** that are **not
on by default** (example: `tlon`).
Maintainers use the private release docs in
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
for the actual runbook.

View File

@ -157,7 +157,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [macOS permissions](/platforms/mac/permissions)
- [macOS remote](/platforms/mac/remote)
- [macOS signing](/platforms/mac/signing)
- [macOS release](/platforms/mac/release)
- [macOS gateway (launchd)](/platforms/mac/bundled-gateway)
- [macOS XPC](/platforms/mac/xpc)
- [macOS skills](/platforms/mac/skills)
@ -190,5 +189,5 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Testing + release
- [Testing](/reference/test)
- [Release checklist](/reference/RELEASING)
- [Release policy](/reference/RELEASING)
- [Device models](/reference/device-models)

View File

@ -96,7 +96,8 @@ pnpm install
pnpm gateway:watch
```
`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes.
`gateway:watch` runs the gateway in watch mode and reloads on relevant source,
config, and bundled-plugin metadata changes.
### 2) Point the macOS app at your running Gateway

View File

@ -12,7 +12,7 @@
- 目标文档:`docs/zh-CN/**/*.md`
- 术语表:`docs/.i18n/glossary.zh-CN.json`
- 翻译记忆库:`docs/.i18n/zh-CN.tm.jsonl`
- 提示词规则:`scripts/docs-i18n/translator.go`
- 提示词规则:`scripts/docs-i18n/prompt.go`
常用运行方式:
@ -31,6 +31,8 @@ go run scripts/docs-i18n/main.go -mode segment docs/channels/matrix.md
注意事项:
- doc 模式用于整页翻译segment 模式用于小范围修补(依赖 TM
- 新增技术术语、页面标题或短导航标签时,先更新 `docs/.i18n/glossary.zh-CN.json`,再跑 `doc` 模式;不要指望模型自行保留英文术语或固定译名。
- `pnpm docs:check-i18n-glossary` 会检查变更过的英文文档标题和短内部链接标签是否已写入 glossary。
- 超大文件若超时,优先做**定点替换**或拆分后再跑。
- 翻译后检查中文引号、CJK-Latin 间距和术语一致性。

View File

@ -1,92 +0,0 @@
---
read_when:
- 制作或验证 OpenClaw macOS 发布版本
- 更新 Sparkle appcast 或订阅源资源
summary: OpenClaw macOS 发布清单Sparkle 订阅源、打包、签名)
title: macOS 发布
x-i18n:
generated_at: "2026-02-01T21:33:17Z"
model: claude-opus-4-5
provider: pi
source_hash: 703c08c13793cd8c96bd4c31fb4904cdf4ffff35576e7ea48a362560d371cb30
source_path: platforms/mac/release.md
workflow: 15
---
# OpenClaw macOS 发布Sparkle
本应用现已支持 Sparkle 自动更新。发布构建必须经过 Developer ID 签名、压缩,并发布包含签名的 appcast 条目。
## 前提条件
- 已安装 Developer ID Application 证书(示例:`Developer ID Application: <Developer Name> (<TEAMID>)`)。
- 环境变量 `SPARKLE_PRIVATE_KEY_FILE` 已设置为 Sparkle ed25519 私钥路径(公钥已嵌入 Info.plist。如果缺失请检查 `~/.profile`
- 用于 `xcrun notarytool` 的公证凭据(钥匙串配置文件或 API 密钥),以实现通过 Gatekeeper 安全分发的 DMG/zip。
- 我们使用名为 `openclaw-notary` 的钥匙串配置文件,由 shell 配置文件中的 App Store Connect API 密钥环境变量创建:
- `APP_STORE_CONNECT_API_KEY_P8``APP_STORE_CONNECT_KEY_ID``APP_STORE_CONNECT_ISSUER_ID`
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
- 已安装 `pnpm` 依赖(`pnpm install --config.node-linker=hoisted`)。
- Sparkle 工具通过 SwiftPM 自动获取,位于 `apps/macos/.build/artifacts/sparkle/Sparkle/bin/``sign_update``generate_appcast` 等)。
## 构建与打包
注意事项:
- `APP_BUILD` 映射到 `CFBundleVersion`/`sparkle:version`;保持纯数字且单调递增(不含 `-beta`),否则 Sparkle 会将其视为相同版本。
- 默认为当前架构(`$(uname -m)`)。对于发布/通用构建,设置 `BUILD_ARCHS="arm64 x86_64"`(或 `BUILD_ARCHS=all`)。
- 使用 `scripts/package-mac-dist.sh` 生成发布产物zip + DMG + 公证)。使用 `scripts/package-mac-app.sh` 进行本地/开发打包。
```bash
# 从仓库根目录运行;设置发布 ID 以启用 Sparkle 订阅源。
# APP_BUILD 必须为纯数字且单调递增,以便 Sparkle 正确比较。
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# 打包用于分发的 zip包含资源分支以支持 Sparkle 增量更新)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.zip
# 可选:同时构建适合用户使用的样式化 DMG拖拽到 /Applications
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.dmg
# 推荐:构建 + 公证/装订 zip + DMG
# 首先,创建一次钥匙串配置文件:
# xcrun notarytool store-credentials "openclaw-notary" \
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# 可选:随发布一起提供 dSYM
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.1.27-beta.1.dSYM.zip
```
## Appcast 条目
使用发布说明生成器,以便 Sparkle 渲染格式化的 HTML 说明:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.1.27-beta.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
`CHANGELOG.md`(通过 [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh))生成 HTML 发布说明,并将其嵌入 appcast 条目。
发布时,将更新后的 `appcast.xml` 与发布资源zip + dSYM一起提交。
## 发布与验证
- 将 `OpenClaw-2026.1.27-beta.1.zip`(和 `OpenClaw-2026.1.27-beta.1.dSYM.zip`)上传到标签 `v2026.1.27-beta.1` 对应的 GitHub 发布。
- 确保原始 appcast URL 与内置的订阅源匹配:`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`
- 完整性检查:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` 返回 200。
- `curl -I <enclosure url>` 在资源上传后返回 200。
- 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。
完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。

View File

@ -1,123 +1,48 @@
---
read_when:
- 发布新的 npm 版本
- 发布新的 macOS 应用版本
- 发布前验证元数据
summary: npm + macOS 应用的逐步发布清单
- 查找公开发布渠道的定义
- 查找版本命名与发布节奏
summary: 公开发布渠道、版本命名与发布节奏
title: 发布策略
x-i18n:
generated_at: "2026-02-03T10:09:28Z"
model: claude-opus-4-5
generated_at: "2026-03-15T19:23:11Z"
model: claude-opus-4-6
provider: pi
source_hash: 1a684bc26665966eb3c9c816d58d18eead008fd710041181ece38c21c5ff1c62
source_hash: df332d3169de7099661725d9266955456e80fc3d3ff95cb7aaf9997a02f0baaf
source_path: reference/RELEASING.md
workflow: 15
---
# 发布清单npm + macOS
# 发布策略
从仓库根目录使用 `pnpm`Node 22+)。在打标签/发布前保持工作树干净。
OpenClaw 有三个公开发布渠道:
## 操作员触发
- stable带标签的正式发布发布到 npm `latest`
- beta预发布标签发布到 npm `beta`
- dev`main` 分支的最新提交
当操作员说"release"时,立即执行此预检(除非遇到阻碍否则不要额外提问):
## 版本命名
- 阅读本文档和 `docs/platforms/mac/release.md`
- 从 `~/.profile` 加载环境变量并确认 `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect 变量已设置SPARKLE_PRIVATE_KEY_FILE 应位于 `~/.profile` 中)。
- 如需要,使用 `~/Library/CloudStorage/Dropbox/Backup/Sparkle` 中的 Sparkle 密钥。
- 正式发布版本号:`YYYY.M.D`
- Git 标签:`vYYYY.M.D`
- Beta 预发布版本号:`YYYY.M.D-beta.N`
- Git 标签:`vYYYY.M.D-beta.N`
- 月份和日期不补零
- `latest` 表示当前 npm 正式发布版本
- `beta` 表示当前 npm 预发布版本
- Beta 版本可能会在 macOS 应用跟进之前发布
1. **版本和元数据**
## 发布节奏
- [ ] 更新 `package.json` 版本(例如 `2026.1.29`)。
- [ ] 运行 `pnpm plugins:sync` 以对齐扩展包版本和变更日志。
- [ ] 更新 CLI/版本字符串:[`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) 和 [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts) 中的 Baileys user agent。
- [ ] 确认包元数据name、description、repository、keywords、license以及 `bin` 映射指向 [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) 作为 `openclaw`
- [ ] 如果依赖项有变化,运行 `pnpm install` 确保 `pnpm-lock.yaml` 是最新的。
- 发布遵循 beta 优先原则
- 仅在最新的 beta 版本验证通过后才会发布正式版本
- 详细的发布流程、审批、凭证和恢复说明仅限维护者查阅
2. **构建和产物**
## 公开参考
- [ ] 如果 A2UI 输入有变化,运行 `pnpm canvas:a2ui:bundle` 并提交更新后的 [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js)。
- [ ] `pnpm run build`(重新生成 `dist/`)。
- [ ] 验证 npm 包的 `files` 包含所有必需的 `dist/*` 文件夹(特别是用于 headless node + ACP CLI 的 `dist/node-host/**``dist/acp/**`)。
- [ ] 确认 `dist/build-info.json` 存在并包含预期的 `commit` 哈希CLI 横幅在 npm 安装时使用此信息)。
- [ ] 可选:构建后运行 `npm pack --pack-destination /tmp`;检查 tarball 内容并保留以备 GitHub 发布使用(**不要**提交它)。
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
3. **变更日志和文档**
- [ ] 更新 `CHANGELOG.md`,添加面向用户的亮点(如果文件不存在则创建);按版本严格降序排列条目。
- [ ] 确保 README 示例/标志与当前 CLI 行为匹配(特别是新命令或选项)。
4. **验证**
- [ ] `pnpm build`
- [ ] `pnpm check`
- [ ] `pnpm test`(如需覆盖率输出则使用 `pnpm test:coverage`
- [ ] `pnpm release:check`(验证 npm pack 内容)
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`Docker 安装冒烟测试,快速路径;发布前必需)
- 如果已知上一个 npm 发布版本有问题,为预安装步骤设置 `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>``OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1`
- [ ](可选)完整安装程序冒烟测试(添加非 root + CLI 覆盖):`pnpm test:install:smoke`
- [ ](可选)安装程序 E2EDocker运行 `curl -fsSL https://openclaw.ai/install.sh | bash`,新手引导,然后运行真实工具调用):
- `pnpm test:install:e2e:openai`(需要 `OPENAI_API_KEY`
- `pnpm test:install:e2e:anthropic`(需要 `ANTHROPIC_API_KEY`
- `pnpm test:install:e2e`(需要两个密钥;运行两个提供商)
- [ ](可选)如果你的更改影响发送/接收路径,抽查 Web Gateway 网关。
5. **macOS 应用Sparkle**
- [ ] 构建并签名 macOS 应用,然后压缩以供分发。
- [ ] 生成 Sparkle appcast通过 [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh) 生成 HTML 注释)并更新 `appcast.xml`
- [ ] 保留应用 zip和可选的 dSYM zip以便附加到 GitHub 发布。
- [ ] 按照 [macOS 发布](/platforms/mac/release) 获取确切命令和所需环境变量。
- `APP_BUILD` 必须是数字且单调递增(不带 `-beta`),以便 Sparkle 正确比较版本。
- 如果进行公证,使用从 App Store Connect API 环境变量创建的 `openclaw-notary` 钥匙串配置文件(参见 [macOS 发布](/platforms/mac/release))。
6. **发布npm**
- [ ] 确认 git 状态干净;根据需要提交并推送。
- [ ] 如需要,`npm login`(验证 2FA
- [ ] `npm publish --access public`(预发布版本使用 `--tag beta`)。
- [ ] 验证注册表:`npm view openclaw version``npm view openclaw dist-tags``npx -y openclaw@X.Y.Z --version`(或 `--help`)。
### 故障排除(来自 2.0.0-beta2 发布的笔记)
- **npm pack/publish 挂起或产生巨大 tarball**`dist/OpenClaw.app` 中的 macOS 应用包(和发布 zip被扫入包中。通过 `package.json``files` 白名单发布内容来修复(包含 dist 子目录、docs、skills排除应用包。用 `npm pack --dry-run` 确认 `dist/OpenClaw.app` 未列出。
- **npm auth dist-tags 的 Web 循环**:使用旧版认证以获取 OTP 提示:
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
- **`npx` 验证失败并显示 `ECOMPROMISED: Lock compromised`**:使用新缓存重试:
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
- **延迟修复后需要重新指向标签**:强制更新并推送标签,然后确保 GitHub 发布资产仍然匹配:
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
7. **GitHub 发布 + appcast**
- [ ] 打标签并推送:`git tag vX.Y.Z && git push origin vX.Y.Z`(或 `git push --tags`)。
- [ ] 为 `vX.Y.Z` 创建/刷新 GitHub 发布,**标题为 `openclaw X.Y.Z`**(不仅仅是标签);正文应包含该版本的**完整**变更日志部分(亮点 + 更改 + 修复),内联显示(无裸链接),且**不得在正文中重复标题**。
- [ ] 附加产物:`npm pack` tarball可选`OpenClaw-X.Y.Z.zip``OpenClaw-X.Y.Z.dSYM.zip`(如果生成)。
- [ ] 提交更新后的 `appcast.xml` 并推送Sparkle 从 main 获取源)。
- [ ] 从干净的临时目录(无 `package.json`),运行 `npx -y openclaw@X.Y.Z send --help` 确认安装/CLI 入口点正常工作。
- [ ] 宣布/分享发布说明。
## 插件发布范围npm
我们只发布 `@openclaw/*` 范围下的**现有 npm 插件**。不在 npm 上的内置插件保持**仅磁盘树**(仍在 `extensions/**` 中发布)。
获取列表的流程:
1. `npm search @openclaw --json` 并捕获包名。
2. 与 `extensions/*/package.json` 名称比较。
3. 只发布**交集**(已在 npm 上)。
当前 npm 插件列表(根据需要更新):
- @openclaw/bluebubbles
- @openclaw/diagnostics-otel
- @openclaw/discord
- @openclaw/lobster
- @openclaw/matrix
- @openclaw/msteams
- @openclaw/nextcloud-talk
- @openclaw/nostr
- @openclaw/voice-call
- @openclaw/zalo
- @openclaw/zalouser
发布说明还必须标注**默认未启用**的**新可选内置插件**(例如:`tlon`)。
维护者使用
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
中的私有发布文档作为实际操作手册。

View File

@ -1,20 +1,24 @@
---
read_when:
- 你想要一份完整的文档地图
summary: 链接到每篇 OpenClaw 文档的导航中心
summary: 链接到所有 OpenClaw 文档的导航中心
title: 文档导航中心
x-i18n:
generated_at: "2026-02-04T17:55:29Z"
model: claude-opus-4-5
generated_at: "2026-03-15T19:29:16Z"
model: claude-opus-4-6
provider: pi
source_hash: c4b4572b64d36c9690988b8f964b0712f551ee6491b18a493701a17d2d352cb4
source_hash: e12e8b7881311fdaf08cd297392911dfa30dc46031a7038b6bb9011d166b1669
source_path: start/hubs.md
workflow: 15
---
# 文档导航中心
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。
<Note>
如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。
</Note>
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。
## 从这里开始
@ -75,7 +79,6 @@ x-i18n:
- [模型提供商中心](/providers/models)
- [WhatsApp](/channels/whatsapp)
- [Telegram](/channels/telegram)
- [TelegramgrammY 注意事项)](/channels/grammy)
- [Slack](/channels/slack)
- [Discord](/channels/discord)
- [Mattermost](/channels/mattermost)(插件)
@ -113,17 +116,18 @@ x-i18n:
- [OpenProse](/prose)
- [CLI 参考](/cli)
- [Exec 工具](/tools/exec)
- [PDF 工具](/tools/pdf)
- [提权模式](/tools/elevated)
- [定时任务](/automation/cron-jobs)
- [定时任务 vs 心跳](/automation/cron-vs-heartbeat)
- [思考 + 详细输出](/tools/thinking)
- [模型](/concepts/models)
- [子智能体](/tools/subagents)
- [Agent send CLI](/tools/agent-send)
- [智能体发送 CLI](/tools/agent-send)
- [终端界面](/web/tui)
- [浏览器控制](/tools/browser)
- [浏览器Linux 故障排除)](/tools/browser-linux-troubleshooting)
- [轮询](/automation/poll)
- [投票](/automation/poll)
## 节点、媒体、语音
@ -160,7 +164,6 @@ x-i18n:
- [macOS 权限](/platforms/mac/permissions)
- [macOS 远程](/platforms/mac/remote)
- [macOS 签名](/platforms/mac/signing)
- [macOS 发布](/platforms/mac/release)
- [macOS Gateway 网关 (launchd)](/platforms/mac/bundled-gateway)
- [macOS XPC](/platforms/mac/xpc)
- [macOS Skills](/platforms/mac/skills)
@ -183,8 +186,6 @@ x-i18n:
## 实验(探索性)
- [新手引导配置协议](/experiments/onboarding-config-protocol)
- [定时任务加固笔记](/experiments/plans/cron-add-hardening)
- [群组策略加固笔记](/experiments/plans/group-policy-hardening)
- [研究:记忆](/experiments/research/memory)
- [模型配置探索](/experiments/proposals/model-config)
@ -195,5 +196,5 @@ x-i18n:
## 测试 + 发布
- [测试](/reference/test)
- [发布检查清单](/reference/RELEASING)
- [发布策略](/reference/RELEASING)
- [设备型号](/reference/device-models)

View File

@ -1,13 +1,44 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
ACPX_BUNDLED_BIN,
ACPX_PINNED_VERSION,
createAcpxPluginConfigSchema,
resolveAcpxPluginRoot,
resolveAcpxPluginConfig,
} from "./config.js";
describe("acpx plugin config parsing", () => {
it("resolves source-layout plugin root from a file under src", () => {
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-"));
try {
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
} finally {
fs.rmSync(pluginRoot, { recursive: true, force: true });
}
});
it("resolves bundled-layout plugin root from the dist entry file", () => {
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-"));
try {
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href;
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
} finally {
fs.rmSync(pluginRoot, { recursive: true, force: true });
}
});
it("resolves bundled acpx with pinned version by default", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx";
@ -11,7 +12,27 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO
export const ACPX_PINNED_VERSION = "0.1.16";
export const ACPX_VERSION_ANY = "any";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
let cursor = path.dirname(fileURLToPath(moduleUrl));
for (let i = 0; i < 3; i += 1) {
// Bundled entries live at the plugin root while source files still live under src/.
if (
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
fs.existsSync(path.join(cursor, "package.json"))
) {
return cursor;
}
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
cursor = parent;
}
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
}
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
return `npm install --omit=dev --no-save acpx@${version}`;

View File

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
vi.mock("openclaw/extension-api", () => {
return {
runEmbeddedPiAgent: vi.fn(async () => ({
meta: { startedAt: Date.now() },
@ -9,7 +9,7 @@ vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
};
});
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
import { runEmbeddedPiAgent } from "openclaw/extension-api";
import { createLlmTaskTool } from "./llm-task-tool.js";
// oxlint-disable-next-line typescript/no-explicit-any

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { Type } from "@sinclair/typebox";
import Ajv from "ajv";
import { runEmbeddedPiAgent } from "openclaw/extension-api";
import {
formatThinkingLevels,
formatXHighModelHint,
@ -9,39 +10,8 @@ import {
resolvePreferredOpenClawTmpDir,
supportsXHighThinking,
} from "openclaw/plugin-sdk/llm-task";
// NOTE: This extension is intended to be bundled with OpenClaw.
// When running from source (tests/dev), OpenClaw internals live under src/.
// When running from a built install, internals live under dist/ (no src/ tree).
// So we resolve internal imports dynamically with src-first, dist-fallback.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task";
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
// Source checkout (tests/dev)
try {
const mod = await import("../../../src/agents/pi-embedded-runner.js");
// oxlint-disable-next-line typescript/no-explicit-any
if (typeof (mod as any).runEmbeddedPiAgent === "function") {
// oxlint-disable-next-line typescript/no-explicit-any
return (mod as any).runEmbeddedPiAgent;
}
} catch {
// ignore
}
// Bundled install (built)
// NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint.
const distExtensionApi = "../../../dist/extensionAPI.js";
const mod = (await import(distExtensionApi)) as { runEmbeddedPiAgent?: unknown };
// oxlint-disable-next-line typescript/no-explicit-any
const fn = (mod as any).runEmbeddedPiAgent;
if (typeof fn !== "function") {
throw new Error("Internal error: runEmbeddedPiAgent not available");
}
return fn as RunEmbeddedPiAgentFn;
}
function stripCodeFences(s: string): string {
const trimmed = s.trim();
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
@ -209,8 +179,6 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
const sessionId = `llm-task-${Date.now()}`;
const sessionFile = path.join(tmpDir, "session.json");
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
const result = await runEmbeddedPiAgent({
sessionId,
sessionFile,

View File

@ -1,11 +1,9 @@
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
createActionGate,
createWhatsAppOutboundBase,
DEFAULT_ACCOUNT_ID,

View File

@ -1,5 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");

View File

@ -212,6 +212,7 @@
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
"default": "./dist/plugin-sdk/keyed-async-queue.js"
},
"./extension-api": "./dist/extensionAPI.js",
"./cli-entry": "./openclaw.mjs"
},
"scripts": {
@ -224,13 +225,13 @@
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
@ -245,6 +246,7 @@
"deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount",
"dev": "node scripts/run-node.mjs",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs",
"docs:check-links": "node scripts/docs-link-audit.mjs",
"docs:dev": "cd docs && mint dev",
"docs:list": "node scripts/docs-list.js",

View File

@ -0,0 +1,237 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const GLOSSARY_PATH = path.join(ROOT, "docs", ".i18n", "glossary.zh-CN.json");
const DOC_FILE_RE = /^docs\/(?!zh-CN\/).+\.(md|mdx)$/i;
const LIST_ITEM_LINK_RE = /^\s*(?:[-*]|\d+\.)\s+\[([^\]]+)\]\((\/[^)]+)\)/;
const MAX_TITLE_WORDS = 8;
const MAX_LABEL_WORDS = 6;
const MAX_TERM_LENGTH = 80;
/**
* @typedef {{
* file: string;
* line: number;
* kind: "title" | "link label";
* term: string;
* }} TermMatch
*/
function parseArgs(argv) {
/** @type {{ base: string; head: string }} */
const args = { base: "", head: "" };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--base") {
args.base = argv[i + 1] ?? "";
i += 1;
continue;
}
if (argv[i] === "--head") {
args.head = argv[i + 1] ?? "";
i += 1;
}
}
return args;
}
function runGit(args) {
return execFileSync("git", args, {
cwd: ROOT,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).trim();
}
function resolveBase(explicitBase) {
if (explicitBase) {
return explicitBase;
}
const envBase = process.env.DOCS_I18N_GLOSSARY_BASE?.trim();
if (envBase) {
return envBase;
}
for (const candidate of ["origin/main", "fork/main", "main"]) {
try {
return runGit(["merge-base", candidate, "HEAD"]);
} catch {
// Try the next candidate.
}
}
return "";
}
function listChangedDocs(base, head) {
const args = ["diff", "--name-only", "--diff-filter=ACMR", base];
if (head) {
args.push(head);
}
args.push("--", "docs");
return runGit(args)
.split("\n")
.map((line) => line.trim())
.filter((line) => DOC_FILE_RE.test(line));
}
function loadGlossarySources() {
const data = fs.readFileSync(GLOSSARY_PATH, "utf8");
const entries = JSON.parse(data);
return new Set(entries.map((entry) => String(entry.source || "").trim()).filter(Boolean));
}
function containsLatin(text) {
return /[A-Za-z]/.test(text);
}
function wordCount(text) {
return text.trim().split(/\s+/).filter(Boolean).length;
}
function unquoteScalar(raw) {
const value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1).trim();
}
return value;
}
function isGlossaryCandidate(term, maxWords) {
if (!term) {
return false;
}
if (!containsLatin(term)) {
return false;
}
if (term.includes("`")) {
return false;
}
if (term.length > MAX_TERM_LENGTH) {
return false;
}
return wordCount(term) <= maxWords;
}
function readGitFile(base, relPath) {
try {
return runGit(["show", `${base}:${relPath}`]);
} catch {
return "";
}
}
/**
* @param {string} file
* @param {string} text
* @returns {Map<string, TermMatch>}
*/
function extractTerms(file, text) {
/** @type {Map<string, TermMatch>} */
const terms = new Map();
const lines = text.split("\n");
if (lines[0]?.trim() === "---") {
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim() === "---") {
break;
}
const match = line.match(/^title:\s*(.+)\s*$/);
if (!match) {
continue;
}
const title = unquoteScalar(match[1]);
if (isGlossaryCandidate(title, MAX_TITLE_WORDS)) {
terms.set(title, { file, line: index + 1, kind: "title", term: title });
}
break;
}
}
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(LIST_ITEM_LINK_RE);
if (!match) {
continue;
}
const label = match[1].trim();
if (!isGlossaryCandidate(label, MAX_LABEL_WORDS)) {
continue;
}
if (!terms.has(label)) {
terms.set(label, { file, line: index + 1, kind: "link label", term: label });
}
}
return terms;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const base = resolveBase(args.base);
if (!base) {
console.warn(
"docs:check-i18n-glossary: no merge base found; skipping glossary coverage check.",
);
process.exit(0);
}
const changedDocs = listChangedDocs(base, args.head);
if (changedDocs.length === 0) {
process.exit(0);
}
const glossary = loadGlossarySources();
/** @type {TermMatch[]} */
const missing = [];
for (const relPath of changedDocs) {
const absPath = path.join(ROOT, relPath);
if (!fs.existsSync(absPath)) {
continue;
}
const currentTerms = extractTerms(relPath, fs.readFileSync(absPath, "utf8"));
const baseTerms = extractTerms(relPath, readGitFile(base, relPath));
for (const [term, match] of currentTerms) {
if (baseTerms.has(term)) {
continue;
}
if (glossary.has(term)) {
continue;
}
missing.push(match);
}
}
if (missing.length === 0) {
process.exit(0);
}
console.error("docs:check-i18n-glossary: missing zh-CN glossary entries for changed doc labels:");
for (const match of missing) {
console.error(`- ${match.file}:${match.line} ${match.kind} "${match.term}"`);
}
console.error("");
console.error(
"Add exact source terms to docs/.i18n/glossary.zh-CN.json before rerunning docs-i18n.",
);
console.error(`Checked changed English docs relative to ${base}.`);
process.exit(1);
}
main();

View File

@ -0,0 +1,3 @@
export function rewritePackageExtensions(entries: unknown): string[] | undefined;
export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void;

View File

@ -0,0 +1,129 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
export function rewritePackageExtensions(entries) {
if (!Array.isArray(entries)) {
return undefined;
}
return entries
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
.map((entry) => {
const normalized = entry.replace(/^\.\//, "");
const rewritten = normalized.replace(/\.[^.]+$/u, ".js");
return `./${rewritten}`;
});
}
function ensurePathInsideRoot(rootDir, rawPath) {
const resolved = path.resolve(rootDir, rawPath);
const relative = path.relative(rootDir, resolved);
if (
relative === "" ||
relative === "." ||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
) {
return resolved;
}
throw new Error(`path escapes plugin root: ${rawPath}`);
}
function copyDeclaredPluginSkillPaths(params) {
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
const copiedSkills = [];
for (const raw of skills) {
if (typeof raw !== "string" || raw.trim().length === 0) {
continue;
}
const normalized = raw.replace(/^\.\//u, "");
const sourcePath = ensurePathInsideRoot(params.pluginDir, raw);
if (!fs.existsSync(sourcePath)) {
// Some Docker/lightweight builds intentionally omit optional plugin-local
// dependencies. Only advertise skill paths that were actually bundled.
console.warn(
`[bundled-plugin-metadata] skipping missing skill path ${sourcePath} (plugin ${params.manifest.id ?? path.basename(params.pluginDir)})`,
);
continue;
}
const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.cpSync(sourcePath, targetPath, {
dereference: true,
force: true,
recursive: true,
});
copiedSkills.push(raw);
}
return copiedSkills;
}
export function copyBundledPluginMetadata(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const extensionsRoot = path.join(repoRoot, "extensions");
const distExtensionsRoot = path.join(repoRoot, "dist", "extensions");
if (!fs.existsSync(extensionsRoot)) {
return;
}
const sourcePluginDirs = new Set();
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
sourcePluginDirs.add(dirent.name);
const pluginDir = path.join(extensionsRoot, dirent.name);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json");
const distPackageJsonPath = path.join(distPluginDir, "package.json");
if (!fs.existsSync(manifestPath)) {
removeFileIfExists(distManifestPath);
removeFileIfExists(distPackageJsonPath);
continue;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir });
const bundledManifest = Array.isArray(manifest.skills)
? { ...manifest, skills: copiedSkills }
: manifest;
writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`);
const packageJsonPath = path.join(pluginDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
removeFileIfExists(distPackageJsonPath);
continue;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
if (packageJson.openclaw && "extensions" in packageJson.openclaw) {
packageJson.openclaw = {
...packageJson.openclaw,
extensions: rewritePackageExtensions(packageJson.openclaw.extensions),
};
}
writeTextFileIfChanged(distPackageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
}
if (!fs.existsSync(distExtensionsRoot)) {
return;
}
for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory() || sourcePluginDirs.has(dirent.name)) {
continue;
}
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json"));
removeFileIfExists(path.join(distPluginDir, "package.json"));
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
copyBundledPluginMetadata();
}

View File

@ -1,10 +1,16 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
export function copyPluginSdkRootAlias(params = {}) {
const cwd = params.cwd ?? process.cwd();
const source = resolve(cwd, "src/plugin-sdk/root-alias.cjs");
const target = resolve(cwd, "dist/plugin-sdk/root-alias.cjs");
const source = resolve("src/plugin-sdk/root-alias.cjs");
const target = resolve("dist/plugin-sdk/root-alias.cjs");
writeTextFileIfChanged(target, readFileSync(source, "utf8"));
}
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
copyPluginSdkRootAlias();
}

View File

@ -58,6 +58,11 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical Chinese; avoid slang or jokes.
- Use neutral documentation tone; prefer /你的, avoid /您的.
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.
- If a glossary target is identical to the source text, preserve that term in
English exactly as written.
- Insert a space between Latin characters and CJK text (W3C CLREQ), e.g., Gateway 网关, Skills 配置.
- Use Chinese quotation marks and for Chinese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys.
- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
@ -90,6 +95,11 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical Japanese; avoid slang or jokes.
- Use neutral documentation tone; avoid overly formal honorifics (e.g., avoid でございます).
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.
- If a glossary target is identical to the source text, preserve that term in
English exactly as written.
- Use Japanese quotation marks and for Japanese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys.
- Do not add or remove spacing around Latin text just because it borders Japanese; keep spacing stable unless required by Japanese grammar.
- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
@ -121,6 +131,11 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical language in the target language; avoid slang or jokes.
- Use neutral documentation tone.
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.
- If a glossary target is identical to the source text, preserve that term in
English exactly as written.
- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
- Keep these terms in English: Skills, local loopback, Tailscale.
- Never output an empty response; if unsure, return the source text unchanged.
@ -135,7 +150,7 @@ func buildGlossaryPrompt(glossary []GlossaryEntry) string {
return ""
}
var lines []string
lines = append(lines, "Preferred translations (use when natural):")
lines = append(lines, "Required terminology (use exactly when the source term matches):")
for _, entry := range glossary {
if entry.Source == "" || entry.Target == "" {
continue

View File

@ -200,13 +200,15 @@ function Ensure-Git {
}
function Install-OpenClawNpm {
param([string]$Version = "latest")
param([string]$Target = "latest")
$installSpec = Resolve-PackageInstallSpec -Target $Target
Write-Host "Installing OpenClaw (openclaw@$Version)..." -Level info
Write-Host "Installing OpenClaw ($installSpec)..." -Level info
try {
# Use -ExecutionPolicy Bypass to handle restricted execution policy
npm install -g openclaw@$Version --no-fund --no-audit 2>&1
npm install -g $installSpec --no-fund --no-audit 2>&1
Write-Host "OpenClaw installed" -Level success
return $true
} catch {
@ -257,6 +259,34 @@ node "%~dp0..\openclaw\dist\entry.js" %*
return $true
}
function Test-ExplicitPackageInstallSpec {
param([string]$Target)
if ([string]::IsNullOrWhiteSpace($Target)) {
return $false
}
return $Target.Contains("://") -or
$Target.Contains("#") -or
$Target -match '^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):'
}
function Resolve-PackageInstallSpec {
param([string]$Target = "latest")
$trimmed = $Target.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) {
return "openclaw@latest"
}
if ($trimmed.ToLowerInvariant() -eq "main") {
return "github:openclaw/openclaw#main"
}
if (Test-ExplicitPackageInstallSpec -Target $trimmed) {
return $trimmed
}
return "openclaw@$trimmed"
}
function Add-ToPath {
param([string]$Path)
@ -301,9 +331,9 @@ function Main {
}
if ($DryRun) {
Write-Host "[DRY RUN] Would install OpenClaw via npm (tag: $Tag)" -Level info
Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info
} else {
if (!(Install-OpenClawNpm -Version $Tag)) {
if (!(Install-OpenClawNpm -Target $Tag)) {
exit 1
}
}

View File

@ -1011,7 +1011,7 @@ Options:
--install-method, --method npm|git Install via npm (default) or from a git checkout
--npm Shortcut for --install-method npm
--git, --github Shortcut for --install-method git
--version <version|dist-tag> npm install: version (default: latest)
--version <version|dist-tag|spec> npm install target (default: latest; use "main" for GitHub main)
--beta Use beta if available, else latest
--git-dir, --dir <path> Checkout directory (default: ~/openclaw)
--no-git-update Skip git pull for existing checkout
@ -1024,7 +1024,7 @@ Options:
Environment variables:
OPENCLAW_INSTALL_METHOD=git|npm
OPENCLAW_VERSION=latest|next|<semver>
OPENCLAW_VERSION=latest|next|main|<semver>|<spec>
OPENCLAW_BETA=0|1
OPENCLAW_GIT_DIR=...
OPENCLAW_GIT_UPDATE=0|1
@ -1040,6 +1040,7 @@ Examples:
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --verify
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --no-onboard
EOF
}
@ -1963,6 +1964,43 @@ resolve_beta_version() {
echo "$beta"
}
is_explicit_package_install_spec() {
local value="${1:-}"
[[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]]
}
can_resolve_registry_package_version() {
local value="${1:-}"
if [[ -z "$value" ]]; then
return 0
fi
if [[ "${value,,}" == "main" ]]; then
return 1
fi
if is_explicit_package_install_spec "$value"; then
return 1
fi
return 0
}
resolve_package_install_spec() {
local package_name="$1"
local value="$2"
if [[ "${value,,}" == "main" ]]; then
echo "github:openclaw/openclaw#main"
return 0
fi
if is_explicit_package_install_spec "$value"; then
echo "$value"
return 0
fi
if [[ "$value" == "latest" ]]; then
echo "${package_name}@latest"
return 0
fi
echo "${package_name}@${value}"
}
install_openclaw() {
local package_name="openclaw"
if [[ "$USE_BETA" == "1" ]]; then
@ -1983,18 +2021,16 @@ install_openclaw() {
fi
local resolved_version=""
resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)"
if can_resolve_registry_package_version "${OPENCLAW_VERSION}"; then
resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)"
fi
if [[ -n "$resolved_version" ]]; then
ui_info "Installing OpenClaw v${resolved_version}"
else
ui_info "Installing OpenClaw (${OPENCLAW_VERSION})"
fi
local install_spec=""
if [[ "${OPENCLAW_VERSION}" == "latest" ]]; then
install_spec="${package_name}@latest"
else
install_spec="${package_name}@${OPENCLAW_VERSION}"
fi
install_spec="$(resolve_package_install_spec "${package_name}" "${OPENCLAW_VERSION}")"
if ! install_openclaw_npm "${install_spec}"; then
ui_warn "npm install failed; retrying"

View File

@ -15,7 +15,7 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
type PackFile = { path: string };
type PackResult = { files?: PackFile[] };
type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number };
const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"],
@ -112,6 +112,10 @@ const requiredPathGroups = [
"dist/build-info.json",
];
const forbiddenPrefixes = ["dist/OpenClaw.app/"];
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
// startup/doctor OOM reports. Keep enough headroom for the current pack while
// failing fast if duplicate/shim content sneaks back into the release artifact.
const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024;
const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
const laneFloorAdoptionDateKey = 20260227;
@ -228,6 +232,50 @@ export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
.toSorted();
}
function formatMiB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function resolvePackResultLabel(entry: PackResult, index: number): string {
return entry.filename?.trim() || `pack result #${index + 1}`;
}
function formatPackUnpackedSizeBudgetError(params: {
label: string;
unpackedSize: number;
}): string {
return [
`${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`,
"Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
].join(" ");
}
export function collectPackUnpackedSizeErrors(results: Iterable<PackResult>): string[] {
const entries = Array.from(results);
const errors: string[] = [];
let checkedCount = 0;
for (const [index, entry] of entries.entries()) {
if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) {
continue;
}
checkedCount += 1;
if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) {
continue;
}
const label = resolvePackResultLabel(entry, index);
errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize }));
}
if (entries.length > 0 && checkedCount === 0) {
errors.push(
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
);
}
return errors;
}
function checkPluginVersions() {
const rootPackagePath = resolve("package.json");
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson;
@ -433,8 +481,9 @@ function main() {
})
.toSorted();
const forbidden = collectForbiddenPackPaths(paths);
const sizeErrors = collectPackUnpackedSizeErrors(results);
if (missing.length > 0 || forbidden.length > 0) {
if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) {
if (missing.length > 0) {
console.error("release-check: missing files in npm pack:");
for (const path of missing) {
@ -447,6 +496,12 @@ function main() {
console.error(` - ${path}`);
}
}
if (sizeErrors.length > 0) {
console.error("release-check: npm pack unpacked size budget exceeded:");
for (const error of sizeErrors) {
console.error(` - ${error}`);
}
}
process.exit(1);
}

View File

@ -1,4 +1,6 @@
export const runNodeWatchedPaths: string[];
export function isBuildRelevantRunNodePath(repoPath: string): boolean;
export function isRestartRelevantRunNodePath(repoPath: string): boolean;
export function runNodeMain(params?: {
spawn?: (

View File

@ -4,11 +4,68 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { runRuntimePostBuild } from "./runtime-postbuild.mjs";
const compiler = "tsdown";
const compilerArgs = ["exec", compiler, "--no-clean"];
export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"];
const runNodeSourceRoots = ["src", "extensions"];
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const isIgnoredSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
};
const isBuildRelevantSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath);
};
export const isBuildRelevantRunNodePath = (repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith("extensions/")) {
return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length));
}
return false;
};
const isRestartRelevantExtensionPath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
return true;
}
return isBuildRelevantSourcePath(normalizedPath);
};
export const isRestartRelevantRunNodePath = (repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith("extensions/")) {
return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length));
}
return false;
};
const statMtime = (filePath, fsImpl = fs) => {
try {
@ -18,16 +75,12 @@ const statMtime = (filePath, fsImpl = fs) => {
}
};
const isExcludedSource = (filePath, srcRoot) => {
const relativePath = path.relative(srcRoot, filePath);
const isExcludedSource = (filePath, sourceRoot, sourceRootName) => {
const relativePath = normalizePath(path.relative(sourceRoot, filePath));
if (relativePath.startsWith("..")) {
return false;
}
return (
relativePath.endsWith(".test.ts") ||
relativePath.endsWith(".test.tsx") ||
relativePath.endsWith(`test-helpers.ts`)
);
return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath));
};
const findLatestMtime = (dirPath, shouldSkip, deps) => {
@ -89,15 +142,39 @@ const resolveGitHead = (deps) => {
return head || null;
};
const readGitStatus = (deps) => {
try {
const result = deps.spawnSync(
"git",
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
{
cwd: deps.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
},
);
if (result.status !== 0) {
return null;
}
return result.stdout ?? "";
} catch {
return null;
}
};
const parseGitStatusPaths = (output) =>
output
.split("\n")
.flatMap((line) => line.slice(3).split(" -> "))
.map((entry) => normalizePath(entry.trim()))
.filter(Boolean);
const hasDirtySourceTree = (deps) => {
const output = runGit(
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
deps,
);
const output = readGitStatus(deps);
if (output === null) {
return null;
}
return output.length > 0;
return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath));
};
const readBuildStamp = (deps) => {
@ -119,12 +196,18 @@ const readBuildStamp = (deps) => {
};
const hasSourceMtimeChanged = (stampMtime, deps) => {
const srcMtime = findLatestMtime(
deps.srcRoot,
(candidate) => isExcludedSource(candidate, deps.srcRoot),
deps,
);
return srcMtime != null && srcMtime > stampMtime;
let latestSourceMtime = null;
for (const sourceRoot of deps.sourceRoots) {
const sourceMtime = findLatestMtime(
sourceRoot.path,
(candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name),
deps,
);
if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) {
latestSourceMtime = sourceMtime;
}
}
return latestSourceMtime != null && latestSourceMtime > stampMtime;
};
const shouldBuild = (deps) => {
@ -193,6 +276,19 @@ const runOpenClaw = async (deps) => {
return res.exitCode ?? 1;
};
const syncRuntimeArtifacts = (deps) => {
try {
runRuntimePostBuild({ cwd: deps.cwd });
} catch (error) {
logRunner(
`Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`,
deps,
);
return false;
}
return true;
};
const writeBuildStamp = (deps) => {
try {
deps.fs.mkdirSync(deps.distRoot, { recursive: true });
@ -223,10 +319,16 @@ export async function runNodeMain(params = {}) {
deps.distRoot = path.join(deps.cwd, "dist");
deps.distEntry = path.join(deps.distRoot, "/entry.js");
deps.buildStampPath = path.join(deps.distRoot, ".buildstamp");
deps.srcRoot = path.join(deps.cwd, "src");
deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")];
deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({
name: sourceRoot,
path: path.join(deps.cwd, sourceRoot),
}));
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
if (!shouldBuild(deps)) {
if (!syncRuntimeArtifacts(deps)) {
return 1;
}
return await runOpenClaw(deps);
}
@ -249,6 +351,9 @@ export async function runNodeMain(params = {}) {
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return buildRes.exitCode;
}
if (!syncRuntimeArtifacts(deps)) {
return 1;
}
writeBuildStamp(deps);
return await runOpenClaw(deps);
}

View File

@ -0,0 +1,26 @@
import fs from "node:fs";
import { dirname } from "node:path";
export function writeTextFileIfChanged(filePath, contents) {
const next = String(contents);
try {
const current = fs.readFileSync(filePath, "utf8");
if (current === next) {
return false;
}
} catch {
// Write the file when it does not exist or cannot be read.
}
fs.mkdirSync(dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, next, "utf8");
return true;
}
export function removeFileIfExists(filePath) {
try {
fs.rmSync(filePath, { force: true });
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,12 @@
import { pathToFileURL } from "node:url";
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
export function runRuntimePostBuild(params = {}) {
copyPluginSdkRootAlias(params);
copyBundledPluginMetadata(params);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
runRuntimePostBuild();
}

View File

@ -1,26 +1,32 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import chokidar from "chokidar";
import { runNodeWatchedPaths } from "./run-node.mjs";
import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs";
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const normalizePath = (filePath) =>
String(filePath ?? "")
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
const isIgnoredWatchPath = (filePath) => {
const normalizedPath = normalizePath(filePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
const resolveRepoPath = (filePath, cwd) => {
const rawPath = String(filePath ?? "");
if (path.isAbsolute(rawPath)) {
return normalizePath(path.relative(cwd, rawPath));
}
return normalizePath(rawPath);
};
const isIgnoredWatchPath = (filePath, cwd) =>
!isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd));
export async function runWatchMain(params = {}) {
const deps = {
spawn: params.spawn ?? spawn,
@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) {
const watcher = deps.createWatcher(deps.watchPaths, {
ignoreInitial: true,
ignored: (watchPath) => isIgnoredWatchPath(watchPath),
ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd),
});
const settle = (code) => {
@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) {
};
const requestRestart = (changedPath) => {
if (shuttingDown || isIgnoredWatchPath(changedPath)) {
if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) {
return;
}
if (!watchProcess) {

View File

@ -295,6 +295,17 @@ describe("normalizeModelCompat", () => {
expect(supportsUsageInStreaming(normalized)).toBe(true);
});
it("preserves explicit supportsUsageInStreaming false on non-native endpoints", () => {
const model = {
...baseModel(),
provider: "custom-cpa",
baseUrl: "https://proxy.example.com/v1",
compat: { supportsUsageInStreaming: false },
};
const normalized = normalizeModelCompat(model);
expect(supportsUsageInStreaming(normalized)).toBe(false);
});
it("still forces flags off when not explicitly set by user", () => {
const model = {
...baseModel(),
@ -348,6 +359,32 @@ describe("normalizeModelCompat", () => {
expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
});
it("leaves fully explicit non-native compat untouched", () => {
const model = baseModel();
model.baseUrl = "https://proxy.example.com/v1";
model.compat = {
supportsDeveloperRole: false,
supportsUsageInStreaming: true,
supportsStrictMode: true,
};
const normalized = normalizeModelCompat(model);
expect(normalized).toBe(model);
});
it("preserves explicit usage compat when developer role is explicitly enabled", () => {
const model = baseModel();
model.baseUrl = "https://proxy.example.com/v1";
model.compat = {
supportsDeveloperRole: true,
supportsUsageInStreaming: true,
supportsStrictMode: true,
};
const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(true);
expect(supportsUsageInStreaming(normalized)).toBe(true);
expect(supportsStrictMode(normalized)).toBe(true);
});
});
describe("isModernModelRef", () => {

View File

@ -66,11 +66,11 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
return model;
}
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
const hasStreamingUsageOverride = compat?.supportsUsageInStreaming !== undefined;
const targetStrictMode = compat?.supportsStrictMode ?? false;
if (
compat?.supportsDeveloperRole !== undefined &&
compat?.supportsUsageInStreaming !== undefined &&
hasStreamingUsageOverride &&
compat?.supportsStrictMode !== undefined
) {
return model;
@ -83,7 +83,7 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
? {
...compat,
supportsDeveloperRole: forcedDeveloperRole || false,
supportsUsageInStreaming: forcedUsageStreaming || false,
...(hasStreamingUsageOverride ? {} : { supportsUsageInStreaming: false }),
supportsStrictMode: targetStrictMode,
}
: {

View File

@ -6,6 +6,7 @@ import {
type ExistingProviderConfig,
} from "./models-config.merge.js";
import {
applyNativeStreamingUsageCompat,
enforceSourceManagedProviderSecrets,
normalizeProviders,
resolveImplicitProviders,
@ -126,7 +127,8 @@ export async function planOpenClawModelsJson(params: {
sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
secretRefManagedProviders,
}) ?? mergedProviders;
const nextContents = `${JSON.stringify({ providers: secretEnforcedProviders }, null, 2)}\n`;
const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders);
const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`;
if (params.existingRaw === nextContents) {
return { action: "noop" };

View File

@ -1,32 +1,36 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
import { buildModelStudioProvider } from "./models-config.providers.js";
const modelStudioApiKeyEnv = ["MODELSTUDIO_API", "KEY"].join("_");
import {
applyNativeStreamingUsageCompat,
buildModelStudioProvider,
} from "./models-config.providers.js";
describe("Model Studio implicit provider", () => {
it("should include modelstudio when MODELSTUDIO_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const modelStudioApiKey = "test-key"; // pragma: allowlist secret
await withEnvAsync({ [modelStudioApiKeyEnv]: modelStudioApiKey }, async () => {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.modelstudio).toBeDefined();
expect(providers?.modelstudio?.apiKey).toBe("MODELSTUDIO_API_KEY");
expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1");
it("should opt native Model Studio baseUrls into streaming usage", () => {
const providers = applyNativeStreamingUsageCompat({
modelstudio: buildModelStudioProvider(),
});
expect(providers?.modelstudio).toBeDefined();
expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1");
expect(
providers?.modelstudio?.models?.every(
(model) => model.compat?.supportsUsageInStreaming === true,
),
).toBe(true);
});
it("should build the static Model Studio provider catalog", () => {
const provider = buildModelStudioProvider();
const modelIds = provider.models.map((model) => model.id);
expect(provider.api).toBe("openai-completions");
expect(provider.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1");
expect(modelIds).toContain("qwen3.5-plus");
expect(modelIds).toContain("qwen3-coder-plus");
expect(modelIds).toContain("kimi-k2.5");
it("should keep streaming usage opt-in disabled for custom Model Studio-compatible baseUrls", () => {
const providers = applyNativeStreamingUsageCompat({
modelstudio: {
...buildModelStudioProvider(),
baseUrl: "https://proxy.example.com/v1",
},
});
expect(providers?.modelstudio).toBeDefined();
expect(providers?.modelstudio?.baseUrl).toBe("https://proxy.example.com/v1");
expect(
providers?.modelstudio?.models?.some(
(model) => model.compat?.supportsUsageInStreaming === true,
),
).toBe(false);
});
});

View File

@ -7,7 +7,11 @@ import {
MOONSHOT_CN_BASE_URL,
} from "../commands/onboard-auth.models.js";
import { captureEnv } from "../test-utils/env.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import {
applyNativeStreamingUsageCompat,
resolveImplicitProviders,
} from "./models-config.providers.js";
import { buildMoonshotProvider } from "./models-config.providers.static.js";
describe("moonshot implicit provider (#33637)", () => {
it("uses explicit CN baseUrl when provided", async () => {
@ -39,6 +43,31 @@ describe("moonshot implicit provider (#33637)", () => {
expect(providers?.moonshot).toBeDefined();
expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_CN_BASE_URL);
expect(providers?.moonshot?.apiKey).toBeDefined();
expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
it("keeps streaming usage opt-in unset before the final compat pass", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["MOONSHOT_API_KEY"]);
process.env.MOONSHOT_API_KEY = "sk-test-custom";
try {
const providers = await resolveImplicitProviders({
agentDir,
explicitProviders: {
moonshot: {
baseUrl: "https://proxy.example.com/v1",
api: "openai-completions",
models: [],
},
},
});
expect(providers?.moonshot).toBeDefined();
expect(providers?.moonshot?.baseUrl).toBe("https://proxy.example.com/v1");
expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
} finally {
envSnapshot.restore();
}
@ -53,8 +82,32 @@ describe("moonshot implicit provider (#33637)", () => {
const providers = await resolveImplicitProviders({ agentDir });
expect(providers?.moonshot).toBeDefined();
expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_AI_BASE_URL);
expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
it("opts native Moonshot baseUrls into streaming usage only after the final compat pass", () => {
const defaultProviders = applyNativeStreamingUsageCompat({
moonshot: buildMoonshotProvider(),
});
expect(defaultProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true);
const cnProviders = applyNativeStreamingUsageCompat({
moonshot: {
...buildMoonshotProvider(),
baseUrl: MOONSHOT_CN_BASE_URL,
},
});
expect(cnProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true);
const customProviders = applyNativeStreamingUsageCompat({
moonshot: {
...buildMoonshotProvider(),
baseUrl: "https://proxy.example.com/v1",
},
});
expect(customProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined();
});
});

View File

@ -81,6 +81,15 @@ type SecretDefaults = {
exec?: string;
};
const MOONSHOT_NATIVE_BASE_URLS = new Set([
"https://api.moonshot.ai/v1",
"https://api.moonshot.cn/v1",
]);
const MODELSTUDIO_NATIVE_BASE_URLS = new Set([
"https://coding-intl.dashscope.aliyuncs.com/v1",
"https://coding.dashscope.aliyuncs.com/v1",
]);
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
function normalizeApiKeyConfig(value: string): string {
@ -89,6 +98,65 @@ function normalizeApiKeyConfig(value: string): string {
return match?.[1] ?? trimmed;
}
function normalizeProviderBaseUrl(baseUrl: string | undefined): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return "";
}
try {
const url = new URL(trimmed);
url.hash = "";
url.search = "";
return url.toString().replace(/\/+$/, "").toLowerCase();
} catch {
return trimmed.replace(/\/+$/, "").toLowerCase();
}
}
function withStreamingUsageCompat(provider: ProviderConfig): ProviderConfig {
if (!Array.isArray(provider.models) || provider.models.length === 0) {
return provider;
}
let changed = false;
const models = provider.models.map((model) => {
if (model.compat?.supportsUsageInStreaming !== undefined) {
return model;
}
changed = true;
return {
...model,
compat: {
...model.compat,
supportsUsageInStreaming: true,
},
};
});
return changed ? { ...provider, models } : provider;
}
export function applyNativeStreamingUsageCompat(
providers: Record<string, ProviderConfig>,
): Record<string, ProviderConfig> {
let changed = false;
const nextProviders: Record<string, ProviderConfig> = {};
for (const [providerKey, provider] of Object.entries(providers)) {
const normalizedBaseUrl = normalizeProviderBaseUrl(provider.baseUrl);
const isNativeMoonshot =
providerKey === "moonshot" && MOONSHOT_NATIVE_BASE_URLS.has(normalizedBaseUrl);
const isNativeModelStudio =
providerKey === "modelstudio" && MODELSTUDIO_NATIVE_BASE_URLS.has(normalizedBaseUrl);
const nextProvider =
isNativeMoonshot || isNativeModelStudio ? withStreamingUsageCompat(provider) : provider;
nextProviders[providerKey] = nextProvider;
changed ||= nextProvider !== provider;
}
return changed ? nextProviders : providers;
}
function resolveEnvApiKeyVarName(
provider: string,
env: NodeJS.ProcessEnv = process.env,
@ -684,7 +752,16 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [
apiKey,
})),
withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })),
withApiKey("modelstudio", async ({ apiKey }) => ({ ...buildModelStudioProvider(), apiKey })),
withApiKey("modelstudio", async ({ apiKey, explicitProvider }) => {
const explicitBaseUrl = explicitProvider?.baseUrl;
return {
...buildModelStudioProvider(),
...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim()
? { baseUrl: explicitBaseUrl.trim() }
: {}),
apiKey,
};
}),
withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })),
withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })),
withApiKey("kilocode", async ({ apiKey }) => ({

View File

@ -0,0 +1,52 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { getCompletionScript } from "./completion-cli.js";
function createCompletionProgram(): Command {
const program = new Command();
program.name("openclaw");
program.description("CLI root");
program.option("-v, --verbose", "Verbose output");
const gateway = program.command("gateway").description("Gateway commands");
gateway.option("--force", "Force the action");
gateway.command("status").description("Show gateway status").option("--json", "JSON output");
gateway.command("restart").description("Restart gateway");
return program;
}
describe("completion-cli", () => {
it("generates zsh functions for nested subcommands", () => {
const script = getCompletionScript("zsh", createCompletionProgram());
expect(script).toContain("_openclaw_gateway()");
expect(script).toContain("(status) _openclaw_gateway_status ;;");
expect(script).toContain("(restart) _openclaw_gateway_restart ;;");
expect(script).toContain("--force[Force the action]");
});
it("generates PowerShell command paths without the executable prefix", () => {
const script = getCompletionScript("powershell", createCompletionProgram());
expect(script).toContain("if ($commandPath -eq 'gateway') {");
expect(script).toContain("if ($commandPath -eq 'gateway status') {");
expect(script).not.toContain("if ($commandPath -eq 'openclaw gateway') {");
expect(script).toContain("$completions = @('status','restart','--force')");
});
it("generates fish completions for root and nested command contexts", () => {
const script = getCompletionScript("fish", createCompletionProgram());
expect(script).toContain(
'complete -c openclaw -n "__fish_use_subcommand" -a "gateway" -d \'Gateway commands\'',
);
expect(script).toContain(
'complete -c openclaw -n "__fish_seen_subcommand_from gateway" -a "status" -d \'Show gateway status\'',
);
expect(script).toContain(
"complete -c openclaw -n \"__fish_seen_subcommand_from gateway\" -l force -d 'Force the action'",
);
});
});

View File

@ -69,7 +69,7 @@ export async function completionCacheExists(
return pathExists(cachePath);
}
function getCompletionScript(shell: CompletionShell, program: Command): string {
export function getCompletionScript(shell: CompletionShell, program: Command): string {
if (shell === "zsh") {
return generateZshCompletion(program);
}
@ -442,17 +442,19 @@ function generateZshSubcmdList(cmd: Command): string {
}
function generateZshSubcommands(program: Command, prefix: string): string {
let script = "";
for (const cmd of program.commands) {
const cmdName = cmd.name();
const funcName = `_${prefix}_${cmdName.replace(/-/g, "_")}`;
const segments: string[] = [];
// Recurse first
script += generateZshSubcommands(cmd, `${prefix}_${cmdName.replace(/-/g, "_")}`);
const visit = (current: Command, currentPrefix: string) => {
for (const cmd of current.commands) {
const cmdName = cmd.name();
const nextPrefix = `${currentPrefix}_${cmdName.replace(/-/g, "_")}`;
const funcName = `_${nextPrefix}`;
const subCommands = cmd.commands;
if (subCommands.length > 0) {
script += `
visit(cmd, nextPrefix);
const subCommands = cmd.commands;
if (subCommands.length > 0) {
segments.push(`
${funcName}() {
local -a commands
local -a options
@ -470,17 +472,21 @@ ${funcName}() {
;;
esac
}
`;
} else {
script += `
`);
continue;
}
segments.push(`
${funcName}() {
_arguments -C \\
${generateZshArgs(cmd)}
}
`;
`);
}
}
return script;
};
visit(program, prefix);
return segments.join("");
}
function generateBashCompletion(program: Command): string {
@ -528,38 +534,34 @@ function generateBashSubcommand(cmd: Command): string {
function generatePowerShellCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [];
const visit = (cmd: Command, parents: string[]): string => {
const cmdName = cmd.name();
const fullPath = [...parents, cmdName].join(" ");
let script = "";
const visit = (cmd: Command, pathSegments: string[]) => {
const fullPath = pathSegments.join(" ");
// Command completion for this level
const subCommands = cmd.commands.map((c) => c.name());
const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
if (allCompletions.length > 0) {
script += `
if (fullPath.length > 0 && allCompletions.length > 0) {
segments.push(`
if ($commandPath -eq '${fullPath}') {
$completions = @(${allCompletions})
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
}
}
`;
`);
}
// Recurse
for (const sub of cmd.commands) {
script += visit(sub, [...parents, cmdName]);
visit(sub, [...pathSegments, sub.name()]);
}
return script;
};
const rootBody = visit(program, []);
visit(program, []);
const rootBody = segments.join("");
return `
Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
@ -593,65 +595,57 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
function generateFishCompletion(program: Command): string {
const rootCmd = program.name();
let script = "";
const segments: string[] = [];
const visit = (cmd: Command, parents: string[]) => {
const cmdName = cmd.name();
const fullPath = [...parents];
if (parents.length > 0) {
fullPath.push(cmdName);
} // Only push if not root, or consistent root handling
// Fish uses 'seen_subcommand_from' to determine context.
// For root: complete -c openclaw -n "__fish_use_subcommand" -a "subcmd" -d "desc"
// Root logic
if (parents.length === 0) {
// Subcommands of root
for (const sub of cmd.commands) {
script += buildFishSubcommandCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
name: sub.name(),
description: sub.description(),
});
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
name: sub.name(),
description: sub.description(),
}),
);
}
// Options of root
for (const opt of cmd.options) {
script += buildFishOptionCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
flags: opt.flags,
description: opt.description,
});
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
flags: opt.flags,
description: opt.description,
}),
);
}
} else {
// Nested commands
// Logic: if seen subcommand matches parents...
// But fish completion logic is simpler if we just say "if we haven't seen THIS command yet but seen parent"
// Actually, a robust fish completion often requires defining a function to check current line.
// For simplicity, we'll assume standard fish helper __fish_seen_subcommand_from.
// To properly scope to 'openclaw gateway' and not 'openclaw other gateway', we need to check the sequence.
// A simplified approach:
// Subcommands
for (const sub of cmd.commands) {
script += buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
name: sub.name(),
description: sub.description(),
});
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
name: sub.name(),
description: sub.description(),
}),
);
}
// Options
for (const opt of cmd.options) {
script += buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
flags: opt.flags,
description: opt.description,
});
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
flags: opt.flags,
description: opt.description,
}),
);
}
}
@ -661,5 +655,5 @@ function generateFishCompletion(program: Command): string {
};
visit(program, []);
return script;
return segments.join("");
}

View File

@ -9,8 +9,8 @@ const runtime = {
exit: vi.fn(),
};
vi.mock("../../commands/auth-choice-options.js", () => ({
formatAuthChoiceChoicesForCli: () => "token|oauth",
vi.mock("../../commands/auth-choice-options.static.js", () => ({
formatStaticAuthChoiceChoicesForCli: () => "token|oauth",
}));
vi.mock("../../commands/onboard-provider-auth-flags.js", () => ({

View File

@ -1,5 +1,5 @@
import type { Command } from "commander";
import { formatAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.js";
import { formatStaticAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.static.js";
import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js";
import { ONBOARD_PROVIDER_AUTH_FLAGS } from "../../commands/onboard-provider-auth-flags.js";
import type {
@ -41,7 +41,7 @@ function resolveInstallDaemonFlag(
return undefined;
}
const AUTH_CHOICE_HELP = formatAuthChoiceChoicesForCli({
const AUTH_CHOICE_HELP = formatStaticAuthChoiceChoicesForCli({
includeLegacyAliases: true,
includeSkip: true,
});

View File

@ -549,6 +549,48 @@ describe("update-cli", () => {
);
});
it("maps --tag main to the GitHub main package spec for package updates", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
await updateCommand({ yes: true, tag: "main" });
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[
"npm",
"i",
"-g",
"github:openclaw/openclaw#main",
"--no-fund",
"--no-audit",
"--loglevel=error",
],
expect.any(Object),
);
});
it("passes explicit git package specs through for package updates", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" });
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[
"npm",
"i",
"-g",
"github:openclaw/openclaw#main",
"--no-fund",
"--no-audit",
"--loglevel=error",
],
expect.any(Object),
);
});
it("updateCommand outputs JSON when --json is set", async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(defaultRuntime.log).mockClear();

View File

@ -39,7 +39,10 @@ export function registerUpdateCli(program: Command) {
.option("--no-restart", "Skip restarting the gateway service after a successful update")
.option("--dry-run", "Preview update actions without making changes", false)
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option(
"--tag <dist-tag|version|spec>",
"Override the package target for this update (dist-tag, version, or package spec)",
)
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
.addHelpText("after", () => {
@ -48,6 +51,7 @@ export function registerUpdateCli(program: Command) {
["openclaw update --channel beta", "Switch to beta channel (git + npm)"],
["openclaw update --channel dev", "Switch to dev channel (git + npm)"],
["openclaw update --tag beta", "One-off update to a dist-tag or version"],
["openclaw update --tag main", "One-off package install from GitHub main"],
["openclaw update --dry-run", "Preview actions without changing anything"],
["openclaw update --no-restart", "Update without restarting the service"],
["openclaw update --json", "Output result as JSON"],
@ -66,7 +70,7 @@ ${theme.heading("What this does:")}
${theme.heading("Switch channels:")}
- Use --channel stable|beta|dev to persist the update channel in config
- Run openclaw update status to see the active channel and source
- Use --tag <dist-tag|version> for a one-off npm update without persisting
- Use --tag <dist-tag|version|spec> for a one-off package update without persisting
${theme.heading("Non-interactive:")}
- Use --yes to accept downgrade prompts

View File

@ -10,6 +10,7 @@ import { trimLogTail } from "../../infra/restart-sentinel.js";
import { parseSemver } from "../../infra/runtime-guard.js";
import { fetchNpmTagVersion } from "../../infra/update-check.js";
import {
canResolveRegistryVersionForPackageTarget,
detectGlobalInstallManagerByPresence,
detectGlobalInstallManagerForRoot,
type CommandRunner,
@ -77,6 +78,9 @@ export async function resolveTargetVersion(
tag: string,
timeoutMs?: number,
): Promise<string | null> {
if (!canResolveRegistryVersionForPackageTarget(tag)) {
return null;
}
const direct = normalizeVersionTag(tag);
if (direct) {
return direct;

View File

@ -24,6 +24,7 @@ import {
checkUpdateStatus,
} from "../../infra/update-check.js";
import {
canResolveRegistryVersionForPackageTarget,
createGlobalInstallEnv,
cleanupGlobalRenameDirs,
globalInstallArgs,
@ -731,22 +732,31 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let targetVersion: string | null = null;
let downgradeRisk = false;
let fallbackToLatest = false;
let packageInstallSpec: string | null = null;
if (updateInstallKind !== "git") {
currentVersion = switchToPackage ? null : await readPackageVersion(root);
targetVersion = explicitTag
? await resolveTargetVersion(tag, timeoutMs)
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
tag = resolved.tag;
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
return resolved.version;
});
if (explicitTag) {
targetVersion = await resolveTargetVersion(tag, timeoutMs);
} else {
targetVersion = await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
tag = resolved.tag;
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
return resolved.version;
});
}
const cmp =
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
downgradeRisk =
canResolveRegistryVersionForPackageTarget(tag) &&
!fallbackToLatest &&
currentVersion != null &&
(targetVersion == null || (cmp != null && cmp > 0));
packageInstallSpec = resolveGlobalInstallSpec({
packageName: DEFAULT_PACKAGE_NAME,
tag,
env: process.env,
});
}
if (opts.dryRun) {
@ -772,7 +782,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
} else if (updateInstallKind === "git") {
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
} else {
actions.push(`Run global package manager update with spec openclaw@${tag}`);
actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`);
}
actions.push("Run plugin update sync after core update");
actions.push("Refresh shell completion cache (if needed)");
@ -789,6 +799,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
if (fallbackToLatest) {
notes.push("Beta channel resolves to latest for this run (fallback).");
}
if (explicitTag && !canResolveRegistryVersionForPackageTarget(tag)) {
notes.push("Non-registry package specs skip npm version lookup and downgrade previews.");
}
printDryRunPreview(
{
@ -803,7 +816,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
requestedChannel,
storedChannel,
effectiveChannel: channel,
tag,
tag: packageInstallSpec ?? tag,
currentVersion,
targetVersion,
downgradeRisk,

View File

@ -0,0 +1,332 @@
import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js";
import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js";
import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js";
export type { AuthChoiceGroupId };
export type AuthChoiceOption = {
value: AuthChoice;
label: string;
hint?: string;
};
export type AuthChoiceGroup = {
value: AuthChoiceGroupId;
label: string;
hint?: string;
options: AuthChoiceOption[];
};
export const AUTH_CHOICE_GROUP_DEFS: {
value: AuthChoiceGroupId;
label: string;
hint?: string;
choices: AuthChoice[];
}[] = [
{
value: "openai",
label: "OpenAI",
hint: "Codex OAuth + API key",
choices: ["openai-codex", "openai-api-key"],
},
{
value: "anthropic",
label: "Anthropic",
hint: "setup-token + API key",
choices: ["token", "apiKey"],
},
{
value: "chutes",
label: "Chutes",
hint: "OAuth",
choices: ["chutes"],
},
{
value: "minimax",
label: "MiniMax",
hint: "M2.5 (recommended)",
choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"],
},
{
value: "moonshot",
label: "Moonshot AI (Kimi K2.5)",
hint: "Kimi K2.5 + Kimi Coding",
choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"],
},
{
value: "google",
label: "Google",
hint: "Gemini API key + OAuth",
choices: ["gemini-api-key", "google-gemini-cli"],
},
{
value: "xai",
label: "xAI (Grok)",
hint: "API key",
choices: ["xai-api-key"],
},
{
value: "mistral",
label: "Mistral AI",
hint: "API key",
choices: ["mistral-api-key"],
},
{
value: "volcengine",
label: "Volcano Engine",
hint: "API key",
choices: ["volcengine-api-key"],
},
{
value: "byteplus",
label: "BytePlus",
hint: "API key",
choices: ["byteplus-api-key"],
},
{
value: "openrouter",
label: "OpenRouter",
hint: "API key",
choices: ["openrouter-api-key"],
},
{
value: "kilocode",
label: "Kilo Gateway",
hint: "API key (OpenRouter-compatible)",
choices: ["kilocode-api-key"],
},
{
value: "qwen",
label: "Qwen",
hint: "OAuth",
choices: ["qwen-portal"],
},
{
value: "zai",
label: "Z.AI",
hint: "GLM Coding Plan / Global / CN",
choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"],
},
{
value: "qianfan",
label: "Qianfan",
hint: "API key",
choices: ["qianfan-api-key"],
},
{
value: "modelstudio",
label: "Alibaba Cloud Model Studio",
hint: "Coding Plan API key (CN / Global)",
choices: ["modelstudio-api-key-cn", "modelstudio-api-key"],
},
{
value: "copilot",
label: "Copilot",
hint: "GitHub + local proxy",
choices: ["github-copilot", "copilot-proxy"],
},
{
value: "ai-gateway",
label: "Vercel AI Gateway",
hint: "API key",
choices: ["ai-gateway-api-key"],
},
{
value: "opencode",
label: "OpenCode",
hint: "Shared API key for Zen + Go catalogs",
choices: ["opencode-zen", "opencode-go"],
},
{
value: "xiaomi",
label: "Xiaomi",
hint: "API key",
choices: ["xiaomi-api-key"],
},
{
value: "synthetic",
label: "Synthetic",
hint: "Anthropic-compatible (multi-model)",
choices: ["synthetic-api-key"],
},
{
value: "together",
label: "Together AI",
hint: "API key",
choices: ["together-api-key"],
},
{
value: "huggingface",
label: "Hugging Face",
hint: "Inference API (HF token)",
choices: ["huggingface-api-key"],
},
{
value: "venice",
label: "Venice AI",
hint: "Privacy-focused (uncensored models)",
choices: ["venice-api-key"],
},
{
value: "litellm",
label: "LiteLLM",
hint: "Unified LLM gateway (100+ providers)",
choices: ["litellm-api-key"],
},
{
value: "cloudflare-ai-gateway",
label: "Cloudflare AI Gateway",
hint: "Account ID + Gateway ID + API key",
choices: ["cloudflare-ai-gateway-api-key"],
},
{
value: "custom",
label: "Custom Provider",
hint: "Any OpenAI or Anthropic compatible endpoint",
choices: ["custom-api-key"],
},
];
const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial<Record<AuthChoice, string>> = {
"litellm-api-key": "Unified gateway for 100+ LLM providers",
"cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key",
"venice-api-key": "Privacy-focused inference (uncensored models)",
"together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models",
"huggingface-api-key": "Inference Providers — OpenAI-compatible chat",
"opencode-zen": "Shared OpenCode key; curated Zen catalog",
"opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog",
};
const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial<Record<AuthChoice, string>> = {
"moonshot-api-key": "Kimi API key (.ai)",
"moonshot-api-key-cn": "Kimi API key (.cn)",
"kimi-code-api-key": "Kimi Code API key (subscription)",
"cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway",
"opencode-zen": "OpenCode Zen catalog",
"opencode-go": "OpenCode Go catalog",
};
function buildProviderAuthChoiceOptions(): AuthChoiceOption[] {
return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({
value: flag.authChoice,
label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description,
...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice]
? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] }
: {}),
}));
}
export const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
{
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "run `claude setup-token` elsewhere, then paste the token here",
},
{
value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)",
},
{ value: "chutes", label: "Chutes (OAuth)" },
...buildProviderAuthChoiceOptions(),
{
value: "moonshot-api-key-cn",
label: "Kimi API key (.cn)",
},
{
value: "github-copilot",
label: "GitHub Copilot (GitHub device login)",
hint: "Uses GitHub device flow",
},
{ value: "gemini-api-key", label: "Google Gemini API key" },
{
value: "google-gemini-cli",
label: "Google Gemini CLI OAuth",
hint: "Unofficial flow; review account-risk warning before use",
},
{ value: "zai-api-key", label: "Z.AI API key" },
{
value: "zai-coding-global",
label: "Coding-Plan-Global",
hint: "GLM Coding Plan Global (api.z.ai)",
},
{
value: "zai-coding-cn",
label: "Coding-Plan-CN",
hint: "GLM Coding Plan CN (open.bigmodel.cn)",
},
{
value: "zai-global",
label: "Global",
hint: "Z.AI Global (api.z.ai)",
},
{
value: "zai-cn",
label: "CN",
hint: "Z.AI CN (open.bigmodel.cn)",
},
{
value: "xiaomi-api-key",
label: "Xiaomi API key",
},
{
value: "minimax-global-oauth",
label: "MiniMax Global — OAuth (minimax.io)",
hint: "Only supports OAuth for the coding plan",
},
{
value: "minimax-global-api",
label: "MiniMax Global — API Key (minimax.io)",
hint: "sk-api- or sk-cp- keys supported",
},
{
value: "minimax-cn-oauth",
label: "MiniMax CN — OAuth (minimaxi.com)",
hint: "Only supports OAuth for the coding plan",
},
{
value: "minimax-cn-api",
label: "MiniMax CN — API Key (minimaxi.com)",
hint: "sk-api- or sk-cp- keys supported",
},
{ value: "qwen-portal", label: "Qwen OAuth" },
{
value: "copilot-proxy",
label: "Copilot Proxy (local)",
hint: "Local proxy for VS Code Copilot models",
},
{ value: "apiKey", label: "Anthropic API key" },
{
value: "opencode-zen",
label: "OpenCode Zen catalog",
hint: "Claude, GPT, Gemini via opencode.ai/zen",
},
{ value: "qianfan-api-key", label: "Qianfan API key" },
{
value: "modelstudio-api-key-cn",
label: "Coding Plan API Key for China (subscription)",
hint: "Endpoint: coding.dashscope.aliyuncs.com",
},
{
value: "modelstudio-api-key",
label: "Coding Plan API Key for Global/Intl (subscription)",
hint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
},
{ value: "custom-api-key", label: "Custom Provider" },
];
export function formatStaticAuthChoiceChoicesForCli(params?: {
includeSkip?: boolean;
includeLegacyAliases?: boolean;
}): string {
const includeSkip = params?.includeSkip ?? true;
const includeLegacyAliases = params?.includeLegacyAliases ?? false;
const values = BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value);
if (includeSkip) {
values.push("skip");
}
if (includeLegacyAliases) {
values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI);
}
return values.join("|");
}

View File

@ -6,6 +6,7 @@ import {
buildAuthChoiceOptions,
formatAuthChoiceChoicesForCli,
} from "./auth-choice-options.js";
import { formatStaticAuthChoiceChoicesForCli } from "./auth-choice-options.static.js";
const resolveProviderWizardOptions = vi.hoisted(() =>
vi.fn<() => ProviderWizardOption[]>(() => []),
@ -104,6 +105,26 @@ describe("buildAuthChoiceOptions", () => {
expect(cliChoices).toContain("codex-cli");
});
it("keeps static cli help choices off the plugin-backed catalog", () => {
resolveProviderWizardOptions.mockReturnValue([
{
value: "ollama",
label: "Ollama",
hint: "Cloud and local open models",
groupId: "ollama",
groupLabel: "Ollama",
},
]);
const cliChoices = formatStaticAuthChoiceChoicesForCli({
includeLegacyAliases: false,
includeSkip: true,
}).split("|");
expect(cliChoices).not.toContain("ollama");
expect(cliChoices).toContain("skip");
});
it("shows Chutes in grouped provider selection", () => {
const { groups } = buildAuthChoiceGroups({
store: EMPTY_STORE,

View File

@ -1,321 +1,15 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveProviderWizardOptions } from "../plugins/provider-wizard.js";
import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js";
import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js";
import {
AUTH_CHOICE_GROUP_DEFS,
BASE_AUTH_CHOICE_OPTIONS,
type AuthChoiceGroup,
type AuthChoiceOption,
formatStaticAuthChoiceChoicesForCli,
} from "./auth-choice-options.static.js";
import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js";
export type { AuthChoiceGroupId };
export type AuthChoiceOption = {
value: AuthChoice;
label: string;
hint?: string;
};
export type AuthChoiceGroup = {
value: AuthChoiceGroupId;
label: string;
hint?: string;
options: AuthChoiceOption[];
};
const AUTH_CHOICE_GROUP_DEFS: {
value: AuthChoiceGroupId;
label: string;
hint?: string;
choices: AuthChoice[];
}[] = [
{
value: "openai",
label: "OpenAI",
hint: "Codex OAuth + API key",
choices: ["openai-codex", "openai-api-key"],
},
{
value: "anthropic",
label: "Anthropic",
hint: "setup-token + API key",
choices: ["token", "apiKey"],
},
{
value: "chutes",
label: "Chutes",
hint: "OAuth",
choices: ["chutes"],
},
{
value: "minimax",
label: "MiniMax",
hint: "M2.5 (recommended)",
choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"],
},
{
value: "moonshot",
label: "Moonshot AI (Kimi K2.5)",
hint: "Kimi K2.5 + Kimi Coding",
choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"],
},
{
value: "google",
label: "Google",
hint: "Gemini API key + OAuth",
choices: ["gemini-api-key", "google-gemini-cli"],
},
{
value: "xai",
label: "xAI (Grok)",
hint: "API key",
choices: ["xai-api-key"],
},
{
value: "mistral",
label: "Mistral AI",
hint: "API key",
choices: ["mistral-api-key"],
},
{
value: "volcengine",
label: "Volcano Engine",
hint: "API key",
choices: ["volcengine-api-key"],
},
{
value: "byteplus",
label: "BytePlus",
hint: "API key",
choices: ["byteplus-api-key"],
},
{
value: "openrouter",
label: "OpenRouter",
hint: "API key",
choices: ["openrouter-api-key"],
},
{
value: "kilocode",
label: "Kilo Gateway",
hint: "API key (OpenRouter-compatible)",
choices: ["kilocode-api-key"],
},
{
value: "qwen",
label: "Qwen",
hint: "OAuth",
choices: ["qwen-portal"],
},
{
value: "zai",
label: "Z.AI",
hint: "GLM Coding Plan / Global / CN",
choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"],
},
{
value: "qianfan",
label: "Qianfan",
hint: "API key",
choices: ["qianfan-api-key"],
},
{
value: "modelstudio",
label: "Alibaba Cloud Model Studio",
hint: "Coding Plan API key (CN / Global)",
choices: ["modelstudio-api-key-cn", "modelstudio-api-key"],
},
{
value: "copilot",
label: "Copilot",
hint: "GitHub + local proxy",
choices: ["github-copilot", "copilot-proxy"],
},
{
value: "ai-gateway",
label: "Vercel AI Gateway",
hint: "API key",
choices: ["ai-gateway-api-key"],
},
{
value: "opencode",
label: "OpenCode",
hint: "Shared API key for Zen + Go catalogs",
choices: ["opencode-zen", "opencode-go"],
},
{
value: "xiaomi",
label: "Xiaomi",
hint: "API key",
choices: ["xiaomi-api-key"],
},
{
value: "synthetic",
label: "Synthetic",
hint: "Anthropic-compatible (multi-model)",
choices: ["synthetic-api-key"],
},
{
value: "together",
label: "Together AI",
hint: "API key",
choices: ["together-api-key"],
},
{
value: "huggingface",
label: "Hugging Face",
hint: "Inference API (HF token)",
choices: ["huggingface-api-key"],
},
{
value: "venice",
label: "Venice AI",
hint: "Privacy-focused (uncensored models)",
choices: ["venice-api-key"],
},
{
value: "litellm",
label: "LiteLLM",
hint: "Unified LLM gateway (100+ providers)",
choices: ["litellm-api-key"],
},
{
value: "cloudflare-ai-gateway",
label: "Cloudflare AI Gateway",
hint: "Account ID + Gateway ID + API key",
choices: ["cloudflare-ai-gateway-api-key"],
},
{
value: "custom",
label: "Custom Provider",
hint: "Any OpenAI or Anthropic compatible endpoint",
choices: ["custom-api-key"],
},
];
const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial<Record<AuthChoice, string>> = {
"litellm-api-key": "Unified gateway for 100+ LLM providers",
"cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key",
"venice-api-key": "Privacy-focused inference (uncensored models)",
"together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models",
"huggingface-api-key": "Inference Providers — OpenAI-compatible chat",
"opencode-zen": "Shared OpenCode key; curated Zen catalog",
"opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog",
};
const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial<Record<AuthChoice, string>> = {
"moonshot-api-key": "Kimi API key (.ai)",
"moonshot-api-key-cn": "Kimi API key (.cn)",
"kimi-code-api-key": "Kimi Code API key (subscription)",
"cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway",
"opencode-zen": "OpenCode Zen catalog",
"opencode-go": "OpenCode Go catalog",
};
function buildProviderAuthChoiceOptions(): AuthChoiceOption[] {
return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({
value: flag.authChoice,
label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description,
...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice]
? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] }
: {}),
}));
}
const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
{
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "run `claude setup-token` elsewhere, then paste the token here",
},
{
value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)",
},
{ value: "chutes", label: "Chutes (OAuth)" },
...buildProviderAuthChoiceOptions(),
{
value: "moonshot-api-key-cn",
label: "Kimi API key (.cn)",
},
{
value: "github-copilot",
label: "GitHub Copilot (GitHub device login)",
hint: "Uses GitHub device flow",
},
{ value: "gemini-api-key", label: "Google Gemini API key" },
{
value: "google-gemini-cli",
label: "Google Gemini CLI OAuth",
hint: "Unofficial flow; review account-risk warning before use",
},
{ value: "zai-api-key", label: "Z.AI API key" },
{
value: "zai-coding-global",
label: "Coding-Plan-Global",
hint: "GLM Coding Plan Global (api.z.ai)",
},
{
value: "zai-coding-cn",
label: "Coding-Plan-CN",
hint: "GLM Coding Plan CN (open.bigmodel.cn)",
},
{
value: "zai-global",
label: "Global",
hint: "Z.AI Global (api.z.ai)",
},
{
value: "zai-cn",
label: "CN",
hint: "Z.AI CN (open.bigmodel.cn)",
},
{
value: "xiaomi-api-key",
label: "Xiaomi API key",
},
{
value: "minimax-global-oauth",
label: "MiniMax Global — OAuth (minimax.io)",
hint: "Only supports OAuth for the coding plan",
},
{
value: "minimax-global-api",
label: "MiniMax Global — API Key (minimax.io)",
hint: "sk-api- or sk-cp- keys supported",
},
{
value: "minimax-cn-oauth",
label: "MiniMax CN — OAuth (minimaxi.com)",
hint: "Only supports OAuth for the coding plan",
},
{
value: "minimax-cn-api",
label: "MiniMax CN — API Key (minimaxi.com)",
hint: "sk-api- or sk-cp- keys supported",
},
{ value: "qwen-portal", label: "Qwen OAuth" },
{
value: "copilot-proxy",
label: "Copilot Proxy (local)",
hint: "Local proxy for VS Code Copilot models",
},
{ value: "apiKey", label: "Anthropic API key" },
{
value: "opencode-zen",
label: "OpenCode Zen catalog",
hint: "Claude, GPT, Gemini via opencode.ai/zen",
},
{ value: "qianfan-api-key", label: "Qianfan API key" },
{
value: "modelstudio-api-key-cn",
label: "Coding Plan API Key for China (subscription)",
hint: "Endpoint: coding.dashscope.aliyuncs.com",
},
{
value: "modelstudio-api-key",
label: "Coding Plan API Key for Global/Intl (subscription)",
hint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
},
{ value: "custom-api-key", label: "Custom Provider" },
];
function resolveDynamicProviderCliChoices(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
@ -331,20 +25,11 @@ export function formatAuthChoiceChoicesForCli(params?: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
const includeSkip = params?.includeSkip ?? true;
const includeLegacyAliases = params?.includeLegacyAliases ?? false;
const values = [
...BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value),
...formatStaticAuthChoiceChoicesForCli(params).split("|"),
...resolveDynamicProviderCliChoices(params),
];
if (includeSkip) {
values.push("skip");
}
if (includeLegacyAliases) {
values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI);
}
return values.join("|");
}

View File

@ -8,10 +8,12 @@ import { compactEmbeddedPiSessionDirect } from "../agents/pi-embedded-runner/com
import { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js";
import {
registerContextEngine,
registerContextEngineForOwner,
getContextEngineFactory,
listContextEngineIds,
resolveContextEngine,
} from "./registry.js";
import type { ContextEngineFactory, ContextEngineRegistrationResult } from "./registry.js";
import type {
ContextEngine,
ContextEngineInfo,
@ -231,18 +233,80 @@ describe("Registry tests", () => {
expect(Array.isArray(ids)).toBe(true);
});
it("registering the same id overwrites the previous factory", () => {
it("registering the same id with the same owner refreshes the factory", () => {
const factory1 = () => new MockContextEngine();
const factory2 = () => new MockContextEngine();
registerContextEngine("reg-overwrite", factory1);
expect(
registerContextEngineForOwner("reg-overwrite", factory1, "owner-a", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
expect(getContextEngineFactory("reg-overwrite")).toBe(factory1);
registerContextEngine("reg-overwrite", factory2);
expect(
registerContextEngineForOwner("reg-overwrite", factory2, "owner-a", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
expect(getContextEngineFactory("reg-overwrite")).toBe(factory2);
expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1);
});
it("rejects context engine registrations from a different owner", () => {
const factory1 = () => new MockContextEngine();
const factory2 = () => new MockContextEngine();
expect(
registerContextEngineForOwner("reg-owner-guard", factory1, "owner-a", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
expect(registerContextEngineForOwner("reg-owner-guard", factory2, "owner-b")).toEqual({
ok: false,
existingOwner: "owner-a",
});
expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1);
});
it("public registerContextEngine cannot spoof owner or refresh existing ids", () => {
const ownedFactory = () => new MockContextEngine();
expect(
registerContextEngineForOwner("public-owner-guard", ownedFactory, "owner-a", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
const spoofAttempt = (
registerContextEngine as unknown as (
id: string,
factory: ContextEngineFactory,
opts?: { owner?: string },
) => ContextEngineRegistrationResult
)("public-owner-guard", () => new MockContextEngine(), { owner: "owner-a" });
expect(spoofAttempt).toEqual({
ok: false,
existingOwner: "owner-a",
});
expect(getContextEngineFactory("public-owner-guard")).toBe(ownedFactory);
});
it("public registerContextEngine reserves the default legacy id", () => {
const legacyAttempt = (
registerContextEngine as unknown as (
id: string,
factory: ContextEngineFactory,
opts?: { owner?: string },
) => ContextEngineRegistrationResult
)("legacy", () => new MockContextEngine(), { owner: "core" });
expect(legacyAttempt).toEqual({
ok: false,
existingOwner: "core",
});
});
it("shares registered engines across duplicate module copies", async () => {
const registryUrl = new URL("./registry.ts", import.meta.url).href;
const suffix = Date.now().toString(36);
@ -474,6 +538,33 @@ describe("Bundle chunk isolation (#40096)", () => {
expect(getContextEngineFactory(sdkEngineId)).toBeDefined();
});
it("plugin-sdk registerContextEngine cannot spoof privileged ownership", async () => {
const ts = Date.now().toString(36);
const engineId = `sdk-spoof-guard-${ts}`;
const ownedFactory = () => new MockContextEngine();
expect(
registerContextEngineForOwner(engineId, ownedFactory, "plugin:owner-a", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
const sdkUrl = new URL("../plugin-sdk/index.ts", import.meta.url).href;
const sdk = await import(/* @vite-ignore */ `${sdkUrl}?sdk-spoof-${ts}`);
const spoofAttempt = (
sdk.registerContextEngine as unknown as (
id: string,
factory: ContextEngineFactory,
opts?: { owner?: string },
) => ContextEngineRegistrationResult
)(engineId, () => new MockContextEngine(), { owner: "plugin:owner-a" });
expect(spoofAttempt).toEqual({
ok: false,
existingOwner: "plugin:owner-a",
});
expect(getContextEngineFactory(engineId)).toBe(ownedFactory);
});
it("concurrent registration from multiple chunks does not lose entries", async () => {
const ts = Date.now().toString(36);
const registryUrl = new URL("./registry.ts", import.meta.url).href;

View File

@ -1,5 +1,5 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { registerContextEngine } from "./registry.js";
import { registerContextEngineForOwner } from "./registry.js";
import type {
ContextEngine,
ContextEngineInfo,
@ -124,5 +124,7 @@ export class LegacyContextEngine implements ContextEngine {
}
export function registerLegacyContextEngine(): void {
registerContextEngine("legacy", () => new LegacyContextEngine());
registerContextEngineForOwner("legacy", () => new LegacyContextEngine(), "core", {
allowSameOwnerRefresh: true,
});
}

View File

@ -7,15 +7,28 @@ import type { ContextEngine } from "./types.js";
* Supports async creation for engines that need DB connections etc.
*/
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
type RegisterContextEngineForOwnerOptions = {
allowSameOwnerRefresh?: boolean;
};
// ---------------------------------------------------------------------------
// Registry (module-level singleton)
// ---------------------------------------------------------------------------
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
const CORE_CONTEXT_ENGINE_OWNER = "core";
const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk";
type ContextEngineRegistryState = {
engines: Map<string, ContextEngineFactory>;
engines: Map<
string,
{
factory: ContextEngineFactory;
owner: string;
}
>;
};
// Keep context-engine registrations process-global so duplicated dist chunks
@ -26,24 +39,69 @@ function getContextEngineRegistryState(): ContextEngineRegistryState {
};
if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) {
globalState[CONTEXT_ENGINE_REGISTRY_STATE] = {
engines: new Map<string, ContextEngineFactory>(),
engines: new Map(),
};
}
return globalState[CONTEXT_ENGINE_REGISTRY_STATE];
}
function requireContextEngineOwner(owner: string): string {
const normalizedOwner = owner.trim();
if (!normalizedOwner) {
throw new Error(
`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`,
);
}
return normalizedOwner;
}
/**
* Register a context engine implementation under the given id.
* Register a context engine implementation under an explicit trusted owner.
*/
export function registerContextEngine(id: string, factory: ContextEngineFactory): void {
getContextEngineRegistryState().engines.set(id, factory);
export function registerContextEngineForOwner(
id: string,
factory: ContextEngineFactory,
owner: string,
opts?: RegisterContextEngineForOwnerOptions,
): ContextEngineRegistrationResult {
const normalizedOwner = requireContextEngineOwner(owner);
const registry = getContextEngineRegistryState().engines;
const existing = registry.get(id);
if (
id === defaultSlotIdForKey("contextEngine") &&
normalizedOwner !== CORE_CONTEXT_ENGINE_OWNER
) {
return { ok: false, existingOwner: CORE_CONTEXT_ENGINE_OWNER };
}
if (existing && existing.owner !== normalizedOwner) {
return { ok: false, existingOwner: existing.owner };
}
if (existing && opts?.allowSameOwnerRefresh !== true) {
return { ok: false, existingOwner: existing.owner };
}
registry.set(id, { factory, owner: normalizedOwner });
return { ok: true };
}
/**
* Public SDK entry point for third-party registrations.
*
* This path is intentionally unprivileged: it cannot claim core-owned ids and
* it cannot safely refresh an existing registration because the caller's
* identity is not authenticated.
*/
export function registerContextEngine(
id: string,
factory: ContextEngineFactory,
): ContextEngineRegistrationResult {
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
}
/**
* Return the factory for a registered engine, or undefined.
*/
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
return getContextEngineRegistryState().engines.get(id);
return getContextEngineRegistryState().engines.get(id)?.factory;
}
/**
@ -73,13 +131,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
? slotValue.trim()
: defaultSlotIdForKey("contextEngine");
const factory = getContextEngineRegistryState().engines.get(engineId);
if (!factory) {
const entry = getContextEngineRegistryState().engines.get(engineId);
if (!entry) {
throw new Error(
`Context engine "${engineId}" is not registered. ` +
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
);
}
return factory();
return entry.factory();
}

View File

@ -63,11 +63,11 @@ async function issuePairingScopedOperator(name: string): Promise<{
role: "operator",
scopes: ["operator.pairing"],
});
expect(rotated?.token).toBeTruthy();
expect(rotated.ok ? rotated.entry.token : "").toBeTruthy();
return {
identityPath: loaded.identityPath,
deviceId: loaded.identity.deviceId,
token: String(rotated?.token ?? ""),
token: rotated.ok ? rotated.entry.token : "",
};
}

View File

@ -85,6 +85,11 @@ export type ApproveDevicePairingResult =
| { status: "forbidden"; missingScope: string }
| null;
type ApprovedDevicePairingResult = Extract<
NonNullable<ApproveDevicePairingResult>,
{ status: "approved" }
>;
type DevicePairingStateFile = {
pendingById: Record<string, DevicePairingPendingRequest>;
pairedByDeviceId: Record<string, PairedDevice>;
@ -343,6 +348,15 @@ export async function requestDevicePairing(
});
}
export async function approveDevicePairing(
requestId: string,
baseDir?: string,
): Promise<ApprovedDevicePairingResult | null>;
export async function approveDevicePairing(
requestId: string,
options: { callerScopes?: readonly string[] },
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,

View File

@ -24,6 +24,21 @@ function createExitedProcess(code: number | null, signal: string | null = null)
};
}
async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
const pluginSdkAliasPath = path.join(tmp, "src", "plugin-sdk", "root-alias.cjs");
await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true });
await fs.mkdir(path.join(tmp, "extensions"), { recursive: true });
await fs.writeFile(pluginSdkAliasPath, "module.exports = {};\n", "utf-8");
const baselineTime = new Date("2026-03-13T09:00:00.000Z");
await fs.utimes(pluginSdkAliasPath, baselineTime, baselineTime);
}
function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) {
return platform === "win32"
? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"]
: ["pnpm", "exec", "tsdown", "--no-clean"];
}
describe("run-node script", () => {
it.runIf(process.platform !== "win32")(
"preserves control-ui assets by building with tsdown --no-clean",
@ -32,6 +47,7 @@ describe("run-node script", () => {
const argsPath = path.join(tmp, ".pnpm-args.txt");
const indexPath = path.join(tmp, "dist", "control-ui", "index.html");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(indexPath), { recursive: true });
await fs.writeFile(indexPath, "<html>sentinel</html>\n", "utf-8");
@ -78,6 +94,73 @@ describe("run-node script", () => {
},
);
it("copies bundled plugin metadata after rebuilding from a clean dist", async () => {
await withTempDir(async (tmp) => {
const extensionManifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
const extensionPackagePath = path.join(tmp, "extensions", "demo", "package.json");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(extensionManifestPath), { recursive: true });
await fs.writeFile(
extensionManifestPath,
'{"id":"demo","configSchema":{"type":"object"}}\n',
"utf-8",
);
await fs.writeFile(
extensionPackagePath,
JSON.stringify(
{
name: "demo",
openclaw: {
extensions: ["./src/index.ts", "./nested/entry.mts"],
},
},
null,
2,
) + "\n",
"utf-8",
);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_FORCE_BUILD: "1",
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
await expect(
fs.readFile(path.join(tmp, "dist", "plugin-sdk", "root-alias.cjs"), "utf-8"),
).resolves.toContain("module.exports = {};");
await expect(
fs.readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8"),
).resolves.toContain('"id":"demo"');
await expect(
fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"),
).resolves.toContain(
'"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]',
);
});
});
it("skips rebuilding when dist is current and the source tree is clean", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
@ -85,6 +168,7 @@ describe("run-node script", () => {
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
@ -161,4 +245,525 @@ describe("run-node script", () => {
expect(exitCode).toBe(23);
});
});
it("rebuilds when extension sources are newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(extensionPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(extensionPath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
});
});
it("skips rebuilding when extension package metadata is newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
const packagePath = path.join(tmp, "extensions", "demo", "package.json");
const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.mkdir(path.dirname(packagePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.mkdir(path.dirname(distPackagePath), { recursive: true });
await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8");
await fs.writeFile(
packagePath,
'{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n',
"utf-8",
);
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(
distPackagePath,
'{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n',
"utf-8",
);
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(manifestPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(tsdownConfigPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(packagePath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
await expect(fs.readFile(distPackagePath, "utf-8")).resolves.toContain('"./index.js"');
});
});
it("skips rebuilding for dirty non-source files under extensions", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const readmePath = path.join(tmp, "extensions", "demo", "README.md");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(readmePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(readmePath, "# demo\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(readmePath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: " M extensions/demo/README.md\n" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.mkdir(path.dirname(distManifestPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(
distManifestPath,
'{"id":"stale","configSchema":{"type":"object"}}\n',
"utf-8",
);
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(manifestPath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"');
});
});
it("repairs missing bundled plugin metadata without rerunning tsdown", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(manifestPath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: "" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"');
});
});
it("removes stale bundled plugin metadata when the source manifest is gone", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const extensionDir = path.join(tmp, "extensions", "demo");
const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json");
const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(extensionDir, { recursive: true });
await fs.mkdir(path.dirname(distManifestPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
await fs.writeFile(
distManifestPath,
'{"id":"stale","configSchema":{"type":"object"}}\n',
"utf-8",
);
await fs.writeFile(distPackagePath, '{"name":"stale"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: "" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
await expect(fs.access(distManifestPath)).rejects.toThrow();
await expect(fs.access(distPackagePath)).rejects.toThrow();
});
});
it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const readmePath = path.join(tmp, "extensions", "demo", "README.md");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(readmePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(readmePath, "# demo\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(srcPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(tsdownConfigPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(readmePath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("rebuilds when tsdown config is newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await writeRuntimePostBuildScaffold(tmp);
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(srcPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: "" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
});
});
});

View File

@ -4,11 +4,15 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import {
canResolveRegistryVersionForPackageTarget,
cleanupGlobalRenameDirs,
detectGlobalInstallManagerByPresence,
detectGlobalInstallManagerForRoot,
globalInstallArgs,
globalInstallFallbackArgs,
isExplicitPackageInstallSpec,
isMainPackageTarget,
OPENCLAW_MAIN_PACKAGE_SPEC,
resolveGlobalPackageRoot,
resolveGlobalInstallSpec,
resolveGlobalRoot,
@ -60,6 +64,40 @@ describe("update global helpers", () => {
);
});
it("maps main and explicit install specs for global installs", () => {
expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe(
OPENCLAW_MAIN_PACKAGE_SPEC,
);
expect(
resolveGlobalInstallSpec({
packageName: "openclaw",
tag: "github:openclaw/openclaw#feature/my-branch",
}),
).toBe("github:openclaw/openclaw#feature/my-branch");
expect(
resolveGlobalInstallSpec({
packageName: "openclaw",
tag: "https://example.com/openclaw-main.tgz",
}),
).toBe("https://example.com/openclaw-main.tgz");
});
it("classifies main and raw install specs separately from registry selectors", () => {
expect(isMainPackageTarget("main")).toBe(true);
expect(isMainPackageTarget(" MAIN ")).toBe(true);
expect(isMainPackageTarget("beta")).toBe(false);
expect(isExplicitPackageInstallSpec("github:openclaw/openclaw#main")).toBe(true);
expect(isExplicitPackageInstallSpec("https://example.com/openclaw-main.tgz")).toBe(true);
expect(isExplicitPackageInstallSpec("file:/tmp/openclaw-main.tgz")).toBe(true);
expect(isExplicitPackageInstallSpec("beta")).toBe(false);
expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true);
expect(canResolveRegistryVersionForPackageTarget("2026.3.14")).toBe(true);
expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false);
expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false);
});
it("detects install managers from resolved roots and on-disk presence", async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-global-"));
const npmRoot = path.join(base, "npm-root");

View File

@ -14,12 +14,41 @@ export type CommandRunner = (
const PRIMARY_PACKAGE_NAME = "openclaw";
const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const;
const GLOBAL_RENAME_PREFIX = ".";
export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main";
const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const;
const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [
"--omit=optional",
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
] as const;
function normalizePackageTarget(value: string): string {
return value.trim();
}
export function isMainPackageTarget(value: string): boolean {
return normalizePackageTarget(value).toLowerCase() === "main";
}
export function isExplicitPackageInstallSpec(value: string): boolean {
const trimmed = normalizePackageTarget(value);
if (!trimmed) {
return false;
}
return (
trimmed.includes("://") ||
trimmed.includes("#") ||
/^(?:file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/i.test(trimmed)
);
}
export function canResolveRegistryVersionForPackageTarget(value: string): boolean {
const trimmed = normalizePackageTarget(value);
if (!trimmed) {
return true;
}
return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed);
}
async function resolvePortableGitPathPrepend(
env: NodeJS.ProcessEnv | undefined,
): Promise<string[]> {
@ -68,7 +97,14 @@ export function resolveGlobalInstallSpec(params: {
if (override) {
return override;
}
return `${params.packageName}@${params.tag}`;
const target = normalizePackageTarget(params.tag);
if (isMainPackageTarget(target)) {
return OPENCLAW_MAIN_PACKAGE_SPEC;
}
if (isExplicitPackageInstallSpec(target)) {
return target;
}
return `${params.packageName}@${target}`;
}
export async function createGlobalInstallEnv(

View File

@ -441,6 +441,20 @@ describe("runGatewayUpdate", () => {
expect(calls.some((call) => call === expectedInstallCommand)).toBe(true);
});
it("updates global npm installs from the GitHub main package spec", async () => {
const { calls, result } = await runNpmGlobalUpdateCase({
expectedInstallCommand:
"npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error",
tag: "main",
});
expect(result.status).toBe("ok");
expect(result.mode).toBe("npm");
expect(calls).toContain(
"npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error",
);
});
it("falls back to global npm update when git is missing from PATH", async () => {
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
const { calls, runCommand } = createGlobalInstallHarness({

View File

@ -44,10 +44,17 @@ describe("watch-node script", () => {
{ ignoreInitial: boolean; ignored: (watchPath: string) => boolean },
];
expect(watchPaths).toEqual(runNodeWatchedPaths);
expect(watchPaths).toContain("extensions");
expect(watchPaths).toContain("tsdown.config.ts");
expect(watchOptions.ignoreInitial).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true);
expect(watchOptions.ignored("extensions/voice-call/README.md")).toBe(true);
expect(watchOptions.ignored("extensions/voice-call/openclaw.plugin.json")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/package.json")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/index.ts")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/src/runtime.ts")).toBe(false);
expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false);
expect(watchOptions.ignored("tsconfig.json")).toBe(false);
@ -120,9 +127,24 @@ describe("watch-node script", () => {
}),
});
const childB = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childB.emit("exit", 0, null));
}),
});
const childC = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childC.emit("exit", 0, null));
}),
});
const childD = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB);
const spawn = vi
.fn()
.mockReturnValueOnce(childA)
.mockReturnValueOnce(childB)
.mockReturnValueOnce(childC)
.mockReturnValueOnce(childD);
const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}),
});
@ -151,11 +173,26 @@ describe("watch-node script", () => {
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "src/infra/watch-node.ts");
watcher.emit("change", "extensions/voice-call/README.md");
await new Promise((resolve) => setImmediate(resolve));
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "extensions/voice-call/openclaw.plugin.json");
await new Promise((resolve) => setImmediate(resolve));
expect(childA.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(2);
watcher.emit("change", "extensions/voice-call/package.json");
await new Promise((resolve) => setImmediate(resolve));
expect(childB.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(3);
watcher.emit("change", "src/infra/watch-node.ts");
await new Promise((resolve) => setImmediate(resolve));
expect(childC.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(4);
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);

View File

@ -1,3 +1,4 @@
import * as extensionApi from "openclaw/extension-api";
import * as compatSdk from "openclaw/plugin-sdk/compat";
import * as discordSdk from "openclaw/plugin-sdk/discord";
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
@ -132,4 +133,8 @@ describe("plugin-sdk subpath exports", () => {
const zalo = await import("openclaw/plugin-sdk/zalo");
expect(typeof zalo.resolveClientIp).toBe("function");
});
it("exports the extension api bridge", () => {
expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function");
});
});

View File

@ -25,6 +25,11 @@ export {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
} from "../channels/plugins/directory-config.js";
export {
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
} from "../channels/plugins/group-policy-warnings.js";
export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js";
export {
@ -44,5 +49,6 @@ export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp
export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js";
export { createActionGate, readStringParam } from "../agents/tools/common.js";
export { createPluginRuntimeStore } from "./runtime-store.js";
export { normalizeE164 } from "../utils.js";

View File

@ -0,0 +1,144 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
copyBundledPluginMetadata,
rewritePackageExtensions,
} from "../../scripts/copy-bundled-plugin-metadata.mjs";
const tempDirs: string[] = [];
function makeRepoRoot(prefix: string): string {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(repoRoot);
return repoRoot;
}
function writeJson(filePath: string, value: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("rewritePackageExtensions", () => {
it("rewrites TypeScript extension entries to built JS paths", () => {
expect(rewritePackageExtensions(["./index.ts", "./nested/entry.mts"])).toEqual([
"./index.js",
"./nested/entry.js",
]);
});
});
describe("copyBundledPluginMetadata", () => {
it("copies plugin manifests, package metadata, and local skill directories", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-");
const pluginDir = path.join(repoRoot, "extensions", "acpx");
fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "skills", "acp-router", "SKILL.md"),
"# ACP Router\n",
"utf8",
);
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "acpx",
configSchema: { type: "object" },
skills: ["./skills"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/acpx",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadata({ repoRoot });
expect(
fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")),
).toBe(true);
expect(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "acpx", "skills", "acp-router", "SKILL.md"),
"utf8",
),
).toContain("ACP Router");
const packageJson = JSON.parse(
fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"),
) as { openclaw?: { extensions?: string[] } };
expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]);
});
it("dereferences node_modules-backed skill paths into the bundled dist tree", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
const storeSkillDir = path.join(
repoRoot,
"node_modules",
".pnpm",
"@tloncorp+tlon-skill@0.2.2",
"node_modules",
"@tloncorp",
"tlon-skill",
);
fs.mkdirSync(storeSkillDir, { recursive: true });
fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8");
fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true });
fs.symlinkSync(
storeSkillDir,
path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"),
process.platform === "win32" ? "junction" : "dir",
);
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "tlon",
configSchema: { type: "object" },
skills: ["node_modules/@tloncorp/tlon-skill"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadata({ repoRoot });
const copiedSkillDir = path.join(
repoRoot,
"dist",
"extensions",
"tlon",
"node_modules",
"@tloncorp",
"tlon-skill",
);
expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true);
expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false);
});
it("omits missing declared skill paths from the bundled manifest", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-");
const pluginDir = path.join(repoRoot, "extensions", "tlon");
fs.mkdirSync(pluginDir, { recursive: true });
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: "tlon",
configSchema: { type: "object" },
skills: ["node_modules/@tloncorp/tlon-skill"],
});
writeJson(path.join(pluginDir, "package.json"), {
name: "@openclaw/tlon",
openclaw: { extensions: ["./index.ts"] },
});
copyBundledPluginMetadata({ repoRoot });
const bundledManifest = JSON.parse(
fs.readFileSync(
path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"),
"utf8",
),
) as { skills?: string[] };
expect(bundledManifest.skills).toEqual([]);
});
});

View File

@ -284,6 +284,22 @@ function createPluginSdkAliasFixture(params?: {
return { root, srcFile, distFile };
}
function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "extensionAPI.ts");
const distFile = path.join(root, "dist", "extensionAPI.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf-8",
);
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
return { root, srcFile, distFile };
}
afterEach(() => {
clearPluginLoaderCache();
if (prevBundledDir === undefined) {
@ -986,6 +1002,218 @@ describe("loadOpenClawPlugins", () => {
expect(httpPlugin?.httpRoutes).toBe(1);
});
it("rejects duplicate plugin-visible hook names", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "hook-owner-a",
filename: "hook-owner-a.cjs",
body: `module.exports = { id: "hook-owner-a", register(api) {
api.registerHook("gateway:startup", () => {}, { name: "shared-hook" });
} };`,
});
const second = writePlugin({
id: "hook-owner-b",
filename: "hook-owner-b.cjs",
body: `module.exports = { id: "hook-owner-b", register(api) {
api.registerHook("gateway:startup", () => {}, { name: "shared-hook" });
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
allow: ["hook-owner-a", "hook-owner-b"],
},
},
});
expect(registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook")).toHaveLength(
1,
);
expect(
registry.diagnostics.some(
(diag) =>
diag.level === "error" &&
diag.pluginId === "hook-owner-b" &&
diag.message === "hook already registered: shared-hook (hook-owner-a)",
),
).toBe(true);
});
it("rejects duplicate plugin service ids", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "service-owner-a",
filename: "service-owner-a.cjs",
body: `module.exports = { id: "service-owner-a", register(api) {
api.registerService({ id: "shared-service", start() {} });
} };`,
});
const second = writePlugin({
id: "service-owner-b",
filename: "service-owner-b.cjs",
body: `module.exports = { id: "service-owner-b", register(api) {
api.registerService({ id: "shared-service", start() {} });
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
allow: ["service-owner-a", "service-owner-b"],
},
},
});
expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength(
1,
);
expect(
registry.diagnostics.some(
(diag) =>
diag.level === "error" &&
diag.pluginId === "service-owner-b" &&
diag.message === "service already registered: shared-service (service-owner-a)",
),
).toBe(true);
});
it("rejects plugin context engine ids reserved by core", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "context-engine-core-collision",
filename: "context-engine-core-collision.cjs",
body: `module.exports = { id: "context-engine-core-collision", register(api) {
api.registerContextEngine("legacy", () => ({}));
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["context-engine-core-collision"],
},
});
expect(
registry.diagnostics.some(
(diag) =>
diag.level === "error" &&
diag.pluginId === "context-engine-core-collision" &&
diag.message === "context engine id reserved by core: legacy",
),
).toBe(true);
});
it("rejects duplicate plugin context engine ids", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "context-engine-owner-a",
filename: "context-engine-owner-a.cjs",
body: `module.exports = { id: "context-engine-owner-a", register(api) {
api.registerContextEngine("shared-context-engine-loader-test", () => ({}));
} };`,
});
const second = writePlugin({
id: "context-engine-owner-b",
filename: "context-engine-owner-b.cjs",
body: `module.exports = { id: "context-engine-owner-b", register(api) {
api.registerContextEngine("shared-context-engine-loader-test", () => ({}));
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
allow: ["context-engine-owner-a", "context-engine-owner-b"],
},
},
});
expect(
registry.diagnostics.some(
(diag) =>
diag.level === "error" &&
diag.pluginId === "context-engine-owner-b" &&
diag.message ===
"context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)",
),
).toBe(true);
});
it("requires plugin CLI registrars to declare explicit command roots", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "cli-missing-metadata",
filename: "cli-missing-metadata.cjs",
body: `module.exports = { id: "cli-missing-metadata", register(api) {
api.registerCli(() => {});
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["cli-missing-metadata"],
},
});
expect(registry.cliRegistrars).toHaveLength(0);
expect(
registry.diagnostics.some(
(diag) =>
diag.level === "error" &&
diag.pluginId === "cli-missing-metadata" &&
diag.message === "cli registration missing explicit commands metadata",
),
).toBe(true);
});
it("rejects duplicate plugin CLI command roots", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "cli-owner-a",
filename: "cli-owner-a.cjs",
body: `module.exports = { id: "cli-owner-a", register(api) {
api.registerCli(() => {}, { commands: ["shared-cli"] });
} };`,
});
const second = writePlugin({
id: "cli-owner-b",
filename: "cli-owner-b.cjs",
body: `module.exports = { id: "cli-owner-b", register(api) {
api.registerCli(() => {}, { commands: ["shared-cli"] });
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
allow: ["cli-owner-a", "cli-owner-b"],
},
},
});
expect(registry.cliRegistrars).toHaveLength(1);
expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a");
expect(
registry.diagnostics.some(
(diag) =>
diag.level === "error" &&
diag.pluginId === "cli-owner-b" &&
diag.message === "cli command already registered: shared-cli (cli-owner-a)",
),
).toBe(true);
});
it("registers http routes", () => {
useNoBundledPlugins();
const plugin = writePlugin({
@ -2187,4 +2415,24 @@ describe("loadOpenClawPlugins", () => {
);
expect(resolved).toBe(srcFile);
});
it("prefers dist extension-api alias when loader runs from dist", () => {
const { root, distFile } = createExtensionApiAliasFixture();
const resolved = __testing.resolveExtensionApiAlias({
modulePath: path.join(root, "dist", "plugins", "loader.js"),
});
expect(resolved).toBe(distFile);
});
it("prefers src extension-api alias when loader runs from src in non-production", () => {
const { root, srcFile } = createExtensionApiAliasFixture();
const resolved = withEnv({ NODE_ENV: undefined }, () =>
__testing.resolveExtensionApiAlias({
modulePath: path.join(root, "src", "plugins", "loader.ts"),
}),
);
expect(resolved).toBe(srcFile);
});
});

View File

@ -124,6 +124,36 @@ const resolvePluginSdkAliasFile = (params: {
const resolvePluginSdkAlias = (): string | null =>
resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" });
const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string | null => {
try {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveOpenClawPackageRootSync({
cwd: path.dirname(modulePath),
});
if (!packageRoot) {
return null;
}
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const candidateMap = {
src: path.join(packageRoot, "src", "extensionAPI.ts"),
dist: path.join(packageRoot, "dist", "extensionAPI.js"),
} as const;
for (const kind of orderedKinds) {
const candidate = candidateMap[kind];
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
};
const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] {
@ -172,6 +202,7 @@ const resolvePluginSdkScopedAliasMap = (): Record<string, string> => {
export const __testing = {
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
resolveExtensionApiAlias,
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
@ -701,7 +732,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
return jitiLoader;
}
const pluginSdkAlias = resolvePluginSdkAlias();
const extensionApiAlias = resolveExtensionApiAlias();
const aliasMap = {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap(),
};

View File

@ -2,7 +2,7 @@ import path from "node:path";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { registerContextEngine } from "../context-engine/registry.js";
import { registerContextEngineForOwner } from "../context-engine/registry.js";
import type {
GatewayRequestHandler,
GatewayRequestHandlers,
@ -15,6 +15,7 @@ import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import { normalizeRegisteredProvider } from "./provider-validation.js";
import type { PluginRuntime } from "./runtime/types.js";
import { defaultSlotIdForKey } from "./slots.js";
import {
isPluginHookName,
isPromptInjectionHookName,
@ -238,6 +239,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
const existingHook = registry.hooks.find((entry) => entry.entry.hook.name === name);
if (existingHook) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `hook already registered: ${name} (${existingHook.pluginId})`,
});
return;
}
const description = entry?.hook.description ?? opts?.description ?? "";
const hookEntry: HookEntry = entry
@ -473,6 +484,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
opts?: { commands?: string[] },
) => {
const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean);
if (commands.length === 0) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "cli registration missing explicit commands metadata",
});
return;
}
const existing = registry.cliRegistrars.find((entry) =>
entry.commands.some((command) => commands.includes(command)),
);
if (existing) {
const overlap = commands.find((command) => existing.commands.includes(command));
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})`,
});
return;
}
record.cliCommands.push(...commands);
registry.cliRegistrars.push({
pluginId: record.id,
@ -487,6 +520,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
if (!id) {
return;
}
const existing = registry.services.find((entry) => entry.service.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `service already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.services.push(id);
registry.services.push({
pluginId: record.id,
@ -611,7 +654,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
registerCommand: (command) => registerCommand(record, command),
registerContextEngine: (id, factory) => registerContextEngine(id, factory),
registerContextEngine: (id, factory) => {
if (id === defaultSlotIdForKey("contextEngine")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `context engine id reserved by core: ${id}`,
});
return;
}
const result = registerContextEngineForOwner(id, factory, `plugin:${record.id}`, {
allowSameOwnerRefresh: true,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `context engine already registered: ${id} (${result.existingOwner})`,
});
}
},
resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) =>
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),

View File

@ -4,12 +4,17 @@ import {
collectBundledExtensionManifestErrors,
collectBundledExtensionRootDependencyGapErrors,
collectForbiddenPackPaths,
collectPackUnpackedSizeErrors,
} from "../scripts/release-check.ts";
function makeItem(shortVersion: string, sparkleVersion: string): string {
return `<item><title>${shortVersion}</title><sparkle:shortVersionString>${shortVersion}</sparkle:shortVersionString><sparkle:version>${sparkleVersion}</sparkle:version></item>`;
}
function makePackResult(filename: string, unpackedSize: number) {
return { filename, unpackedSize };
}
describe("collectAppcastSparkleVersionErrors", () => {
it("accepts legacy 9-digit calver builds before lane-floor cutover", () => {
const xml = `<rss><channel>${makeItem("2026.2.26", "202602260")}</channel></rss>`;
@ -163,3 +168,30 @@ describe("collectForbiddenPackPaths", () => {
).toEqual(["extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw"]);
});
});
describe("collectPackUnpackedSizeErrors", () => {
it("accepts pack results within the unpacked size budget", () => {
expect(
collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.14.tgz", 120_354_302)]),
).toEqual([]);
});
it("flags oversized pack results that risk low-memory startup failures", () => {
expect(
collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]),
).toEqual([
"openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
]);
});
it("fails closed when npm pack output omits unpackedSize for every result", () => {
expect(
collectPackUnpackedSizeErrors([
{ filename: "openclaw-2026.3.14.tgz" },
{ filename: "openclaw-extra.tgz", unpackedSize: Number.NaN },
]),
).toEqual([
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
]);
});
});

View File

@ -18,6 +18,7 @@
"target": "es2023",
"useDefineForClassFields": false,
"paths": {
"openclaw/extension-api": ["./src/extensionAPI.ts"],
"openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"],
"openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
"openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"]

View File

@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { defineConfig } from "tsdown";
const env = {
@ -87,6 +89,51 @@ const pluginSdkEntrypoints = [
"keyed-async-queue",
] as const;
function listBundledPluginBuildEntries(): Record<string, string> {
const extensionsRoot = path.join(process.cwd(), "extensions");
const entries: Record<string, string> = {};
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const pluginDir = path.join(extensionsRoot, dirent.name);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
continue;
}
const packageJsonPath = path.join(pluginDir, "package.json");
let packageEntries: string[] = [];
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: { extensions?: unknown };
};
packageEntries = Array.isArray(packageJson.openclaw?.extensions)
? packageJson.openclaw.extensions.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
)
: [];
} catch {
packageEntries = [];
}
}
const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"];
for (const entry of sourceEntries) {
const normalizedEntry = entry.replace(/^\.\//, "");
const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`;
entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry);
}
}
return entries;
}
const bundledPluginBuildEntries = listBundledPluginBuildEntries();
export default defineConfig([
nodeBuildConfig({
entry: "src/index.ts",
@ -122,6 +169,12 @@ export default defineConfig([
entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])),
outDir: "dist/plugin-sdk",
}),
nodeBuildConfig({
// Bundle bundled plugin entrypoints so built gateway startup can load JS
// directly from dist/extensions instead of transpiling extensions/*.ts via Jiti.
entry: bundledPluginBuildEntries,
outDir: "dist",
}),
nodeBuildConfig({
entry: "src/extensionAPI.ts",
}),

View File

@ -58,6 +58,10 @@ export default defineConfig({
resolve: {
// Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match.
alias: [
{
find: "openclaw/extension-api",
replacement: path.join(repoRoot, "src", "extensionAPI.ts"),
},
...pluginSdkSubpaths.map((subpath) => ({
find: `openclaw/plugin-sdk/${subpath}`,
replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),