Compare commits

..

93 Commits

Author SHA1 Message Date
Peter Steinberger
59b0825d8c fix(memory-host): preserve core resolver exports in sdk shims 2026-04-28 11:54:02 +01:00
Peter Steinberger
b1b9f0094c fix(memory-host): keep sdk shim exports complete 2026-04-28 11:47:52 +01:00
Peter Steinberger
9a6a663d84 test(qa): settle channel before thread memory check 2026-04-28 11:38:31 +01:00
Peter Steinberger
db365d531f fix(logging): honor forced color for subsystem output 2026-04-28 11:38:31 +01:00
Peter Steinberger
b469092d92 test(memory-host): assert package-owned boundary report 2026-04-28 11:38:18 +01:00
Peter Steinberger
064ec5f07c fix(memory-host): harden service overrides 2026-04-28 11:38:18 +01:00
Peter Steinberger
c3f5af897c refactor(memory-host): replace core runtime bridge with services 2026-04-28 11:38:17 +01:00
Vincent Koc
aa1834a3ff fix(gateway): warn on legacy env vars
Fixes #53482.

Supersedes #53667.
2026-04-28 03:37:57 -07:00
Peter Steinberger
d770a3b786 test(memory): stabilize reindex and cron checks 2026-04-28 11:36:28 +01:00
Peter Steinberger
6a387afc53 refactor(memory-host): route sdk shims to package source 2026-04-28 11:36:28 +01:00
Peter Steinberger
94fc91e235 ci: harden clawsweeper dispatch workflow 2026-04-28 11:35:40 +01:00
Peter Steinberger
5a1ff1347d fix(slack): bound inbound media downloads 2026-04-28 11:35:26 +01:00
James Reagan
a722da3ed0 fix(gateway): align session thinking defaults (#63418)
Aligns Gateway history and session list thinking-default resolution so backend session state matches the Control UI default label:

- `chat.history` now falls back through the shared Gateway session thinking-default resolver.
- Explicit session overrides still win, then owning `agents.list[].thinkingDefault`, then global/model/catalog defaults.
- `sessions.list` catalog-aware thinking defaults are covered by focused regressions.

PR by @jpreagan.

Validated in Blacksmith Testbox `tbx_01kq9t1aeqrz1mj598vvqv9dpg`:
- `pnpm test:serial src/gateway/session-utils.test.ts src/gateway/server.sessions.gateway-server-sessions-a.test.ts src/gateway/server.chat.gateway-server-chat.test.ts` (141 passed)
- `OPENCLAW_TESTBOX=1 pnpm check:changed`
2026-04-28 03:34:58 -07:00
Vincent Koc
d70191f8af feat(sandbox): add Docker GPU passthrough
Add opt-in `sandbox.docker.gpus` config plumbing for Docker sandbox containers.

- thread the optional GPU passthrough field through config types, schema, resolution, and Docker create args
- reject empty config values and emit `--gpus` as a separate Docker argv pair
- document the Docker-only behavior and credit the original contributor in the changelog

Fixes #57976.
Carries forward #58124 from @cyan-ember.

Co-authored-by: cyan-ember <5855097+cyan-ember@users.noreply.github.com>
2026-04-28 03:33:28 -07:00
Peter Steinberger
7150acba69 ci: debounce clawsweeper dispatch metadata 2026-04-28 11:31:49 +01:00
Peter Steinberger
35bc13f9ef fix: prefer OpenAI media for Codex defaults 2026-04-28 11:30:17 +01:00
Shakker
32c987626b fix: prune stale plugin runtime mirror entries 2026-04-28 11:25:09 +01:00
Shakker
92016b82ae fix: refresh plugin runtime mirrors in place 2026-04-28 11:25:09 +01:00
Shakker
7727e102a5 fix: scope plugin inspect runtime loading 2026-04-28 11:25:09 +01:00
Shakker
1bd4b7ac4d fix: keep plugin uninstall on metadata path 2026-04-28 11:25:09 +01:00
Vincent Koc
7950a18025 fix(whatsapp): recover stale listener after auth conflict churn (#72621)
* fix(whatsapp): recover stale listener after auth conflict churn

* fix(whatsapp): block symlink auth cleanup escapes

* fix(whatsapp): refuse external auth cleanup
2026-04-28 03:24:57 -07:00
Vincent Koc
e2f3044b8f fix(memory-wiki): route bridge CLI through gateway
Route Memory Wiki bridge-mode status, doctor, and bridge import CLI paths through Gateway RPC when bridge artifact reads are active, while preserving local/offline fallbacks.

Harden Gateway CLI rendering and imported-source writes: validate RPC response shapes, bound response strings before rendering/JSON serialization, sanitize/escape terminal-controlled output, avoid redundant JSON forwarding, and replace imported source pages through a temp-file rename path with symlink and hardlink regressions.

Fixes #65722
Fixes #65976
Fixes #66082
Fixes #67979
Fixes #68371
Fixes #68828
Fixes #69019
Fixes #70181
Fixes #70242
Fixes #70842

Thanks @moorsecopers99, @vincentkoc, and @prasad-yashdeep.
2026-04-28 03:22:12 -07:00
Vincent Koc
f12dedb5c8 fix(tasks): keep media tool runs live 2026-04-28 03:21:00 -07:00
Peter Steinberger
1b13f53047 fix(ollama): reject garbled Kimi symbol output 2026-04-28 11:20:15 +01:00
Vincent Koc
77192572f6 ci: split macos codeql shard
Split the slow macOS CodeQL job into its own weekly/manual workflow and keep the daily CodeQL default on the fast JS/Actions security path.
2026-04-28 03:14:07 -07:00
Peter Steinberger
6cc6996a1c fix(slack): tune socket mode pong timeout 2026-04-28 11:13:03 +01:00
Peter Steinberger
c9ead1b928 test: annotate Docker test-state scenarios 2026-04-28 11:10:30 +01:00
Peter Steinberger
ade9aaae89 fix(cli): classify scope-limited status probes as reachable 2026-04-28 11:09:42 +01:00
Peter Steinberger
1fcf0a422f fix(agents): keep media generation tasks fresh 2026-04-28 10:59:42 +01:00
Peter Steinberger
9da76c4255 test: fix openclaw test state helper types 2026-04-28 10:59:42 +01:00
Gabriel Kripalani
17ef9ef895 feat(openrouter): add video generation provider (#72700)
Adds OpenRouter video generation via video_generate, with hardened async polling/download handling, docs, and regression coverage.

Validation:
- pnpm test src/plugins/plugin-lookup-table.test.ts src/secrets/target-registry.fast-path.test.ts src/gateway/server-startup-post-attach.test.ts extensions/openrouter/video-generation-provider.test.ts src/video-generation/live-test-helpers.test.ts src/media-generation/provider-capabilities.contract.test.ts src/agents/pi-embedded-helpers/failover-matches.test.ts src/plugins/manifest-metadata-scan.test.ts src/agents/openai-transport-stream.test.ts src/media-understanding/openai-compatible-audio.test.ts src/agents/schema-normalization-runtime-contract.test.ts src/agents/provider-request-config.test.ts src/plugin-sdk/provider-stream.test.ts src/agents/pi-embedded-runner/run/attempt.spawn-workspace.websocket.test.ts -- --reporter=verbose
- OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=0 OPENCLAW_LIVE_VIDEO_GENERATION_MODELS=openrouter/google/veo-3.1-fast pnpm test:live src/video-generation/video-generation.live.test.ts -- --runInBand

Co-authored-by: notamicrodose <gabrielkripalani@me.com>
2026-04-28 10:57:31 +01:00
Peter Steinberger
5915489631 test: stabilize tts fast-lane guard 2026-04-28 10:54:23 +01:00
Peter Steinberger
6f8792f3f1 fix(cli): wire image describe prompt options 2026-04-28 10:53:53 +01:00
Peter Steinberger
0bc8b9a95a test: add shared OpenClaw test-state harness 2026-04-28 10:52:47 +01:00
Patrick Erichsen
ab3feca0d5 docs(skills): generalize pre-release testing skill wording (#73468) 2026-04-28 02:50:11 -07:00
Peter Steinberger
9207660c87 test: fix main ci shard routing 2026-04-28 10:48:27 +01:00
Vincent Koc
ae63f76bbd fix(cron): infer session agentId when omitted (#72326)
* fix(cron): infer session agentId when omitted

* fix(clownfish): address review for ghcrawl-165998-agentic-merge (1)
2026-04-28 02:47:20 -07:00
Peter Steinberger
c5cd7aabcf fix(auto-reply): bound pending tool result drain 2026-04-28 10:46:06 +01:00
Vincent Koc
210cccb0fe fix(tasks): index async media tasks by agent 2026-04-28 02:43:17 -07:00
Peter Steinberger
a6bb0265f0 test: speed up unit hotspot routing 2026-04-28 10:42:14 +01:00
Vincent Koc
17811480da docs(skills): add plugin pre-release test plan 2026-04-28 02:40:33 -07:00
Vincent Koc
cfbf4d1fa4 docs: note default sandbox image fail-fast behavior
For 47dc9f7fc0: docs/gateway/sandboxing.md now warns under "Build the default
image" that OpenClaw no longer silently retags plain debian:bookworm-slim as
openclaw-sandbox:bookworm-slim when the default image is missing. Sandbox runs
fail with a build instruction so the python3 tooling required by sandbox
write/edit helpers is preserved instead of being silently dropped.
2026-04-28 02:40:26 -07:00
Vincent Koc
058b57867e fix(qwen): allow explicit qwen3.6-plus on Coding Plan (#72664) 2026-04-28 02:38:47 -07:00
Peter Steinberger
b4ffef5c5f fix(plugins): prune inactive bundled runtime deps 2026-04-28 10:34:24 +01:00
Peter Steinberger
1346a31861 fix(plugins): keep manifestless bundles indexed 2026-04-28 10:34:01 +01:00
Peter Steinberger
f5922e6eb1 fix(agents): trim config write tool responses 2026-04-28 10:32:58 +01:00
Vincent Koc
5820a48fca ci: add plugin boundary codeql quality shard (#73447) 2026-04-28 02:30:33 -07:00
Peter Steinberger
1f1b98e33b fix(auto-reply): keep consumed reset triggers out of prompt 2026-04-28 10:24:04 +01:00
Vincent Koc
aa2f964bda fix(mattermost): keep inspector capture quiet 2026-04-28 02:19:57 -07:00
Vincent Koc
ad954dd1ca test(plugins): fix codex inspector capture regression 2026-04-28 02:19:56 -07:00
Vincent Koc
5f3b8b4100 fix(plugins): harden inspector runtime capture 2026-04-28 02:19:56 -07:00
Peter Steinberger
0f24a8d8e1 test: isolate gateway prewarm scheduling 2026-04-28 10:18:42 +01:00
Peter Steinberger
fac116cfa4 fix: resolve providerless image model refs 2026-04-28 10:18:07 +01:00
ZC
5741e40c14 fix(cron): clarify local timezone cron expressions (#73372)
* fix(cron): clarify local timezone cron expressions

* fix: clarify cron timezone guidance

---------

Co-authored-by: Altay <altay@uinaf.dev>
2026-04-28 12:16:27 +03:00
Peter Steinberger
9cdae734a7 test: stabilize gateway startup prewarm test 2026-04-28 10:14:03 +01:00
Vincent Koc
1912e309f7 fix(ui): confirm button-triggered new session resets (#73361) 2026-04-28 02:10:33 -07:00
Peter Steinberger
62997f7fce fix(deepseek): backfill v4 assistant reasoning replay 2026-04-28 10:07:39 +01:00
Peter Steinberger
0876ff481b test: speed up styled select test 2026-04-28 10:02:20 +01:00
Scott Hanselman
8f277e4b7f fix: allow safe Windows companion node commands (#71884)
Merged via squash.

Prepared head SHA: 24e2b79fe4
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
Reviewed-by: @shanselman
2026-04-28 02:01:20 -07:00
Edwin Rivera
bca30b62be fix: defer Claude live MCP cleanup (#73351)
Thanks @edwin-rivera-dev.
2026-04-28 09:59:58 +01:00
Peter Steinberger
249cb54373 fix: keep acp typing for tool-only replies 2026-04-28 09:58:18 +01:00
Vincent Koc
7fd9c152d1 fix(memory): keep pre-compaction flush prompt out of user transcript (#73380) 2026-04-28 01:58:14 -07:00
Vincent Koc
47dc9f7fc0 Fix default sandbox image fallback for python3-dependent mutations (#73362) 2026-04-28 01:57:44 -07:00
Peter Steinberger
6f3b5f8666 fix(agents): pause yielded subagent runs 2026-04-28 09:57:12 +01:00
Peter Steinberger
2790825ae5 test(auto-reply): assert bare reset acknowledgement 2026-04-28 09:56:41 +01:00
Peter Steinberger
11f0244cf4 fix(gateway): start channels before model prewarm 2026-04-28 09:56:16 +01:00
Vincent Koc
b6a21cde34 ci: schedule android codeql shard (#73430) 2026-04-28 01:54:57 -07:00
Vincent Koc
76cd97289b fix(cron): support Telegram thread IDs in cron add/edit
- Add `--thread-id` support to cron add/edit Telegram delivery.
- Reject non-positive thread IDs and guard cron edit lookup pagination against non-progress/max-page loops.
- Preserve existing delivery mode on thread-only cron edit patches.

Carries forward #51581, #60373, and #60890.

Co-authored-by: ChunHao Chen <crazycjh@gmail.com>
2026-04-28 01:50:44 -07:00
Vincent Koc
02908db62b fix(ui): clear webchat pending state only for completed active run (#73368) 2026-04-28 01:47:00 -07:00
Peter Steinberger
3ed3248d7b fix(gateway): preserve config SecretRef env for services 2026-04-28 09:44:51 +01:00
Peter Steinberger
4c61040c52 test: speed up small unit fast cases 2026-04-28 09:44:44 +01:00
Vincent Koc
fe7865aad6 docs: cover Anthropic beta header suppression and claude-cli fallback prelude
- docs/concepts/model-providers.md: add proxy-route shaping rule for the
  09ec5d2c4d fix that suppresses implicit Anthropic beta headers
  (`claude-code-20250219`, `interleaved-thinking-2025-05-14`, OAuth markers)
  on non-direct endpoints, parallel to the existing OpenAI
  `compat.supportsDeveloperRole` rule.
- docs/gateway/cli-backends.md: add a "Fallback prelude from claude-cli
  sessions" section for a96f1fa5ef so users know that non-CLI fallback
  candidates after a claude-cli failure are now seeded with a context prelude
  harvested from Claude Code's `~/.claude/projects/` JSONL (preferring the
  latest `/compact` summary, coalescing tool blocks, skipping same-provider
  `--resume` fallbacks).
2026-04-28 01:42:25 -07:00
Peter Steinberger
8a98c08c8a fix(mattermost): avoid system events for user posts 2026-04-28 09:41:04 +01:00
Peter Steinberger
28bf71d74b fix(auto-reply): preserve silent voice payloads 2026-04-28 09:41:04 +01:00
Peter Steinberger
a3bbcf2792 fix(docker): keep plugin runtime deps off bind mounts 2026-04-28 09:37:59 +01:00
Peter Steinberger
3ee5490c60 fix(auto-reply): avoid duplicate reset hook acknowledgements 2026-04-28 09:37:15 +01:00
Vincent Koc
e2bcec33b3 fix(security): avoid duplicate skill package import 2026-04-28 01:37:01 -07:00
Vincent Koc
7e028917c0 fix(android): remediate app CodeQL alerts 2026-04-28 01:37:01 -07:00
Vincent Koc
5ac6d7661c fix(ci): harden workflow checkouts 2026-04-28 01:37:00 -07:00
Peter Steinberger
f76c8322d3 test: route gateway audit through fast lane 2026-04-28 09:35:34 +01:00
Vincent Koc
474859aaaa test(agents): cover raw model cache trace stage 2026-04-28 01:32:34 -07:00
Peter Steinberger
99ceaaa76e test: fix attempt execution fixture lint 2026-04-28 09:32:02 +01:00
Peter Steinberger
a68ca1ae0b fix(auto-reply): acknowledge bare reset commands 2026-04-28 09:31:14 +01:00
Peter Steinberger
8178b62187 fix(android): include third-party sensitive handlers 2026-04-28 09:27:59 +01:00
Peter Steinberger
2276f660f3 refactor(android): split sensitive features by flavor 2026-04-28 09:27:39 +01:00
Peter Steinberger
8ff0ea50b0 ci: stabilize full release validation 2026-04-28 09:26:50 +01:00
Vincent Koc
bab403d0ee fix(plugins): avoid bundled install load path aliases 2026-04-28 01:26:21 -07:00
Peter Steinberger
169dba2042 fix(skills): require opt-in for coding-agent 2026-04-28 09:24:24 +01:00
Peter Steinberger
4f6dab852e ci: fix main test and boundary checks 2026-04-28 09:23:26 +01:00
Peter Steinberger
09ec5d2c4d fix(agents): suppress Anthropic beta headers for custom endpoints 2026-04-28 09:20:58 +01:00
Peter Steinberger
2a1e47ffcb fix(agents): restore raw model run type coverage 2026-04-28 09:20:58 +01:00
Peter Steinberger
732e5805e3 fix(ollama): preserve configured native thinking 2026-04-28 09:20:44 +01:00
Peter Steinberger
7092313b2f docs: advertise xhigh docs i18n thinking 2026-04-28 09:19:40 +01:00
410 changed files with 12736 additions and 2158 deletions

View File

@@ -0,0 +1,234 @@
---
name: openclaw-pre-release-plugin-testing
description: Plan and run pre-release OpenClaw plugin validation across bundled plugins, package artifacts, lifecycle commands, doctor/fix, config round-trip, gateway startup, SDK compatibility, Docker E2E, Package Acceptance, and Testbox proof.
---
# OpenClaw Pre-Release Plugin Testing
Use this skill when the user asks for plugin release confidence, plugin lifecycle
sweeps, package-artifact plugin proof, or "what else should we test before
release?" It complements `openclaw-testing`; use that skill too when choosing
the cheapest safe runner or debugging a failing lane.
## Goal
Prove the plugin system as a product surface, not just as source tests:
- bundled plugin lifecycle: install, inspect, enable, disable, uninstall
- package artifact behavior from a clean `HOME`
- doctor/fix/config validation and idempotence
- config discovery and config round-trip
- status/log visibility and diagnostics
- gateway startup/bootstrap with plugin metadata snapshots
- public SDK compatibility for real external plugins
- live-ish provider/channel probes only when safe credentials exist
## First Checks
From the OpenClaw repo root:
```bash
pnpm docs:list
git status --short --branch
readlink node_modules
pnpm changed:lanes --json
```
In Codex worktrees under `.codex/worktrees`, `node_modules` must be a symlink to
the main OpenClaw checkout. Do not run `pnpm install` there. For broad or
package-heavy proof, use Blacksmith Testbox or GitHub Actions.
## Runner Choice
Prefer this order:
1. **GitHub Package Acceptance** for installable-package product proof.
2. **`ci-build-artifacts-testbox.yml` Testbox** when Docker/package lanes need
seeded `dist`, `dist-runtime`, and package caches.
3. **`ci-check-testbox.yml` Testbox** for source checks, targeted Vitest,
package-boundary checks, or focused Docker lanes.
4. **Local targeted commands only** for small format/static/unit probes.
Avoid long package Docker runs from a stale sparse worktree. If Testbox sync
reports hundreds of changed files or starts deleting package inputs, stop and
warm a fresh box from current `main`, or switch to Package Acceptance.
## Existing Baseline
Run or verify these before inventing new coverage:
```bash
OPENCLAW_TESTBOX=1 pnpm check:changed
pnpm run test:extensions:package-boundary:canary
pnpm run test:extensions:package-boundary:compile
pnpm test:docker:plugins
OPENCLAW_PLUGINS_E2E_CLAWHUB=0 pnpm test:docker:plugins
pnpm test:docker:plugin-update
pnpm test:docker:bundled-channel-deps:fast
```
For full bundled install/uninstall proof, shard the packaged sweep:
```bash
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL=8 \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX=<0-7> \
pnpm test:docker:bundled-plugin-install-uninstall
```
Expected current packaged scope: 116 public bundled plugins over shards `0-7`.
Private QA plugins are source-mode only unless a package explicitly includes
them.
## Confidence Matrix
Use this matrix for pre-release signoff. Record pass/fail, run URL/Testbox ID,
package SHA/version, and skipped-live reason.
| Surface | Proof | Preferred runner |
| --- | --- | --- |
| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions |
| Bundled lifecycle | 8-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker |
| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance |
| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance |
| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance |
| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness |
| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness |
| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness |
| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke |
| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes |
## Package Acceptance Plan
Use this when validating a release branch, beta, or candidate package:
```bash
gh workflow run package-acceptance.yml \
--repo openclaw/openclaw \
--ref main \
-f workflow_ref=main \
-f source=ref \
-f package_ref=<branch-or-sha> \
-f suite_profile=custom \
-f docker_lanes='plugins-offline plugin-update bundled-channel-deps-compat doctor-switch update-channel-switch config-reload mcp-channels npm-onboard-channel-agent' \
-f telegram_mode=mock-openai
```
Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep
`workflow_ref` as trusted current harness code unless the release process says
otherwise.
## New Testbox Harness Plan
If more certainty is needed, add or run a `plugin-lifecycle-matrix` Docker lane
that uses one package tarball and sharded plugin lists. Per plugin:
1. Start with a clean `HOME`.
2. Capture `plugins list --json`.
3. `plugins install <id>`.
4. `plugins inspect <id> --json`.
5. `plugins disable <id>`, then assert disabled visibility.
6. `plugins enable <id>`, except config-required plugins without config.
7. `plugins registry --refresh`.
8. `doctor --non-interactive`.
9. `plugins uninstall <id> --force`.
10. Assert no config entry, allow/deny residue, install record, managed dir, or
bundled `dist/extensions/...` load path remains.
11. Assert diagnostics contain no `level: "error"` and output redacts
secret-looking values.
Keep `memory-lancedb` special: it is config-required. First assert install does
not enable it without embedding config, then run a second configured case.
## Doctor/Fix Matrix
Seed bad states and require `doctor --fix --non-interactive` to repair them,
then run doctor again and require idempotence:
- stale `plugins.allow`
- stale `plugins.entries`
- stale channel config for missing channel plugin
- invalid `plugins.entries.<id>.config`
- packaged bundled path in `plugins.load.paths`
- legacy `plugins.installs`
- disabled channel/plugin config that must not stage runtime deps
- root-owned global package tree that must remain unmodified
## Gateway Bootstrap Matrix
Start packaged OpenClaw in Docker with clean state:
- provider plugins enabled, no credentials: ready with warnings, no crash
- channel plugins configured disabled: no runtime deps staged
- startup-activation plugins enabled: ready and reflected in status
- invalid single plugin config: bad plugin skipped/quarantined, others remain
Assert:
- gateway reaches ready
- `openclaw status --json` includes plugin diagnostics
- `openclaw plugins inspect --all --json` is parseable
- package tree is not mutated
- logs contain no raw tokens
## Config Round-Trip Representatives
Use representative plugin families instead of every plugin for deep config
round-trip:
- providers: `openai`, `anthropic`, `mistral`, `openrouter`
- channels: `telegram`, `discord`, `slack`, `whatsapp`
- memory: `memory-lancedb`
- feature/runtime: `browser`, `acpx`, `tokenjuice`
For each representative:
1. Write config through CLI when possible.
2. Read it back through `config get` or JSON.
3. Run `plugins inspect`.
4. Run `doctor --non-interactive`.
5. Trigger gateway config reload if applicable.
6. Compare config hash before/after no-op commands.
## External SDK Smoke
In a package Docker lane, create tiny external plugins and install them from:
- local directory
- `.tgz`
- `file:` npm spec
Cover CJS and ESM shapes, plus at least one plugin importing focused
`openclaw/plugin-sdk/*` subpaths. Assert `plugins inspect` sees its tool,
gateway method, CLI command, or service.
## Live-Ish Probe Rules
Before live-ish work, source allowed env in Testbox and generate a redacted
availability matrix: present/missing only, never values.
Only run probes for credentials that exist. Prefer auth/catalog/status probes
over sending user-visible messages. If a probe might contact an external user,
channel, or workspace, stop and ask the user.
## Reporting
Report in this shape:
```text
package/ref:
tbx ids / run urls:
matrix:
bundled lifecycle:
package acceptance:
doctor/fix:
gateway bootstrap:
config round-trip:
sdk external:
live-ish:
failures:
skips:
next highest-value gap:
```
Say clearly when a failure is Testbox sync/env damage rather than product
behavior, and prove that with a clean rerun or current-main comparison.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "OpenClaw Plugin Pre-Release Testing"
short_description: "Plan plugin release validation"
default_prompt: "Use $openclaw-pre-release-plugin-testing to plan or run pre-release OpenClaw plugin validation across package, lifecycle, doctor, gateway, SDK, and live-ish proof."

1
.github/CODEOWNERS vendored
View File

@@ -9,6 +9,7 @@
/.github/dependabot.yml @openclaw/secops
/.github/codeql/ @openclaw/secops
/.github/workflows/codeql.yml @openclaw/secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/secops
/.github/workflows/codeql-critical-quality.yml @openclaw/secops
/src/security/ @openclaw/secops
/src/secrets/ @openclaw/secops

View File

@@ -0,0 +1,76 @@
name: openclaw-codeql-plugin-boundary-critical-quality
disable-default-queries: true
queries:
- uses: security-and-quality
query-filters:
- include:
problem.severity:
- error
- exclude:
tags:
- security
paths:
- src/plugins/activation-planner.ts
- src/plugins/api-builder.ts
- src/plugins/bundled-compat.ts
- src/plugins/bundled-dir.ts
- src/plugins/bundled-plugin-metadata.ts
- src/plugins/bundled-public-surface-runtime-root.ts
- src/plugins/bundled-runtime-deps.ts
- src/plugins/bundled-runtime-root.ts
- src/plugins/captured-registration.ts
- src/plugins/config-activation-shared.ts
- src/plugins/config-contracts.ts
- src/plugins/config-normalization-shared.ts
- src/plugins/config-policy.ts
- src/plugins/config-schema.ts
- src/plugins/config-state.ts
- src/plugins/discovery.ts
- src/plugins/effective-plugin-ids.ts
- src/plugins/externalized-bundled-plugins.ts
- src/plugins/installed-plugin-index*.ts
- src/plugins/loader*.ts
- src/plugins/manifest*.ts
- src/plugins/module-export.ts
- src/plugins/package-entrypoints.ts
- src/plugins/plugin-registry*.ts
- src/plugins/provider-contract-public-artifacts.ts
- src/plugins/provider-public-artifacts.ts
- src/plugins/public-surface*.ts
- src/plugins/registry.ts
- src/plugins/registry-types.ts
- src/plugins/runtime
- src/plugins/runtime-state.ts
- src/plugins/runtime.ts
- src/plugins/sdk-alias.ts
- src/plugins/source-loader.ts
- src/plugins/types.ts
- src/plugins/validation-diagnostics.ts
- src/plugins/web-provider-public-artifacts*.ts
- src/plugin-sdk/*entry*.ts
- src/plugin-sdk/*facade*.ts
- src/plugin-sdk/api-baseline.ts
- src/plugin-sdk/config-schema.ts
- src/plugin-sdk/config-types.ts
- src/plugin-sdk/core.ts
- src/plugin-sdk/extension-shared.ts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"

View File

@@ -26,7 +26,7 @@ jobs:
timeout-minutes: 35
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@v2
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
@@ -218,7 +218,7 @@ jobs:
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@v2
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Checkout
@@ -121,7 +121,7 @@ jobs:
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@v2
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
checkout_sha: ${{ steps.checkout_ref.outputs.sha }}
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
@@ -468,7 +468,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -540,7 +540,7 @@ jobs:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_sha }}
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
@@ -669,7 +669,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -764,7 +764,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -867,7 +867,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -935,7 +935,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1055,7 +1055,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1135,7 +1135,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1322,7 +1322,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1454,7 +1454,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1652,7 +1652,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
@@ -1715,7 +1715,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -1758,7 +1758,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -1863,7 +1863,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -1904,7 +1904,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_sha }}
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
submodules: false
@@ -2005,7 +2005,7 @@ jobs:
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail

View File

@@ -9,18 +9,29 @@ on:
permissions:
contents: read
concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
dispatch:
runs-on: ubuntu-latest
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
steps:
- name: Debounce bursty metadata events
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: 3306130
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
@@ -31,6 +42,8 @@ jobs:
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
SOURCE_EVENT: ${{ github.event_name }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
@@ -40,7 +53,10 @@ jobs:
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--arg item_kind "$ITEM_KIND" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind}}')"
--arg source_event "$SOURCE_EVENT" \
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
if gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then

View File

@@ -0,0 +1,51 @@
name: CodeQL Android Critical Security
on:
workflow_dispatch:
schedule:
- cron: "0 7 * * *"
concurrency:
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
android:
name: Critical Security (android)
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: java-kotlin
build-mode: manual
config-file: ./.github/codeql/codeql-android-critical-security.yml
- name: Build Android for CodeQL
working-directory: apps/android
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/android"

View File

@@ -38,3 +38,24 @@ jobs:
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/javascript-typescript"
plugin-boundary:
name: Critical Quality (plugin-boundary)
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/plugin-boundary"

View File

@@ -0,0 +1,89 @@
name: CodeQL macOS Critical Security
on:
workflow_dispatch:
schedule:
- cron: "0 8 * * 1"
concurrency:
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
macos:
name: Critical Security (macOS)
runs-on: blacksmith-6vcpu-macos-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: swift
build-mode: manual
config-file: ./.github/codeql/codeql-macos-critical-security.yml
- name: Build macOS for CodeQL
run: swift build --package-path apps/macos --product OpenClaw
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
upload: failure-only
category: "/codeql-critical-security/macos"
- name: Remove dependency build results
env:
SARIF_OUTPUT: sarif-results
run: |
set -euo pipefail
shopt -s nullglob
if [ ! -d "$SARIF_OUTPUT" ]; then
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
exit 1
fi
mkdir -p sarif-results-filtered
files=("$SARIF_OUTPUT"/*.sarif)
if [ "${#files[@]}" -eq 0 ]; then
echo "No SARIF files found in $SARIF_OUTPUT" >&2
exit 1
fi
for file in "${files[@]}"; do
jq '
def in_dependency_build:
((.locations // []) | length > 0)
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
' "$file" > "sarif-results-filtered/$(basename "$file")"
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

@@ -11,8 +11,6 @@ on:
options:
- all
- security
- android-security
- macos-security
schedule:
- cron: "0 6 * * *"
@@ -62,107 +60,3 @@ jobs:
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/${{ matrix.language }}"
android-security:
name: Critical Security (android)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'android-security' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: java-kotlin
build-mode: manual
config-file: ./.github/codeql/codeql-android-critical-security.yml
- name: Build Android for CodeQL
working-directory: apps/android
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/android"
macos-security:
name: Critical Security (macOS)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'macos-security' }}
runs-on: blacksmith-6vcpu-macos-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: swift
build-mode: manual
config-file: ./.github/codeql/codeql-macos-critical-security.yml
- name: Build macOS for CodeQL
run: swift build --package-path apps/macos --product OpenClaw
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
upload: failure-only
category: "/codeql-critical-security/macos"
- name: Remove dependency build results
env:
SARIF_OUTPUT: sarif-results
run: |
set -euo pipefail
shopt -s nullglob
if [ ! -d "$SARIF_OUTPUT" ]; then
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
exit 1
fi
mkdir -p sarif-results-filtered
files=("$SARIF_OUTPUT"/*.sarif)
if [ "${#files[@]}" -eq 0 ]; then
echo "No SARIF files found in $SARIF_OUTPUT" >&2
exit 1
fi
for file in "${files[@]}"; do
jq '
def in_dependency_build:
((.locations // []) | length > 0)
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
' "$file" > "sarif-results-filtered/$(basename "$file")"
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@v1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -330,7 +330,7 @@ jobs:
fi
dispatch_and_wait openclaw-release-checks.yml \
-f ref="$TARGET_REF" \
-f ref="$TARGET_SHA" \
-f expected_sha="$TARGET_SHA" \
-f provider="$PROVIDER" \
-f mode="$MODE" \

View File

@@ -321,7 +321,7 @@ jobs:
submodules: recursive
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
@@ -496,7 +496,7 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
with:
version: ${{ env.PNPM_VERSION }}
run_install: false

View File

@@ -72,7 +72,7 @@ jobs:
contents: read
outputs:
ref: ${{ steps.inputs.outputs.ref }}
sha: ${{ steps.ref.outputs.sha }}
revision: ${{ steps.ref.outputs.sha }}
provider: ${{ steps.inputs.outputs.provider }}
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
@@ -106,6 +106,7 @@ jobs:
- name: Checkout trusted workflow helper
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref_name }}
path: workflow
fetch-depth: 1
@@ -126,6 +127,7 @@ jobs:
if: steps.fast_ref.outputs.fallback == 'true'
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
path: source
fetch-depth: 0
@@ -240,6 +242,7 @@ jobs:
- name: Checkout trusted workflow ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref_name }}
fetch-depth: 0
@@ -259,7 +262,7 @@ jobs:
id: package
shell: bash
env:
PACKAGE_REF: ${{ needs.resolve_target.outputs.sha }}
PACKAGE_REF: ${{ needs.resolve_target.outputs.revision }}
run: |
set -euo pipefail
node scripts/resolve-openclaw-package-candidate.mjs \
@@ -298,7 +301,7 @@ jobs:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.revision }}
run_bun_global_install_smoke: true
cross_os_release_checks:
@@ -333,7 +336,7 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.sha }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: true
include_release_path_suites: true
include_openwebui: ${{ needs.resolve_target.outputs.release_profile != 'minimum' }}
@@ -488,7 +491,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -535,7 +539,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.sha }}
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -556,7 +560,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -569,7 +574,7 @@ jobs:
- name: Download parity lane artifacts
uses: actions/download-artifact@v4
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.sha }}
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
merge-multiple: true
@@ -590,7 +595,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.sha }}
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -612,7 +617,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -669,7 +675,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.sha }}
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
@@ -691,7 +697,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -754,7 +761,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.sha }}
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn

View File

@@ -57,9 +57,11 @@ jobs:
steps:
- name: Checkout PR
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
- name: Setup Node
uses: actions/setup-node@v6

View File

@@ -35,7 +35,7 @@ jobs:
permissions:
contents: read
outputs:
ref_sha: ${{ steps.ref.outputs.sha }}
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
@@ -44,6 +44,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
@@ -150,7 +151,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -164,6 +166,7 @@ jobs:
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
@@ -187,7 +190,7 @@ jobs:
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
@@ -209,7 +212,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -223,6 +227,7 @@ jobs:
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: ${{ env.CLAWHUB_REF }}
path: clawhub-source
@@ -266,7 +271,7 @@ jobs:
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}

View File

@@ -46,7 +46,7 @@ jobs:
permissions:
contents: read
outputs:
ref_sha: ${{ steps.ref.outputs.sha }}
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
@@ -54,6 +54,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
@@ -151,7 +152,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -185,7 +187,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
persist-credentials: false
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment

View File

@@ -81,12 +81,13 @@ jobs:
needs: authorize_actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_sha: ${{ steps.validate.outputs.selected_sha }}
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
fetch-depth: 0
@@ -98,27 +99,27 @@ jobs:
shell: bash
run: |
set -euo pipefail
selected_sha="$(git rev-parse HEAD)"
selected_revision="$(git rev-parse HEAD)"
trusted_reason=""
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_sha" == "$release_branch_sha" ]]; then
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
else
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
"repos/${GITHUB_REPOSITORY}/commits/${selected_revision}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_revision}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
trusted_reason="open-pr-head"
@@ -126,16 +127,16 @@ jobs:
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for this secret-bearing QA run." >&2
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA run." >&2
echo "Allowed refs must be on main, point to a release tag, match a release branch head, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
exit 1
fi
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "Validated ref: \`${INPUT_REF}\`"
echo "Resolved SHA: \`$selected_sha\`"
echo "Resolved SHA: \`$selected_revision\`"
echo "Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
@@ -157,7 +158,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -220,7 +222,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -303,7 +306,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -375,7 +379,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
@@ -467,7 +472,8 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@v1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Sandbox/Docker: add opt-in `sandbox.docker.gpus` passthrough for Docker sandbox containers so local GPU workloads can run inside sandboxed agents when the host Docker runtime supports `--gpus`. Fixes #57976; carries forward #58124. Thanks @cyan-ember.
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
- Android: publish authenticated `node.presence.alive` events after node connect and background transitions so paired Android nodes retain durable last-seen metadata after disconnects. Carries forward #63123. Thanks @ngutman.
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.
@@ -13,12 +14,48 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock, including dist-runtime canonical roots, so Docker Desktop/WSL cold starts no longer hold `.openclaw-runtime-mirror.lock` while scanning slow persisted volumes. Fixes #73339. Thanks @1yihui.
- Gateway/sessions: align `chat.history` and `sessions.list` thinking defaults with owning-agent and catalog-aware resolution so Control UI session defaults match backend runtime state. (#63418) Thanks @jpreagan.
- Agents/media: register detached `video_generate` and `music_generate` tool run contexts until terminal status, so Discord-backed provider jobs stay live in `/tasks` instead of becoming `lost` when the parent chat run context disappears. Thanks @vincentkoc.
- Agents/media: prefer OpenAI image and video providers when the default model uses the OpenAI Codex auth alias, so auto media generation no longer falls through to Fal before GPT Image or Sora. Thanks @vincentkoc.
- Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc.
- Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc.
- CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay.
- Slack/Socket Mode: use a 15s Slack SDK pong timeout by default and add `channels.slack.socketMode.clientPingTimeout`, `serverPingTimeout`, and `pingPongLoggingEnabled` overrides so stale-websocket handling no longer depends on app-event health heuristics. Fixes #14248; refs #58519, #64009, and #63488. Thanks @shivasymbl and @freerk.
- Slack/media: bound private file and forwarded attachment downloads with idle and total timeouts while preserving placeholder fallback, so stalled Slack `file_share` media no longer wedges inbound message handling. Fixes #61850. Thanks @bassboy2k.
- Plugins/inspector: keep bundled plugin runtime capture quiet and config-tolerant for Codex, memory-lancedb, Feishu, Mattermost, QQBot, and Tlon so plugin-inspector JSON checks can validate the full bundled set. Thanks @vincentkoc.
- Slack/auto-reply: keep fully consumed text reset triggers such as `new session` out of `BodyForAgent` after directive cleanup, so configured Slack reset phrases do not leak into the fresh model turn. Fixes #73137. Thanks @neeravmakwana.
- Plugins/runtime deps: prune stale retained bundled runtime deps and keep doctor/secret channel contract scans on lightweight artifacts, so disabled bundled channels stop preserving old dependency trees or importing heavy plugin surfaces. Thanks @SymbolStar and @vincentkoc.
- Auto-reply: bound the post-run pending tool-result delivery drain with a progress-aware idle timeout, so a never-settling tool-result task no longer leaves the session active forever while slow healthy deliveries can keep draining. Fixes #53889; supersedes #64733 and #73434. Thanks @zijunl and @wujiaming88.
- Gateway/startup: start chat channels without waiting for primary model prewarm, keeping model warmup bounded in the background so Slack and other channels come online promptly when provider discovery is slow. Supersedes #73420. Thanks @dorukardahan.
- Gateway/install: carry env-backed config SecretRefs such as `channels.discord.token` into generated service environments when they are present only in the installing shell, while keeping gateway auth SecretRefs non-persisted. Fixes #67817; supersedes #73426. Thanks @wdimaculangan and @ztexydt-cqh.
- Auto-reply/commands: stop bare `/reset` and `/new` after reset hooks acknowledge the command, so non-ACP channels no longer fall through into empty provider calls while `/reset <message>` and `/new <message>` still seed the next model turn. Fixes #73367 and #73412. Thanks @hoyanhan, @wenxu007, and @amdhelper.
- Providers/DeepSeek: backfill DeepSeek V4 `reasoning_content` on plain assistant replay messages as well as tool-call turns, so thinking sessions with prior tool use no longer fail follow-up requests with missing reasoning content. Fixes #73417; refs #71372. Thanks @34262315716 and @Bartok9.
- Agents/gateway tool: strip full config payloads from `config.patch` and `config.apply` tool responses while preserving direct RPC responses, so config-heavy sessions no longer replay large redacted configs into transcript history. Fixes #47610; supersedes #73439. Thanks @HanenVit and @juan-flores077.
- Auto-reply: preserve voice-note media from silent turns while continuing to suppress text and non-voice media, so `NO_REPLY` TTS replies still deliver the requested audio bubble. (#73406) Thanks @zqchris.
- Channels/Mattermost: stop enqueueing regular inbound posts as system events, so Mattermost user messages reach the model only as user-role inbound-envelope content instead of also appearing as `System: Mattermost message...` directives. Fixes #71795. Thanks @juan-flores077.
- Agents/media: qualify bare `agents.defaults.imageModel` and `pdfModel` refs from unique configured image-capable providers, so Ollama vision models such as `moondream` and `qwen2.5vl:7b` do not fall through to the default provider. Fixes #38816; supersedes #73396. Thanks @alainasclaw and @vincentkoc.
- Agents/Anthropic: send implicit Anthropic beta headers only to direct public Anthropic endpoints, including OAuth, so custom Anthropic-compatible providers no longer mis-handle unsupported beta flags unless explicitly configured. Refs #73346. Thanks @byBrodowski.
- Skills: require explicit `skills.entries.coding-agent.enabled` before exposing the bundled coding-agent skill, so installs with Codex on PATH but no OpenAI auth do not silently offer Codex delegation. Fixes #73358. Thanks @LaFleurAdvertising and @Sanjays2402.
- Plugins/startup: treat manifestless Claude bundles as valid installed-plugin registry entries instead of stale missing manifests, so workspace bundles no longer force repeated derived registry rebuilds or noisy `plugins.entries.workspace` warnings during Gateway startup. Fixes #73433. Thanks @AnneVoss.
- Agents/subagents: preserve `sessions_yield` as a paused subagent state and ignore its wait text while freezing completion output, so parent sessions wait for the final post-compaction answer instead of receiving intermediate progress or `(no output)`. Fixes #73413. Thanks @Ask-sola.
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui.
- Plugins/runtime deps: refresh bundled runtime mirrors without deleting active import trees, so config-triggered restarts do not see transient missing plugin files during registration. Thanks @shakkernerd.
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
- CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc.
- CLI/plugins: scope `plugins inspect <id>` runtime loading to the matched plugin so single-plugin inspection does not load every plugin before checking the target. Thanks @shakkernerd.
- CLI/plugins: remove managed copied-path plugin directories during uninstall and plan uninstall from metadata instead of runtime-loading plugins, so plugin lifecycle commands avoid unnecessary bundled runtime-deps work. Thanks @shakkernerd.
- Cron tool: infer the creating session's agentId for `cron.add` jobs when `agentId` is omitted or passed as undefined, keeping scheduled agentTurn jobs routed to the session agent; #40571 identified the guard bug and supplied the focused regression coverage. Thanks @ChanningYul.
- Cron/Telegram: add `--thread-id` to `openclaw cron add` and `openclaw cron edit`, preserving Telegram forum topic delivery targets across scheduled announcements. Carries forward #51581, #60373, and #60890. Thanks @ChunHao-dev.
- Cron/Telegram: preserve session-derived Telegram topic thread IDs when isolated cron delivery explicitly targets the parent chat, keeping bare chat targets in the active forum topic without leaking stale topics to other chats. Carries forward #64708. Thanks @addelh.
- Memory/compaction: keep pre-compaction memory-flush prompts runtime-only so session transcripts and `chat.history` no longer expose them as normal user turns. Fixes #54408 and #58956; refs #43567. Thanks @markgong and @guoyuhang9.
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu.
- Sandbox/Docker: stop satisfying a missing default sandbox image by tagging plain Debian as `openclaw-sandbox:bookworm-slim`, preserving the Python tooling required by sandbox write/edit helpers and directing users to build the default image. Fixes #51185; refs #45108, #51099, #51609, and #57713. Thanks @dpalis, @Tin55FoilDev, @jbcohen2-coder, @macminihal-cyber, and @PraxoOnline.
- Control UI/WebChat: confirm toolbar New Session button resets before dispatching `/new` while leaving typed `/new` and `/reset` commands immediate. Fixes #45800; refs #27065, #56611, #54499, and #27110. Thanks @aethnova, @kosta228-huli, @adambezemek, and @xss925175263 (xianshishan).
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Gateway/startup: warn when legacy `CLAWDBOT_*` or `MOLTBOT_*` environment variables are still present, pointing users to `OPENCLAW_*` names instead of failing silently. Fixes #53482; carries forward #53667. Thanks @lndyzwdxhs.
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.
- Doctor/state: require an interactive confirmation before archiving orphan transcript files, so `openclaw doctor --fix` no longer silently renames recoverable session history after upgrades regenerate `sessions.json`. Fixes #73106. Thanks @scottgl9.
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.
@@ -26,11 +63,13 @@ Docs: https://docs.openclaw.ai
- Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar.
- CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk.
- CLI/model probes: keep `infer model run --gateway` raw by skipping prior session transcript, bootstrap context, context-engine assembly, tools, and bundled MCP servers, so local backends can be tested without full agent-context overhead. Fixes #73308. Thanks @ScientificProgrammer.
- CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Addresses #63700. Thanks @cedricjanssens.
- Providers/Ollama: reject long non-linguistic Kimi/GLM symbol runs as provider failures instead of storing them as successful visible assistant replies, so fallback or error handling can recover from garbled cloud output. Fixes #64262; refs #67019. Thanks @Kloz813 and @xiaomenger123.
- CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge.
- Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah.
- Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj.
- Gateway/model pricing: skip plugin manifest discovery during background pricing refreshes when `plugins.enabled: false`, so disabled-plugin setups do not keep rebuilding plugin metadata from the Gateway hot path. Fixes #73291. Thanks @slideshow-dingo and @fishgills.
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata and preserve explicit native `params.think`/`params.thinking`, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
- Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
- Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch.
- ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia.
@@ -47,6 +86,10 @@ Docs: https://docs.openclaw.ai
- Plugins/runtime deps: reuse enclosing versioned cache roots when bundled plugins resolve from nested staged paths, so plugin-runtime-deps no longer mints `openclaw-unknown-*` directories or loops on `ENOTEMPTY`. Fixes #72956. (#73205) Thanks @SymbolStar.
- Agents/failover: classify CJK provider transport, quota, billing, auth, and overload error text so Chinese-language provider failures trigger fallback and user-facing transport copy instead of surfacing as unclassified raw errors. (#56242) Thanks @tomcatzh.
- Agents/failover: seed non-claude-cli fallback prompts with Claude Code session context when a claude-cli attempt fails, so fallback models do not restart cold after billing or quota failover. (#72069) Thanks @stainlu.
- Agents/CLI runner: transfer bundle-MCP tempDir cleanup from the per-turn runner finally to the Claude live-session lifecycle, so persistent Claude CLI sessions keep their `--mcp-config` directory until the live subprocess closes. Fixes #73244. Thanks @edwin-rivera-dev.
- Gateway/nodes: allow Windows companion nodes to use safe declared commands such as canvas, camera list, location, device info, and screen snapshot by default while keeping dangerous media commands opt-in. (#71884) Thanks @shanselman.
- Agents/cron: clarify agent-tool and CLI cron timezone guidance so supplied `tz` values use local wall-clock cron fields and omitted cron `tz` falls back to the Gateway host local timezone. Fixes #53669; carries forward #46177. (#73372) Thanks @chen-zhang-cs-code and @maranello-o.
- Providers/Qwen: allow explicitly configured `qwen/qwen3.6-plus` to resolve on Qwen Coding Plan endpoints while keeping the built-in catalog from advertising it there. Fixes #63654; carries forward #63987. Thanks @jepson-liu.
## 2026.4.27
@@ -402,6 +445,7 @@ Docs: https://docs.openclaw.ai
- Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.
- Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
- WhatsApp: clear cached Web auth and active listener state after terminal 440/401 conflict/logout closes so linked/OK status no longer masks a dead inbound listener after relink or restart. Fixes #45474; refs #49305, #63855, #66920, and #70856. Thanks @juvenalmakoszay and @dsantoreis.
- Gateway/restart: keep local restart-health probes on configured local daemon auth without falling back to remote gateway credentials. (#57374, #59439) Thanks @zssggle-rgb and @roytong9.
- Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain.
- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.
@@ -413,6 +457,7 @@ Docs: https://docs.openclaw.ai
- TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant.
- Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when `systemctl --user` reports no-medium bus failures, without letting stale `SUDO_USER` override `sudo -u` installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner.
- CLI/nodes: make unfiltered `openclaw nodes list` prefer the effective paired-node view used by `nodes status` while preserving pending rows, pairing-scope fallback, terminal-safe table rendering, and paired JSON metadata. Fixes #46871; carries forward #65772 through the ProjectClownfish #72619 repair. Thanks @skainguyen1412.
- Memory Wiki/CLI: route active bridge-mode status, doctor, and bridge imports through Gateway RPC so CLI checks use the runtime memory plugin context while disabled bridge imports stay local/offline. Carries forward #67208 and #71479; related #70185. Thanks @moorsecopers99, @vincentkoc, and @prasad-yashdeep.
- CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.
- Feishu/Lark: stop treating broadcast-only `@all`/`@_all` messages as bot mentions while preserving direct bot mentions, including messages that also include `@all`. Fixes #37706. Thanks @JosepLee.
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
@@ -923,6 +968,7 @@ Docs: https://docs.openclaw.ai
- Google Meet joins OpenClaw as a bundled participant plugin, with personal Google auth, Chrome/Twilio realtime sessions, paired-node Chrome support, artifact/attendance exports, and recovery tooling for already-open Meet tabs.
- DeepSeek V4 Flash and V4 Pro are in the bundled catalog, V4 Flash is the onboarding default, and DeepSeek thinking/replay behavior is fixed for follow-up tool-call turns.
- Talk, Voice Call, and Google Meet can use realtime voice loops that consult the full OpenClaw agent for deeper tool-backed answers.
- Providers/OpenRouter: add native video generation through `video_generate`, so OpenRouter video models work with `OPENROUTER_API_KEY`. (#72700) Thanks @notamicrodose.
- Browser automation gets coordinate clicks, longer default action budgets, per-profile headless overrides, and steadier tab reuse/recovery.
- Plugin and model infrastructure is lighter at startup: static model catalogs, manifest-backed model rows, lazy provider dependencies, and external runtime-dependency repair for packaged installs.

View File

@@ -258,10 +258,12 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs
# Pre-create the default state dir so first-run Docker named volumes mounted
# here inherit node ownership instead of starting as root-owned state.
# Pre-create the default state and runtime-deps dirs so first-run Docker named
# volumes mounted here inherit node ownership instead of root-owned state.
RUN install -d -m 0700 -o node -g node /home/node/.openclaw && \
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700'
install -d -m 0700 -o node -g node /var/lib/openclaw/plugin-runtime-deps && \
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' && \
stat -c '%U:%G %a' /var/lib/openclaw/plugin-runtime-deps | grep -qx 'node:node 700'
ENV NODE_ENV=production

View File

@@ -11,9 +11,6 @@ indent_style = space
indent_size = 2
max_line_length = off
ktlint_standard_filename = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-naming = disabled
ktlint_standard_if-else-bracing = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_property-naming = disabled

View File

@@ -33,10 +33,10 @@ if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
}
plugins {
id("com.android.application")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -78,13 +78,9 @@ android {
productFlavors {
create("play") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
}
create("thirdParty") {
dimension = "store"
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
}
}
@@ -133,15 +129,7 @@ android {
}
lint {
disable +=
setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
"HighAppVersionCode",
"IconLauncherShape",
"NewerVersionAvailable",
"OldTargetApi",
)
lintConfig = file("lint.xml")
warningsAsErrors = true
}
@@ -184,57 +172,57 @@ ktlint {
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.04.01")
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.webkit:webkit:1.15.0")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.webkit)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation(libs.androidx.compose.ui.tooling)
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
implementation(libs.material)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
implementation("org.commonmark:commonmark:0.28.0")
implementation("org.commonmark:commonmark-ext-autolink:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.28.0")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.28.0")
implementation("org.commonmark:commonmark-ext-task-list-items:0.28.0")
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
implementation(libs.bcprov)
implementation(libs.commonmark)
implementation(libs.commonmark.ext.autolink)
implementation(libs.commonmark.ext.gfm.strikethrough)
implementation(libs.commonmark.ext.gfm.tables)
implementation(libs.commonmark.ext.task.list.items)
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.6.0")
implementation("androidx.camera:camera-camera2:1.6.0")
implementation("androidx.camera:camera-lifecycle:1.6.0")
implementation("androidx.camera:camera-video:1.6.0")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.video)
implementation(libs.play.services.code.scanner)
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
implementation(libs.dnsjava)
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.11")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.11")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.3")
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions.core)
testImplementation(libs.mockwebserver)
testImplementation(libs.robolectric)
testRuntimeOnly(libs.junit.vintage.engine)
}
tasks.withType<Test>().configureEach {

13
apps/android/app/lint.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="AndroidGradlePluginVersion" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<issue id="IconLauncherShape" severity="ignore" />
<issue id="NewerVersionAvailable" severity="ignore" />
<!-- OpenClaw uses date-based version codes (yyyyMMddNN), which are high but still below the Android max. -->
<issue id="HighAppVersionCode" severity="ignore" />
<!-- Target SDK follows the current release train; bump only after platform compatibility testing. -->
<issue id="OldTargetApi" severity="ignore" />
</lint>

View File

@@ -13,7 +13,33 @@ import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.*
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
import ai.openclaw.app.node.CallLogHandler
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CameraHandler
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.ConnectionManager
import ai.openclaw.app.node.ContactsHandler
import ai.openclaw.app.node.DEFAULT_SEAM_COLOR_ARGB
import ai.openclaw.app.node.DebugHandler
import ai.openclaw.app.node.DeviceHandler
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.node.InvokeDispatcher
import ai.openclaw.app.node.LocationCaptureManager
import ai.openclaw.app.node.LocationHandler
import ai.openclaw.app.node.MotionHandler
import ai.openclaw.app.node.NodePresenceAliveBeacon
import ai.openclaw.app.node.NotificationsHandler
import ai.openclaw.app.node.PhotosHandler
import ai.openclaw.app.node.Quad
import ai.openclaw.app.node.SmsHandler
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.node.SystemHandler
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import ai.openclaw.app.node.invokeErrorFromThrowable
import ai.openclaw.app.node.parseHexColorArgb
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
import ai.openclaw.app.voice.MicCaptureManager
import ai.openclaw.app.voice.TalkModeManager
@@ -103,8 +129,8 @@ class NodeRuntime(
private val deviceHandler: DeviceHandler =
DeviceHandler(
appContext = appContext,
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
smsEnabled = SensitiveFeatureConfig.smsEnabled,
callLogEnabled = SensitiveFeatureConfig.callLogEnabled,
)
private val notificationsHandler: NotificationsHandler =
@@ -163,10 +189,10 @@ class NodeRuntime(
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() },
readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() },
smsSearchPossible = { SensitiveFeatureConfig.smsEnabled && sms.hasTelephonyFeature() },
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
@@ -190,11 +216,11 @@ class NodeRuntime(
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() },
readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() },
smsFeatureEnabled = { SensitiveFeatureConfig.smsEnabled },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.app.ActivityManager
@@ -25,8 +26,8 @@ import java.util.Locale
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
) {
companion object {
internal fun hasAnySmsCapability(

View File

@@ -1,8 +1,8 @@
package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import android.Manifest
@@ -248,10 +248,10 @@ fun OnboardingFlow(
val smsAvailable =
remember(context) {
BuildConfig.OPENCLAW_ENABLE_SMS &&
SensitiveFeatureConfig.smsEnabled &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
val callLogAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
val motionAvailable =
remember(context) {
hasMotionCapabilities(context)

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.normalizeLocalHourMinute
import android.Manifest
@@ -204,10 +205,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
val smsPermissionAvailable =
remember {
BuildConfig.OPENCLAW_ENABLE_SMS &&
SensitiveFeatureConfig.smsEnabled &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
val photosPermission =
if (Build.VERSION.SDK_INT >= 33) {
Manifest.permission.READ_MEDIA_IMAGES

View File

@@ -0,0 +1,6 @@
package ai.openclaw.app
object SensitiveFeatureConfig {
const val smsEnabled: Boolean = false
const val callLogEnabled: Boolean = false
}

View File

@@ -0,0 +1,54 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.content.Context
internal data class CallLogRecord(
val number: String?,
val cachedName: String?,
val date: Long,
val duration: Long,
val type: Int,
)
internal data class CallLogSearchRequest(
val limit: Int,
val offset: Int,
val cachedName: String?,
val number: String?,
val date: Long?,
val dateStart: Long?,
val dateEnd: Long?,
val duration: Long?,
val type: Int?,
)
internal interface CallLogDataSource {
fun hasReadPermission(context: Context): Boolean
fun search(
context: Context,
request: CallLogSearchRequest,
): List<CallLogRecord>
}
class CallLogHandler private constructor() {
constructor(
@Suppress("unused") appContext: Context,
) : this()
fun handleCallLogSearch(
@Suppress("unused") paramsJson: String?,
): GatewaySession.InvokeResult =
GatewaySession.InvokeResult.error(
code = "CALL_LOG_UNAVAILABLE",
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
)
companion object {
internal fun forTesting(
@Suppress("unused") appContext: Context,
@Suppress("unused") dataSource: CallLogDataSource,
): CallLogHandler = CallLogHandler()
}
}

View File

@@ -0,0 +1,69 @@
package ai.openclaw.app.node
import ai.openclaw.app.PermissionRequester
import android.content.Context
class SmsManager(
@Suppress("unused") private val context: Context,
) {
data class SendResult(
val ok: Boolean,
val to: String,
val message: String?,
val error: String? = null,
val payloadJson: String,
)
data class SmsMessage(
val id: Long,
val threadId: Long,
val address: String?,
val person: String?,
val date: Long,
val dateSent: Long,
val read: Boolean,
val type: Int,
val body: String?,
val status: Int,
val transportType: String? = null,
)
data class SearchResult(
val ok: Boolean,
val messages: List<SmsMessage>,
val error: String? = null,
val payloadJson: String,
)
fun attachPermissionRequester(
@Suppress("unused") requester: PermissionRequester,
) {
}
fun canSendSms(): Boolean = false
fun canSearchSms(): Boolean = false
fun canReadSms(): Boolean = false
fun hasTelephonyFeature(): Boolean = false
suspend fun send(paramsJson: String?): SendResult =
SendResult(
ok = false,
to = "",
message = null,
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
payloadJson = unavailablePayload(paramsJson),
)
suspend fun search(paramsJson: String?): SearchResult =
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
payloadJson = unavailablePayload(paramsJson),
)
private fun unavailablePayload(paramsJson: String?): String = """{"ok":false,"error":"SMS_UNAVAILABLE","paramsProvided":${!paramsJson.isNullOrBlank()}}"""
}

View File

@@ -0,0 +1,6 @@
package ai.openclaw.app
object SensitiveFeatureConfig {
const val smsEnabled: Boolean = true
const val callLogEnabled: Boolean = true
}

View File

@@ -0,0 +1,39 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
class SmsHandler(
private val sms: SmsManager,
) {
suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.send(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
}
return errorResult(res.error, defaultCode = "SMS_SEND_FAILED")
}
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.search(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
}
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
}
private fun errorResult(
error: String?,
defaultCode: String,
): GatewaySession.InvokeResult {
val rawMessage = error ?: defaultCode
val idx = rawMessage.indexOf(':')
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode
val message =
if (idx > 0 && code == rawMessage.substring(0, idx).trim()) {
rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage }
} else {
rawMessage
}
return GatewaySession.InvokeResult.error(code = code, message = message)
}
}

View File

@@ -1,6 +1,6 @@
plugins {
id("com.android.test")
id("org.jlleitschuh.gradle.ktlint")
alias(libs.plugins.android.test)
alias(libs.plugins.ktlint)
}
android {
@@ -39,7 +39,7 @@ ktlint {
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
implementation("androidx.test.ext:junit:1.3.0")
implementation("androidx.test.uiautomator:uiautomator:2.4.0-beta02")
implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.uiautomator)
}

View File

@@ -1,7 +1,7 @@
plugins {
id("com.android.application") version "9.2.0" apply false
id("com.android.test") version "9.2.0" apply false
id("org.jlleitschuh.gradle.ktlint") version "14.2.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

View File

@@ -0,0 +1,74 @@
[versions]
agp = "9.2.0"
androidx-activity = "1.13.0"
androidx-benchmark = "1.4.1"
androidx-camera = "1.6.0"
androidx-compose-bom = "2026.04.01"
androidx-core = "1.18.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
androidx-security = "1.1.0"
androidx-test-ext = "1.3.0"
androidx-uiautomator = "2.4.0-beta02"
androidx-webkit = "1.15.0"
bcprov = "1.84"
commonmark = "0.28.0"
coroutines = "1.10.2"
dnsjava = "3.6.4"
junit = "4.13.2"
junit-vintage = "6.0.3"
kotest = "6.1.11"
ktlint-gradle = "14.2.0"
kotlin = "2.3.21"
material = "1.13.0"
okhttp = "5.3.2"
play-services-code-scanner = "16.1.0"
robolectric = "4.16.1"
serialization-json = "1.11.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" }
androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" }
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" }
dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" }
junit = { module = "junit:junit", version.ref = "junit" }
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" }
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
material = { module = "com.google.android.material:material", version.ref = "material" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-test = { id = "com.android.test", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }

View File

@@ -22,10 +22,12 @@ services:
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps
TZ: ${OPENCLAW_TZ:-UTC}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
- openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps
## Uncomment the lines below to enable sandbox isolation
## (agents.defaults.sandbox). Requires Docker CLI in the image
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
@@ -84,13 +86,18 @@ services:
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps
TZ: ${OPENCLAW_TZ:-UTC}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
- openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps
stdin_open: true
tty: true
init: true
entrypoint: ["node", "dist/index.js"]
depends_on:
- openclaw-gateway
volumes:
openclaw-plugin-runtime-deps:

View File

@@ -1,4 +1,4 @@
b1d76b9451b21434325e64d5bb531b9b995ba3bbf8f7b1628c09cce18f24c8e2 config-baseline.json
78888d302b2263583430e41b9811277aab91937201d4de90cfbd5761e9b95727 config-baseline.json
58e98b59498060d301104b3772332de5600eb674687b06d0d32a202370709ee0 config-baseline.core.json
a9f058ee9616e189dab7fc223e1207a49ae52b8490b8028935c9d0a2b16f81b2 config-baseline.channel.json
323a9fd49a669951ca5b3442d95aad243bd1330083f9857e83a8dcfae2bbc9d0 config-baseline.channel.json
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json

View File

@@ -105,6 +105,7 @@ openclaw gateway
```
If OpenClaw is already running as a background service, restart it via the OpenClaw Mac app or by stopping and restarting the `openclaw gateway run` process.
For managed service installs, run `openclaw gateway install` from a shell where `DISCORD_BOT_TOKEN` is present, or store the variable in `~/.openclaw/.env`, so the service can resolve the env SecretRef after restart.
</Step>

View File

@@ -81,7 +81,7 @@ Only the owner number (from `channels.whatsapp.allowFrom`, or the bots own E.
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.openclaw/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet.
- Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned).
- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies use the default message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins.
## Related

View File

@@ -45,6 +45,8 @@ That means the agent still processes the turn and can update memory/session stat
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.
Typing indicators are still sent while the agent works in tool-only mode. The default group typing mode is upgraded from "message" to "instant" for these turns because there may never be normal assistant message text before the agent decides whether to call the message tool. Explicit typing-mode config still wins.
To restore legacy automatic final replies for group/channel rooms:
```json5

View File

@@ -117,6 +117,27 @@ openclaw gateway
</Tab>
</Tabs>
## Socket Mode transport tuning
OpenClaw sets the Slack SDK client pong timeout to 15 seconds by default for Socket Mode. Override the transport settings only when you need workspace- or host-specific tuning:
```json5
{
channels: {
slack: {
mode: "socket",
socketMode: {
clientPingTimeout: 20000,
serverPingTimeout: 30000,
pingPongLoggingEnabled: false,
},
},
},
}
```
Use this only for Socket Mode workspaces that log Slack websocket pong/server-ping timeouts or run on hosts with known event-loop starvation. `clientPingTimeout` is the pong wait after the SDK sends a client ping; `serverPingTimeout` is the wait for Slack server pings. App messages and events remain application state, not transport liveness signals.
## Manifest and scope checklist
The base Slack app manifest is the same for Socket Mode and HTTP Request URLs. Only the `settings` block (and the slash command `url`) differs.
@@ -611,6 +632,8 @@ Notes:
<Accordion title="Inbound attachments">
Slack file attachments are downloaded from Slack-hosted private URLs (token-authenticated request flow) and written to the media store when fetch succeeds and size limits permit. File placeholders include the Slack `fileId` so agents can fetch the original file with `download-file`.
Downloads use bounded idle and total timeouts. If Slack file retrieval stalls or fails, OpenClaw keeps processing the message and falls back to the file placeholder.
Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`.
</Accordion>

View File

@@ -230,18 +230,29 @@ or overlapping changed hunks.
The `CodeQL` workflow is intentionally a narrow first-pass security scanner,
not the full repository sweep. Daily and manual runs scan Actions workflow code
plus the highest-risk JavaScript/TypeScript auth, secrets, sandbox, cron, and
gateway surfaces with high-precision security queries. Android and macOS remain
manual security shards so their runtime and alert quality can be tracked
separately.
gateway surfaces with high-precision security queries.
The `CodeQL Android Critical Security` workflow is the scheduled Android
security shard. It builds the Android app manually for CodeQL on the smallest
Blacksmith Linux runner label accepted by workflow sanity and uploads results
under the `/codeql-critical-security/android` category.
The `CodeQL macOS Critical Security` workflow is the weekly/manual macOS
security shard. It builds the macOS app manually for CodeQL on Blacksmith macOS,
filters dependency build results out of the uploaded SARIF, and uploads results
under the `/codeql-critical-security/macos` category. Keep it outside the daily
default workflow because the macOS build dominates runtime even when clean.
The `CodeQL Critical Quality` workflow is the matching non-security shard. It
runs only error-severity, non-security JavaScript/TypeScript quality queries
over the same narrow auth, secrets, sandbox, cron, and gateway surface. Keep it
separate from the security workflow so quality findings can be scheduled,
measured, disabled, or expanded without obscuring security signal. Swift,
Android, Python, UI, and bundled-plugin CodeQL expansion should be added back as
scoped or sharded follow-up work only after the narrow profiles have stable
runtime and signal.
over narrow high-value surfaces. Its baseline job scans the same auth, secrets,
sandbox, cron, and gateway surface as the security workflow. The plugin-boundary
job scans loader, registry, public-surface, and Plugin SDK entrypoint contracts
under a separate `/codeql-critical-quality/plugin-boundary` category. Keep the
workflow separate from security so quality findings can be scheduled, measured,
disabled, or expanded without obscuring security signal. Swift, Python, UI, and
bundled-plugin CodeQL expansion should be added back as scoped or sharded
follow-up work only after the narrow profiles have stable runtime and signal.
The `Docs Agent` workflow is an event-driven Codex maintenance lane for keeping
existing docs aligned with recently landed changes. It has no pure schedule: a

View File

@@ -183,6 +183,12 @@ Announce to a specific channel:
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
```
Announce to a Telegram forum topic:
```bash
openclaw cron edit <job-id> --announce --channel telegram --to "-1001234567890" --thread-id 42
```
Create an isolated job with lightweight bootstrap context:
```bash

View File

@@ -107,18 +107,18 @@ and the shared capability runtime before the provider request is made.
This table maps common inference tasks to the corresponding infer command.
| Task | Command | Notes |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------- |
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
| Describe an image file | `openclaw infer image describe --file ./image.png --json` | `--model` must be an image-capable `<provider/model>` |
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
| Generate a video | `openclaw infer video generate --prompt "..." --json` | Supports provider hints such as `--resolution` |
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
| Search the web | `openclaw infer web search --query "..." --json` | |
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
| Task | Command | Notes |
| ----------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------- |
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
| Describe an image file | `openclaw infer image describe --file ./image.png --prompt "..." --json` | `--model` must be an image-capable `<provider/model>` |
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
| Generate a video | `openclaw infer video generate --prompt "..." --json` | Supports provider hints such as `--resolution` |
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
| Search the web | `openclaw infer web search --query "..." --json` | |
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
## Behavior
@@ -176,8 +176,10 @@ openclaw infer image generate --prompt "slow image backend" --timeout-ms 180000
openclaw infer image edit --file ./logo.png --model openai/gpt-image-1.5 --output-format png --background transparent --prompt "keep the logo, remove the background" --json
openclaw infer image edit --file ./poster.png --prompt "make this a vertical story ad" --size 2160x3840 --aspect-ratio 9:16 --resolution 4K --json
openclaw infer image describe --file ./photo.jpg --json
openclaw infer image describe --file ./receipt.jpg --prompt "Extract the merchant, date, and total" --json
openclaw infer image describe-many --file ./before.png --file ./after.png --prompt "Compare the screenshots and list visible UI changes" --json
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --json
openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --prompt "Describe the image in one sentence" --timeout-ms 300000 --json
```
Notes:
@@ -208,6 +210,8 @@ Notes:
output paths. When `--output` is set, the final extension may follow the
provider's returned MIME type.
- For `image describe` and `image describe-many`, use `--prompt` to give the vision model a task-specific instruction such as OCR, comparison, UI inspection, or concise captioning.
- Use `--timeout-ms` with slow local vision models or cold Ollama starts.
- For `image describe`, `--model` must be an image-capable `<provider/model>`.
- For local Ollama vision models, pull the model first and set `OLLAMA_API_KEY` to any placeholder value, for example `ollama-local`. See [Ollama](/providers/ollama#vision-and-image-description).

View File

@@ -68,10 +68,18 @@ Inspect current vault mode, health, and Obsidian CLI availability.
Use this first when you are unsure whether the vault is initialized, bridge mode
is healthy, or Obsidian integration is available.
When bridge mode is active and configured to read memory artifacts, this command
queries the running Gateway so it sees the same active memory plugin context as
agent/runtime memory.
### `wiki doctor`
Run wiki health checks and surface configuration or vault problems.
When bridge mode is active and configured to read memory artifacts, this command
queries the running Gateway before building the report. Disabled bridge imports
and bridge configs that do not read memory artifacts remain local/offline.
Typical issues include:
- bridge mode enabled without public memory artifacts
@@ -168,6 +176,11 @@ source pages.
Use this in `bridge` mode when you want the latest exported memory artifacts
pulled into the wiki vault.
For active bridge artifact reads, the CLI routes the import through Gateway RPC
so the import uses the runtime memory plugin context. If bridge imports are
disabled or artifact reads are turned off, the command keeps the local/offline
zero-import behavior.
### `wiki unsafe-local import`
Import from explicitly configured local paths in `unsafe-local` mode.

View File

@@ -674,6 +674,7 @@ Example (OpenAIcompatible):
- For slow local models or remote LAN/tailnet hosts, set `models.providers.<id>.timeoutSeconds`. This extends provider model HTTP request handling, including connect, headers, body streaming, and the total guarded-fetch abort, without increasing the whole agent runtime timeout.
- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`).
- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints.
- For `api: "anthropic-messages"` on non-direct endpoints (any provider other than canonical `anthropic`, or a custom `models.providers.anthropic.baseUrl` whose host is not a public `api.anthropic.com` endpoint), OpenClaw suppresses implicit Anthropic beta headers such as `claude-code-20250219`, `interleaved-thinking-2025-05-14`, and OAuth markers, so custom Anthropic-compatible proxies do not reject unsupported beta flags. Set `models.providers.<id>.headers["anthropic-beta"]` explicitly if your proxy needs specific beta features.
</Accordion>
</AccordionGroup>

View File

@@ -224,6 +224,27 @@ Serialization notes:
rotation does not cut the stored CLI session. If a CLI does not expose a
stable OAuth account id, OpenClaw lets that CLI enforce resume permissions.
## Fallback prelude from claude-cli sessions
When a `claude-cli` attempt fails over to a non-CLI candidate in
[`agents.defaults.model.fallbacks`](/concepts/model-failover), OpenClaw seeds
the next attempt with a context prelude harvested from Claude Code's local
JSONL transcript at `~/.claude/projects/`. Without this seed, the fallback
provider would start cold because OpenClaw's own session transcript is empty
for `claude-cli` runs.
- The prelude prefers the latest `/compact` summary or `compact_boundary`
marker, then appends the most recent post-boundary turns up to a char
budget. Pre-boundary turns are dropped because the summary already represents
them.
- Tool blocks are coalesced to compact `(tool call: name)` and
`(tool result: …)` hints to keep the prompt budget honest. The summary is
labeled `(truncated)` if it overflows.
- Same-provider `claude-cli` to `claude-cli` fallbacks rely on Claude's own
`--resume` and skip the prelude.
- The seed reuses the existing Claude session-file path validation, so
arbitrary paths cannot be read.
## Images (pass-through)
If your CLI accepts image paths, set `imageArg`:

View File

@@ -125,8 +125,9 @@ knob.
`agents.defaults.bootstrapTotalMaxChars`:
normal workspace bootstrap injection.
- `agents.defaults.startupContext.*`:
one-shot `/new` and `/reset` startup prelude, including recent daily
`memory/*.md` files.
one-shot reset/startup model-run prelude, including recent daily
`memory/*.md` files. Bare chat `/new` and `/reset` commands are
acknowledged without invoking the model.
- `skills.limits.*`:
the compact skills list injected into the system prompt.
- `agents.defaults.contextLimits.*`:
@@ -142,8 +143,9 @@ budget:
#### `agents.defaults.startupContext`
Controls the first-turn startup prelude injected on bare `/new` and `/reset`
runs.
Controls the first-turn startup prelude injected on reset/startup model runs.
Bare chat `/new` and `/reset` commands acknowledge the reset without invoking
the model, so they do not load this prelude.
```json5
{
@@ -340,6 +342,7 @@ Time format in system prompt. Default: `auto` (OS preference).
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
- Used by the `image` tool path as its vision-model config.
- Also used as fallback routing when the selected/default model cannot accept image input.
- Prefer explicit `provider/model` refs. Bare IDs are accepted for compatibility; if a bare ID uniquely matches a configured image-capable entry in `models.providers.*.models`, OpenClaw qualifies it to that provider. Ambiguous configured matches require an explicit provider prefix.
- `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
- Used by the shared image-generation capability and any future tool/plugin surface that generates images.
- Typical values: `google/gemini-3.1-flash-image-preview` for native Gemini image generation, `fal/fal-ai/flux/dev` for fal, `openai/gpt-image-2` for OpenAI Images, or `openai/gpt-image-1.5` for transparent-background OpenAI PNG/WebP output.

View File

@@ -390,6 +390,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
socketMode: {
clientPingTimeout: 15000,
serverPingTimeout: 30000,
pingPongLoggingEnabled: false,
},
dmPolicy: "pairing",
allowFrom: ["U123", "U456", "*"],
dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
@@ -448,6 +453,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
strings or SecretRef objects.
- Slack account snapshots expose per-credential source/status fields such as

View File

@@ -451,7 +451,7 @@ See [Plugins](/tools/plugin).
- `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`.
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades.
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and allowlist evaluation.
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `allowCommands` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, and `screen.record`; `denyCommands` removes a command even if a platform default or explicit allow would otherwise include it. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
- `gateway.tools.allow`: remove tool names from the default HTTP deny list.

View File

@@ -89,6 +89,8 @@ SSH-specific config lives under `agents.defaults.sandbox.ssh`. OpenShell-specifi
Sandboxing is off by default. If you enable sandboxing and do not choose a backend, OpenClaw uses the Docker backend. It executes tools and sandbox browsers locally via the Docker daemon socket (`/var/run/docker.sock`). Sandbox container isolation is determined by Docker namespaces.
To expose host GPUs to Docker sandboxes, set `agents.defaults.sandbox.docker.gpus` or the per-agent `agents.list[].sandbox.docker.gpus` override. The value is passed to Docker's `--gpus` flag as a separate argument, for example `"all"` or `"device=GPU-uuid"`, and requires a compatible host runtime such as NVIDIA Container Toolkit.
<Warning>
**Docker-out-of-Docker (DooD) constraints**
@@ -369,6 +371,8 @@ Default Docker image: `openclaw-sandbox:bookworm-slim`
The default image does **not** include Node. If a skill needs Node (or other runtimes), either bake a custom image or install via `sandbox.docker.setupCommand` (requires network egress + writable root + root user).
OpenClaw does not silently substitute plain `debian:bookworm-slim` when `openclaw-sandbox:bookworm-slim` is missing. Sandbox runs that target the default image fail fast with a build instruction until you run `scripts/sandbox-setup.sh`, because the bundled image carries `python3` for sandbox write/edit helpers.
</Step>
<Step title="Optional: build the common image">
For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`):

View File

@@ -116,18 +116,19 @@ Expected output:
OpenClaw runs in Docker, but Docker is not the source of truth.
All long-lived state must survive restarts, rebuilds, and reboots.
| Component | Location | Persistence mechanism | Notes |
| ------------------- | --------------------------------- | ---------------------- | ------------------------------------------------------------- |
| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, `.env` |
| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents/<agentId>/agent/auth-profiles.json` (OAuth, API keys) |
| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
| Component | Location | Persistence mechanism | Notes |
| ------------------- | ---------------------------------------- | ---------------------- | ------------------------------------------------------------- |
| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, `.env` |
| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents/<agentId>/agent/auth-profiles.json` (OAuth, API keys) |
| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| Plugin runtime deps | `/var/lib/openclaw/plugin-runtime-deps/` | Docker named volume | Generated bundled plugin deps and runtime mirrors |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
## Updates

View File

@@ -129,6 +129,7 @@ The setup script accepts these optional environment variables:
| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_PLUGIN_STAGE_DIR` | Container path for generated bundled plugin deps and mirrors |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path |
| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) |
@@ -267,11 +268,24 @@ That mounted config directory is where OpenClaw keeps:
- `agents/<agentId>/agent/auth-profiles.json` for stored provider OAuth/API-key auth
- `.env` for env-backed runtime secrets such as `OPENCLAW_GATEWAY_TOKEN`
Bundled plugin runtime dependencies and mirrored runtime files are generated
state, not user config. Compose stores them in the named Docker volume
`openclaw-plugin-runtime-deps` mounted at
`/var/lib/openclaw/plugin-runtime-deps`. Keeping that high-churn tree out of the
host config bind mount avoids slow Docker Desktop/WSL file operations and stale
Windows handles during cold Gateway startup.
The default Compose file sets `OPENCLAW_PLUGIN_STAGE_DIR` to that path for both
`openclaw-gateway` and `openclaw-cli`, so `openclaw doctor --fix`, channel
login/setup commands, and Gateway startup all use the same generated runtime
volume.
For full persistence details on VM deployments, see
[Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where).
**Disk growth hotspots:** watch `media/`, session JSONL files, `cron/runs/*.jsonl`,
and rolling file logs under `/tmp/openclaw/`.
the `openclaw-plugin-runtime-deps` Docker volume, and rolling file logs under
`/tmp/openclaw/`.
### Shell helpers (optional)

View File

@@ -188,6 +188,23 @@ openclaw nodes invoke --node <idOrNameOrIp> --command canvas.eval --params '{"ja
Higher-level helpers exist for the common “give the agent a MEDIA attachment” workflows.
## Command policy
Node commands must pass two gates before they can be invoked:
1. The node must declare the command in its WebSocket `connect.commands` list.
2. The gateway's platform policy must allow the declared command.
Windows and macOS companion nodes allow safe declared commands such as
`canvas.*`, `camera.list`, `location.get`, and `screen.snapshot` by default.
Dangerous or privacy-heavy commands such as `camera.snap`, `camera.clip`, and
`screen.record` still require explicit opt-in with
`gateway.nodes.allowCommands`. `gateway.nodes.denyCommands` always wins over
defaults and extra allowlist entries.
After a node changes its declared command list, reject the old device pairing
and approve the new request so the gateway stores the updated command snapshot.
## Screenshots (canvas snapshots)
If the node is showing the Canvas (WebView), `canvas.snapshot` returns `{ format, base64 }`.

View File

@@ -172,6 +172,7 @@ If `tools.media.<capability>.enabled` is **not** set to `false` and you haven't
</Step>
<Step title="agents.defaults.imageModel">
`agents.defaults.imageModel` primary/fallback refs (image only).
Prefer `provider/model` refs. Bare refs are qualified from configured image-capable provider model entries only when the match is unique.
</Step>
<Step title="Local CLIs (audio only)">
Local CLIs (if installed):

View File

@@ -65,6 +65,12 @@ If bridge mode reports zero exported artifacts, the active memory plugin is not
currently exposing public bridge inputs yet. Run `openclaw wiki doctor` first,
then confirm the active memory plugin supports public artifacts.
When bridge mode is active and `bridge.readMemoryArtifacts` is enabled,
`openclaw wiki status`, `openclaw wiki doctor`, and `openclaw wiki bridge
import` read through the running Gateway. That keeps CLI bridge checks aligned
with the runtime memory plugin context. If bridge is disabled or artifact reads
are turned off, those commands keep their local/offline behavior.
## Vault modes
`memory-wiki` supports three vault modes:

View File

@@ -84,17 +84,17 @@ calls can continue.
## Thinking and tools
DeepSeek V4 thinking sessions have a stricter replay contract than most
OpenAI-compatible providers: when a thinking-enabled assistant message includes
tool calls, DeepSeek expects the prior assistant `reasoning_content` to be sent
back on the follow-up request. OpenClaw handles this inside the DeepSeek plugin,
so normal multi-turn tool use works with `deepseek/deepseek-v4-flash` and
`deepseek/deepseek-v4-pro`.
OpenAI-compatible providers: after a thinking-enabled turn uses tools, DeepSeek
expects replayed assistant messages from that turn to include
`reasoning_content` on follow-up requests. OpenClaw handles this inside the
DeepSeek plugin, so normal multi-turn tool use works with
`deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro`.
If you switch an existing session from another OpenAI-compatible provider to a
DeepSeek V4 model, older assistant tool-call turns may not have native
DeepSeek `reasoning_content`. OpenClaw fills that missing field for DeepSeek V4
thinking requests so the provider can accept the replayed tool-call history
without requiring `/new`.
DeepSeek `reasoning_content`. OpenClaw fills that missing field on replayed
assistant messages for DeepSeek V4 thinking requests so the provider can accept
the history without requiring `/new`.
When thinking is disabled in OpenClaw (including the UI **None** selection),
OpenClaw sends DeepSeek `thinking: { type: "disabled" }` and strips replayed

View File

@@ -283,6 +283,8 @@ To make Ollama the default image-understanding model for inbound media, configur
}
```
Prefer the full `ollama/<model>` ref. If the same model is listed under `models.providers.ollama.models` with `input: ["text", "image"]` and no other configured image provider exposes that bare model ID, OpenClaw also normalizes a bare `imageModel` ref such as `qwen2.5vl:7b` to `ollama/qwen2.5vl:7b`. If more than one configured image provider has the same bare ID, use the provider prefix explicitly.
Slow local vision models can need a longer image-understanding timeout than cloud models. They can also crash or stop when Ollama tries to allocate the full advertised vision context on constrained hardware. Set a capability timeout, and cap `num_ctx` on the model entry when you only need a normal image-description turn:
```json5
@@ -859,7 +861,7 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
}
```
Per-model `params.think` or `params.thinking` can disable or force Ollama API thinking for a specific configured model. Runtime commands such as `/think off` still apply to the active run.
Per-model `params.think` or `params.thinking` can disable or force Ollama API thinking for a specific configured model. OpenClaw preserves those explicit model params when the active run only has the implicit default `off`; non-off runtime commands such as `/think medium` still override the active run.
</Accordion>
@@ -934,7 +936,7 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
<Accordion title="Streaming configuration">
OpenClaw's Ollama integration uses the **native Ollama API** (`/api/chat`) by default, which fully supports streaming and tool calling simultaneously. No special configuration is needed.
For native `/api/chat` requests, OpenClaw also forwards thinking control directly to Ollama: `/think off` and `openclaw agent --thinking off` send top-level `think: false`, while `/think low|medium|high` send the matching top-level `think` effort string. `/think max` maps to Ollama's highest native effort, `think: "high"`.
For native `/api/chat` requests, OpenClaw also forwards thinking control directly to Ollama: `/think off` and `openclaw agent --thinking off` send top-level `think: false` unless an explicit model `params.think`/`params.thinking` value is configured, while `/think low|medium|high` send the matching top-level `think` effort string. `/think max` maps to Ollama's highest native effort, `think: "high"`.
<Tip>
If you need to use the OpenAI-compatible endpoint, see the "Legacy OpenAI-compatible mode" section above. Streaming and tool calling may not work simultaneously in that mode.
@@ -1060,6 +1062,18 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
</Accordion>
<Accordion title="Kimi or GLM returns garbled symbols">
Hosted Kimi/GLM responses that are long, non-linguistic symbol runs are treated as failed provider output instead of a successful assistant answer. That lets normal retry, fallback, or error handling take over without persisting the corrupted text into the session.
If it happens repeatedly, capture the raw model name, the current session file, and whether the run used `Cloud + Local` or `Cloud only`, then try a fresh session and a fallback model:
```bash
openclaw infer model run --model ollama/kimi-k2.5:cloud --prompt "Reply with exactly: ok" --json
openclaw models set ollama/gemma4
```
</Accordion>
<Accordion title="Cold local model times out">
Large local models can need a long first load before streaming begins. Keep the timeout scoped to the Ollama provider, and optionally ask Ollama to keep the model loaded between turns:

View File

@@ -4,6 +4,7 @@ read_when:
- You want a single API key for many LLMs
- You want to run models via OpenRouter in OpenClaw
- You want to use OpenRouter for image generation
- You want to use OpenRouter for video generation
title: "OpenRouter"
---
@@ -78,6 +79,33 @@ OpenRouter can also back the `image_generate` tool. Use an OpenRouter image mode
OpenClaw sends image requests to OpenRouter's chat completions image API with `modalities: ["image", "text"]`. Gemini image models receive supported `aspectRatio` and `resolution` hints through OpenRouter's `image_config`. Use `agents.defaults.imageGenerationModel.timeoutMs` for slower OpenRouter image models; the `image_generate` tool's per-call `timeoutMs` parameter still wins.
## Video generation
OpenRouter can also back the `video_generate` tool through its asynchronous `/videos` API. Use an OpenRouter video model under `agents.defaults.videoGenerationModel`:
```json5
{
env: { OPENROUTER_API_KEY: "sk-or-..." },
agents: {
defaults: {
videoGenerationModel: {
primary: "openrouter/google/veo-3.1-fast",
},
},
},
}
```
OpenClaw submits text-to-video and image-to-video jobs to OpenRouter, polls
the returned `polling_url`, and downloads the completed video from
OpenRouter's `unsigned_urls` or the documented job content endpoint.
Reference images are sent as first/last frame images by default; images
tagged with `reference_image` are sent as OpenRouter input references. The
bundled `google/veo-3.1-fast` default advertises the currently supported 4/6/8
second durations, `720P`/`1080P` resolutions, and `16:9`/`9:16` aspect
ratios. Video-to-video is not registered for OpenRouter because the upstream
video generation API currently accepts text and image references.
## Text-to-speech
OpenRouter can also be used as a TTS provider through its OpenAI-compatible

View File

@@ -127,9 +127,9 @@ Use the table below to pick the right model for your use case.
If Venice exposes DeepSeek V4 models such as `venice/deepseek-v4-pro` or
`venice/deepseek-v4-flash`, OpenClaw fills the required DeepSeek V4
`reasoning_content` replay placeholder on assistant tool-call turns when the
proxy omits it. Venice rejects DeepSeek's native top-level `thinking` control,
so OpenClaw keeps that provider-specific replay fix separate from the native
`reasoning_content` replay placeholder on assistant messages when the proxy
omits it. Venice rejects DeepSeek's native top-level `thinking` control, so
OpenClaw keeps that provider-specific replay fix separate from the native
DeepSeek provider's thinking controls.
## Built-in catalog (41 total)

View File

@@ -16,6 +16,7 @@ title: "Tests"
- `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process.
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store. Docker/Bash E2E lanes can use `scripts/lib/openclaw-test-state.mjs shell --label <name> --scenario <name>` for an in-container shell snippet, or `node scripts/lib/openclaw-test-state.mjs -- create --label <name> --scenario <name> --env-file <path> --json` for a sourceable host env file. The `--` before `create` keeps newer Node runtimes from treating `--env-file` as a Node flag.
- Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Source files with sibling tests map to that sibling before falling back to wider directory globs. Helper edits under `src/channels/plugins/contracts/test-helpers`, `src/plugin-sdk/test-helpers`, and `src/plugins/contracts` use a local import graph to run importing tests instead of broad-running every shard when the dependency path is precise.
@@ -34,7 +35,7 @@ title: "Tests"
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
- `pnpm test:docker:all`: Builds the shared live-test image, packs OpenClaw once as an npm tarball, builds/reuses a bare Node/Git runner image plus a functional image that installs that tarball into `/app`, then runs Docker smoke lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1` through a weighted scheduler. The bare image (`OPENCLAW_DOCKER_E2E_BARE_IMAGE`) is used for installer/update/plugin-dependency lanes; those lanes mount the prebuilt tarball instead of using copied repo sources. The functional image (`OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`) is used for normal built-app functionality lanes. `scripts/package-openclaw-for-docker.mjs` is the single local/CI package packer and validates the tarball plus `dist/postinstall-inventory.json` before Docker consumes it. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. `node scripts/test-docker-all.mjs --plan-json` emits the scheduler-owned CI plan for selected lanes, image kinds, package/live-image needs, and credential checks without building or running Docker. `OPENCLAW_DOCKER_ALL_PARALLELISM=<n>` controls process slots and defaults to 10; `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM=<n>` controls the provider-sensitive tail pool and defaults to 10. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; provider caps default to one heavy lane per provider via `OPENCLAW_DOCKER_ALL_LIVE_CLAUDE_LIMIT=4`, `OPENCLAW_DOCKER_ALL_LIVE_CODEX_LIMIT=4`, and `OPENCLAW_DOCKER_ALL_LIVE_GEMINI_LIMIT=4`. Use `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` for larger hosts. If one lane exceeds the effective weight or resource cap on a low-parallelism host, it can still start from an empty pool and will run alone until it releases capacity. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=<ms>`. The runner preflights Docker by default, cleans stale OpenClaw E2E containers, emits active-lane status every 30 seconds, shares provider CLI tool caches between compatible lanes, retries transient live-provider failures once by default (`OPENCLAW_DOCKER_ALL_LIVE_RETRIES=<n>`), and stores lane timings in `.artifacts/docker-tests/lane-timings.json` for longest-first ordering on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the lane manifest without running Docker, `OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS=<ms>` to tune status output, or `OPENCLAW_DOCKER_ALL_TIMINGS=0` to disable timing reuse. Use `OPENCLAW_DOCKER_ALL_LIVE_MODE=skip` for deterministic/local lanes only or `OPENCLAW_DOCKER_ALL_LIVE_MODE=only` for live-provider lanes only; package aliases are `pnpm test:docker:local:all` and `pnpm test:docker:live:all`. Live-only mode merges main and tail live lanes into one longest-first pool so provider buckets can pack Claude, Codex, and Gemini work together. The runner stops scheduling new pooled lanes after the first failure unless `OPENCLAW_DOCKER_ALL_FAIL_FAST=0` is set, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. CLI backend Docker setup commands have their own timeout via `OPENCLAW_LIVE_CLI_BACKEND_SETUP_TIMEOUT_SECONDS` (default 180). Per-lane logs, `summary.json`, `failures.json`, and phase timings are written under `.artifacts/docker-tests/<run-id>/`; use `pnpm test:docker:timings <summary.json>` to inspect slow lanes and `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands.
- `pnpm test:docker:all`: Builds the shared live-test image, packs OpenClaw once as an npm tarball, builds/reuses a bare Node/Git runner image plus a functional image that installs that tarball into `/app`, then runs Docker smoke lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1` through a weighted scheduler. The bare image (`OPENCLAW_DOCKER_E2E_BARE_IMAGE`) is used for installer/update/plugin-dependency lanes; those lanes mount the prebuilt tarball instead of using copied repo sources. The functional image (`OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`) is used for normal built-app functionality lanes. `scripts/package-openclaw-for-docker.mjs` is the single local/CI package packer and validates the tarball plus `dist/postinstall-inventory.json` before Docker consumes it. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. `node scripts/test-docker-all.mjs --plan-json` emits the scheduler-owned CI plan for selected lanes, image kinds, package/live-image needs, state scenarios, and credential checks without building or running Docker. `OPENCLAW_DOCKER_ALL_PARALLELISM=<n>` controls process slots and defaults to 10; `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM=<n>` controls the provider-sensitive tail pool and defaults to 10. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; provider caps default to one heavy lane per provider via `OPENCLAW_DOCKER_ALL_LIVE_CLAUDE_LIMIT=4`, `OPENCLAW_DOCKER_ALL_LIVE_CODEX_LIMIT=4`, and `OPENCLAW_DOCKER_ALL_LIVE_GEMINI_LIMIT=4`. Use `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` for larger hosts. If one lane exceeds the effective weight or resource cap on a low-parallelism host, it can still start from an empty pool and will run alone until it releases capacity. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=<ms>`. The runner preflights Docker by default, cleans stale OpenClaw E2E containers, emits active-lane status every 30 seconds, shares provider CLI tool caches between compatible lanes, retries transient live-provider failures once by default (`OPENCLAW_DOCKER_ALL_LIVE_RETRIES=<n>`), and stores lane timings in `.artifacts/docker-tests/lane-timings.json` for longest-first ordering on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the lane manifest without running Docker, `OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS=<ms>` to tune status output, or `OPENCLAW_DOCKER_ALL_TIMINGS=0` to disable timing reuse. Use `OPENCLAW_DOCKER_ALL_LIVE_MODE=skip` for deterministic/local lanes only or `OPENCLAW_DOCKER_ALL_LIVE_MODE=only` for live-provider lanes only; package aliases are `pnpm test:docker:local:all` and `pnpm test:docker:live:all`. Live-only mode merges main and tail live lanes into one longest-first pool so provider buckets can pack Claude, Codex, and Gemini work together. The runner stops scheduling new pooled lanes after the first failure unless `OPENCLAW_DOCKER_ALL_FAIL_FAST=0` is set, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. CLI backend Docker setup commands have their own timeout via `OPENCLAW_LIVE_CLI_BACKEND_SETUP_TIMEOUT_SECONDS` (default 180). Per-lane logs, `summary.json`, `failures.json`, and phase timings are written under `.artifacts/docker-tests/<run-id>/`; use `pnpm test:docker:timings <summary.json>` to inspect slow lanes and `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands.
- `pnpm test:docker:browser-cdp-snapshot`: Builds a Chromium-backed source E2E container, starts raw CDP plus an isolated Gateway, runs `browser doctor --deep`, and verifies CDP role snapshots include link URLs, cursor-promoted clickables, iframe refs, and frame metadata.
- CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:codex`, `pnpm test:docker:live-cli-backend:codex:resume`, or `pnpm test:docker:live-cli-backend:codex:mcp`. Claude and Gemini have matching `:resume` and `:mcp` aliases.
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.

View File

@@ -21,7 +21,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
with optional per-agent override at
`agents.list[].skillsLimits.maxSkillsPromptChars`.
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@@ -159,7 +159,7 @@ Example:
- Session files: `~/.openclaw/agents/<agentId>/sessions/{{SessionId}}.jsonl`
- Session metadata (token usage, last route, etc): `~/.openclaw/agents/<agentId>/sessions/sessions.json` (legacy: `~/.openclaw/sessions/sessions.json`)
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset.
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, OpenClaw acknowledges the reset without invoking the model.
- `/compact [instructions]` compacts the session context and reports the remaining context budget.
## Heartbeats (proactive mode)

View File

@@ -61,6 +61,7 @@ provider is configured.
| MiniMax | ✓ | ✓ | ✓ | ✓ | | | |
| Mistral | | | | | ✓ | | |
| OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
| OpenRouter | ✓ | ✓ | | ✓ | | | ✓ |
| Qwen | | ✓ | | | | | |
| Runway | | ✓ | | | | | |
| SenseAudio | | | | | ✓ | | |

View File

@@ -324,6 +324,10 @@ under `skills.entries` in `~/.openclaw/openclaw.json`:
<ParamField path="enabled" type="boolean">
`false` disables the skill even if it is bundled or installed.
The bundled `coding-agent` skill is opt-in: set
`skills.entries.coding-agent.enabled: true` before exposing it to agents,
then make sure one of `claude`, `codex`, `opencode`, or `pi` is installed and
authenticated for its own CLI.
</ParamField>
<ParamField path="apiKey" type='string | { source, provider, id }'>
Convenience for skills that declare `metadata.openclaw.primaryEnv`. Supports plaintext or SecretRef.

View File

@@ -1,5 +1,5 @@
---
summary: "Generate videos via video_generate from text, image, or video references across 14 provider backends"
summary: "Generate videos via video_generate from text, image, or video references across 16 provider backends"
read_when:
- Generating videos via the agent
- Configuring video-generation providers and models
@@ -9,7 +9,7 @@ sidebarTitle: "Video generation"
---
OpenClaw agents can generate videos from text prompts, reference images, or
existing videos. Fifteen provider backends are supported, each with
existing videos. Sixteen provider backends are supported, each with
different model options, input modes, and feature sets. The agent picks the
right provider automatically based on your configuration and available API
keys.
@@ -116,6 +116,7 @@ generation.
| Google | `veo-3.1-fast-generate-preview` | ✓ | 1 image | 1 video | `GEMINI_API_KEY` |
| MiniMax | `MiniMax-Hailuo-2.3` | ✓ | 1 image | — | `MINIMAX_API_KEY` or MiniMax OAuth |
| OpenAI | `sora-2` | ✓ | 1 image | 1 video | `OPENAI_API_KEY` |
| OpenRouter | `google/veo-3.1-fast` | ✓ | Up to 4 images (first/last frame or references) | — | `OPENROUTER_API_KEY` |
| Qwen | `wan2.6-t2v` | ✓ | Yes (remote URL) | Yes (remote URL) | `QWEN_API_KEY` |
| Runway | `gen4.5` | ✓ | 1 image | 1 video | `RUNWAYML_API_SECRET` |
| Together | `Wan-AI/Wan2.2-T2V-A14B` | ✓ | 1 image | — | `TOGETHER_API_KEY` |
@@ -133,21 +134,22 @@ runtime modes at runtime.
The explicit mode contract used by `video_generate`, contract tests, and
the shared live sweep:
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| --------- | :--------: | :------------: | :------------: | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Alibaba | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | ✓ | ✓ | — | `generate`, `imageToVideo` |
| ComfyUI | ✓ | ✓ | — | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| DeepInfra | ✓ | — | — | `generate`; native DeepInfra video schemas are text-to-video in the bundled contract |
| fal | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` only when using Seedance reference-to-video |
| Google | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
| MiniMax | ✓ | ✓ | — | `generate`, `imageToVideo` |
| OpenAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
| Qwen | ✓ | ✓ | | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | ✓ | ✓ | | `generate`, `imageToVideo` |
| Vydra | ✓ | ✓ | — | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
| xAI | ✓ | ✓ | | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| ---------- | :--------: | :------------: | :------------: | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Alibaba | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | ✓ | ✓ | — | `generate`, `imageToVideo` |
| ComfyUI | ✓ | ✓ | — | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| DeepInfra | ✓ | — | — | `generate`; native DeepInfra video schemas are text-to-video in the bundled contract |
| fal | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` only when using Seedance reference-to-video |
| Google | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
| MiniMax | ✓ | ✓ | — | `generate`, `imageToVideo` |
| OpenAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
| OpenRouter | ✓ | ✓ | | `generate`, `imageToVideo` |
| Qwen | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | ✓ | ✓ | | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | ✓ | ✓ | — | `generate`, `imageToVideo` |
| Vydra | ✓ | ✓ | | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
## Tool parameters
@@ -389,6 +391,13 @@ only the explicit `model`, `primary`, and `fallbacks` entries.
(`aspectRatio`, `resolution`, `audio`, `watermark`) are ignored with
a warning.
</Accordion>
<Accordion title="OpenRouter">
Uses OpenRouter's asynchronous `/videos` API. OpenClaw submits the
job, polls `polling_url`, and downloads either `unsigned_urls` or the
documented job content endpoint. The bundled `google/veo-3.1-fast` default
advertises 4/6/8 second durations, `720P`/`1080P` resolutions, and
`16:9`/`9:16` aspect ratios.
</Accordion>
<Accordion title="Qwen">
Same DashScope backend as Alibaba. Reference inputs must be remote
`http(s)` URLs; local files are rejected upfront.

View File

@@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -59,6 +59,27 @@ describe("codex plugin", () => {
expect(onConversationBindingResolved).toHaveBeenCalledWith(expect.any(Function));
});
it("registers with capture APIs that do not expose conversation binding hooks yet", () => {
const api = createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerProvider: vi.fn(),
on: vi.fn(),
}) as ReturnType<typeof createTestPluginApi> & {
onConversationBindingResolved?: ReturnType<typeof vi.fn>;
};
delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved;
expect(() => plugin.register(api)).not.toThrow();
});
it("only claims the codex provider by default", () => {
const harness = createCodexAppServerAgentHarness();

View File

@@ -34,6 +34,6 @@ export default definePluginEntry({
pluginConfig: resolveCurrentPluginConfig(),
}),
);
api.onConversationBindingResolved(handleCodexConversationBindingResolved);
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
},
});

View File

@@ -176,4 +176,59 @@ describeLive("deepseek plugin live", () => {
});
expect(extractNonEmptyAssistantText(result.content).length).toBeGreaterThan(0);
}, 60_000);
it("accepts V4 thinking replay after a prior plain assistant message", async () => {
const context: Context = {
messages: [
{
role: "user",
content: "Say hello.",
timestamp: Date.now() - 2,
},
{
role: "assistant",
api: "openai-completions",
provider: "openai",
model: "gpt-5.4",
content: [{ type: "text", text: "Hello." }],
usage: ZERO_USAGE,
stopReason: "stop",
timestamp: Date.now() - 1,
},
{
role: "user",
content: "Reply with exactly: ok",
timestamp: Date.now(),
},
],
};
let capturedPayload: Record<string, unknown> | undefined;
const streamFn = createDeepSeekV4ThinkingWrapper(streamSimple, "high");
expect(streamFn).toBeDefined();
const stream = streamFn?.(resolveDeepSeekV4LiveModel(), context, {
apiKey: DEEPSEEK_KEY,
maxTokens: 64,
onPayload: (payload) => {
capturedPayload = payload as Record<string, unknown>;
},
});
expect(stream).toBeDefined();
const result = await (await stream!).result();
if (result.stopReason === "error") {
throw new Error(
result.errorMessage || "DeepSeek V4 plain replay returned error with no message",
);
}
const messages = capturedPayload?.messages;
expect(Array.isArray(messages)).toBe(true);
expect((messages as Array<Record<string, unknown>>)[1]).toMatchObject({
role: "assistant",
content: "Hello.",
reasoning_content: "",
});
expect(extractNonEmptyAssistantText(result.content).length).toBeGreaterThan(0);
}, 60_000);
});

View File

@@ -309,6 +309,73 @@ describe("deepseek provider plugin", () => {
});
});
it("adds blank reasoning_content for replayed plain assistant messages", async () => {
let capturedPayload: Record<string, unknown> | undefined;
const model = {
provider: "deepseek",
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 384_000,
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
} as Model<"openai-completions">;
const context = {
messages: [
{ role: "user", content: "hi", timestamp: 1 },
{
role: "assistant",
api: "openai-completions",
provider: "openai",
model: "gpt-5.4",
content: [{ type: "text", text: "Hello." }],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
{ role: "user", content: "next", timestamp: 3 },
],
} as Context;
const baseStreamFn = (
streamModel: Model<"openai-completions">,
streamContext: Context,
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
) => {
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
reasoning: "high",
} as never);
options?.onPayload?.(capturedPayload, streamModel);
const stream = createAssistantMessageEventStream();
queueMicrotask(() => stream.end());
return stream;
};
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
expect(wrapThinkingHigh).toBeDefined();
await wrapThinkingHigh?.(model, context, {});
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
role: "assistant",
content: "Hello.",
reasoning_content: "",
});
});
it("strips replayed reasoning_content when DeepSeek V4 thinking is disabled", async () => {
let capturedPayload: Record<string, unknown> | undefined;
const model = {

View File

@@ -543,13 +543,11 @@ const UpdateRecordSchema = Type.Object({
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
return;
}
@@ -732,6 +730,4 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
);
},
});
api.logger.debug?.("feishu_bitable: Registered bitable tools");
}

View File

@@ -123,20 +123,17 @@ export async function getFeishuMemberInfo(
export function registerFeishuChatTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_chat: No config available, skipping chat tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools");
return;
}
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
if (!toolsCfg.chat) {
api.logger.debug?.("feishu_chat: chat tool disabled in config");
return;
}
@@ -188,6 +185,4 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
},
{ name: "feishu_chat" },
);
api.logger.debug?.("feishu_chat: Registered feishu_chat tool");
}

View File

@@ -1386,14 +1386,12 @@ async function listAppScopes(client: Lark.Client) {
export function registerFeishuDocTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
return;
}
// Check if any account is configured
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
return;
}
@@ -1615,8 +1613,4 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
);
registered.push("feishu_app_scopes");
}
if (registered.length > 0) {
api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
}
}

View File

@@ -733,19 +733,16 @@ export async function deliverCommentThreadText(
export function registerFeishuDriveTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_drive: No config available, skipping drive tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.drive) {
api.logger.debug?.("feishu_drive: drive tool disabled in config");
return;
}
@@ -829,6 +826,4 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
},
{ name: "feishu_drive" },
);
api.logger.debug?.(`feishu_drive: Registered feishu_drive tool`);
}

View File

@@ -114,19 +114,16 @@ async function removeMember(
export function registerFeishuPermTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_perm: No config available, skipping perm tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.perm) {
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
return;
}
@@ -170,6 +167,4 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
},
{ name: "feishu_perm" },
);
api.logger.debug?.(`feishu_perm: Registered feishu_perm tool`);
}

View File

@@ -153,19 +153,16 @@ async function renameNode(client: Lark.Client, spaceId: string, nodeToken: strin
export function registerFeishuWikiTools(api: OpenClawPluginApi) {
if (!api.config) {
api.logger.debug?.("feishu_wiki: No config available, skipping wiki tools");
return;
}
const accounts = listEnabledFeishuAccounts(api.config);
if (accounts.length === 0) {
api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools");
return;
}
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.wiki) {
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
return;
}
@@ -227,6 +224,4 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
},
{ name: "feishu_wiki" },
);
api.logger.debug?.(`feishu_wiki: Registered feishu_wiki tool`);
}

View File

@@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -0,0 +1,337 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
class FakeWebSocket {
public readonly sent: string[] = [];
private readonly openListeners: Array<() => void> = [];
private readonly messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
private readonly closeListeners: Array<(code: number, reason: Buffer) => void> = [];
private readonly errorListeners: Array<(err: unknown) => void> = [];
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
if (event === "open") {
this.openListeners.push(listener as () => void);
return;
}
if (event === "message") {
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
return;
}
if (event === "close") {
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
return;
}
this.errorListeners.push(listener as (err: unknown) => void);
}
send(data: string): void {
this.sent.push(data);
}
close(): void {}
terminate(): void {}
get openListenerCount(): number {
return this.openListeners.length;
}
emitOpen(): void {
for (const listener of this.openListeners) {
listener();
}
}
async emitMessage(payload: unknown): Promise<void> {
const buffer = Buffer.from(JSON.stringify(payload), "utf8");
await Promise.all(this.messageListeners.map((listener) => Promise.resolve(listener(buffer))));
}
emitClose(code: number, reason = ""): void {
const buffer = Buffer.from(reason, "utf8");
for (const listener of this.closeListeners) {
listener(code, buffer);
}
}
emitError(err: unknown): void {
for (const listener of this.errorListeners) {
listener(err);
}
}
}
const mockState = vi.hoisted(() => ({
abortController: undefined as AbortController | undefined,
createMattermostClient: vi.fn(),
createMattermostDraftStream: vi.fn(),
dispatchReplyFromConfig: vi.fn(),
enqueueSystemEvent: vi.fn(),
fetchMattermostMe: vi.fn(),
registerMattermostMonitorSlashCommands: vi.fn(),
registerPluginHttpRoute: vi.fn(),
resolveChannelInfo: vi.fn(),
resolveMattermostMedia: vi.fn(),
resolveUserInfo: vi.fn(),
runtimeCore: undefined as unknown,
updateMattermostPost: vi.fn(),
}));
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return {
...actual,
createMattermostClient: mockState.createMattermostClient,
fetchMattermostMe: mockState.fetchMattermostMe,
normalizeMattermostBaseUrl: (value: string | undefined) => value?.trim() ?? "",
updateMattermostPost: mockState.updateMattermostPost,
};
});
vi.mock("./draft-stream.js", () => ({
buildMattermostToolStatusText: () => "Working",
createMattermostDraftStream: mockState.createMattermostDraftStream,
}));
vi.mock("./monitor-resources.js", () => ({
createMattermostMonitorResources: () => ({
resolveMattermostMedia: mockState.resolveMattermostMedia,
sendTypingIndicator: vi.fn(async () => {}),
resolveChannelInfo: mockState.resolveChannelInfo,
resolveUserInfo: mockState.resolveUserInfo,
updateModelPickerPost: vi.fn(async () => {}),
}),
}));
vi.mock("./monitor-slash.js", () => ({
registerMattermostMonitorSlashCommands: mockState.registerMattermostMonitorSlashCommands,
}));
vi.mock("./runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
return {
...actual,
buildAgentMediaPayload: vi.fn(() => ({})),
createChannelPairingController: vi.fn(() => ({
readStoreForDmPolicy: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
})),
createChannelReplyPipeline: vi.fn(() => ({
onModelSelected: vi.fn(),
typingCallbacks: {},
})),
readStoreAllowFromForDmPolicy: vi.fn(async () => []),
registerPluginHttpRoute: mockState.registerPluginHttpRoute,
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
warnMissingProviderGroupPolicyFallbackOnce: vi.fn(),
};
});
function createRuntimeCore(cfg: OpenClawConfig) {
return {
config: {
current: () => cfg,
},
logging: {
shouldLogVerbose: () => false,
getChildLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
media: {
mediaKindFromMime: () => "document",
},
system: {
enqueueSystemEvent: mockState.enqueueSystemEvent,
},
channel: {
activity: {
record: vi.fn(),
},
commands: {
shouldHandleTextCommands: () => false,
},
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void> | void;
}) => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
},
groups: {
resolveRequireMention: () => false,
},
media: {
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
},
mentions: {
buildMentionRegexes: () => [],
matchesMentionPatterns: () => false,
},
pairing: {
buildPairingReply: () => "pairing required",
},
reply: {
createReplyDispatcherWithTyping: vi.fn(() => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
})),
dispatchReplyFromConfig: mockState.dispatchReplyFromConfig,
finalizeInboundContext: (context: unknown) => context,
formatInboundEnvelope: (params: { channel: string; from: string; body: string }) =>
`${params.channel} ${params.from}\n${params.body}`,
resolveHumanDelayConfig: () => ({}),
withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => {
try {
return await params.run();
} finally {
params.onSettled?.();
}
},
},
routing: {
resolveAgentRoute: () => ({
accountId: "default",
agentId: "main",
mainSessionKey: "mattermost:default:channel:chan-1",
sessionKey: "mattermost:default:channel:chan-1",
}),
},
session: {
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
updateLastRoute: vi.fn(async () => {}),
},
text: {
chunkMarkdownTextWithMode: (text: string) => [text],
convertMarkdownTables: (text: string) => text,
hasControlCommand: () => false,
resolveChunkMode: () => "off",
resolveMarkdownTableMode: () => "off",
resolveTextChunkLimit: () => 4000,
},
},
};
}
const testConfig: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
baseUrl: "https://mattermost.example.com",
botToken: "bot-token",
chatmode: "onmessage",
dmPolicy: "open",
groupPolicy: "open",
},
},
};
vi.mock("../runtime.js", () => ({
getMattermostRuntime: () => mockState.runtimeCore,
}));
const testRuntime = (): RuntimeEnv =>
({
log: vi.fn(),
error: vi.fn(),
exit: ((code: number): never => {
throw new Error(`exit ${code}`);
}) as RuntimeEnv["exit"],
}) satisfies RuntimeEnv;
describe("mattermost inbound user posts", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.abortController = undefined;
mockState.runtimeCore = createRuntimeCore(testConfig);
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostDraftStream.mockReturnValue({
update: vi.fn(),
stop: vi.fn(async () => {}),
});
mockState.fetchMattermostMe.mockResolvedValue({
id: "bot-user",
username: "openclaw",
update_at: 1,
});
mockState.registerMattermostMonitorSlashCommands.mockResolvedValue(undefined);
mockState.registerPluginHttpRoute.mockReturnValue(vi.fn());
mockState.resolveChannelInfo.mockResolvedValue({
id: "chan-1",
name: "town-square",
display_name: "Town Square",
team_id: "team-1",
type: "O",
});
mockState.resolveMattermostMedia.mockResolvedValue([]);
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
mockState.dispatchReplyFromConfig.mockImplementation(async () => {
mockState.abortController?.abort();
});
});
it("does not enqueue regular user posts as system events", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
const { monitorMattermostProvider } = await import("./monitor.js");
const monitor = monitorMattermostProvider({
config: testConfig,
runtime: testRuntime(),
abortSignal: abortController.signal,
webSocketFactory: () => socket,
});
await vi.waitFor(() => {
expect(socket.openListenerCount).toBeGreaterThan(0);
});
socket.emitOpen();
await socket.emitMessage({
event: "posted",
data: {
channel_id: "chan-1",
channel_name: "town-square",
channel_display_name: "Town Square",
sender_name: "alice",
post: JSON.stringify({
id: "post-1",
channel_id: "chan-1",
user_id: "user-1",
message: "hello from mattermost",
create_at: 1_714_000_000_000,
}),
},
broadcast: {
channel_id: "chan-1",
user_id: "user-1",
},
});
socket.emitClose(1000);
await monitor;
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(mockState.dispatchReplyFromConfig.mock.calls[0]?.[0].ctx).toMatchObject({
BodyForAgent: "hello from mattermost",
ConversationLabel: "Town Square id:chan-1",
MessageSid: "post-1",
OriginatingChannel: "mattermost",
Provider: "mattermost",
});
});
});

View File

@@ -1488,16 +1488,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
directId: senderId,
});
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel =
kind === "direct"
? `Mattermost DM from ${senderName}`
: `Mattermost message in ${roomLabel} from ${senderName}`;
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
});
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
const body = core.channel.reply.formatInboundEnvelope({
channel: "Mattermost",

View File

@@ -306,6 +306,5 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) {
auth: "plugin",
handler: routeHandler,
});
api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`);
}
}

View File

@@ -19,7 +19,7 @@ export type MemoryIndexMeta = {
export function resolveConfiguredSourcesForMeta(sources: Iterable<MemorySource>): MemorySource[] {
const normalized = Array.from(sources)
.filter((source): source is MemorySource => source === "memory" || source === "sessions")
.toSorted();
.toSorted((left, right) => left.localeCompare(right));
return normalized.length > 0 ? normalized : ["memory"];
}
@@ -34,7 +34,7 @@ export function normalizeMetaSources(meta: MemoryIndexMeta): MemorySource[] {
(source): source is MemorySource => source === "memory" || source === "sessions",
),
),
).toSorted();
).toSorted((left, right) => left.localeCompare(right));
return normalized.length > 0 ? normalized : ["memory"];
}

View File

@@ -202,6 +202,43 @@ describe("memory plugin e2e", () => {
expect(config?.autoRecall).toBe(true);
});
test("registers as disabled instead of throwing when inspected without config", async () => {
const registerService = vi.fn();
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {},
runtime: {},
logger,
registerTool: vi.fn(),
registerCli: vi.fn(),
registerService,
on: vi.fn(),
resolvePath: (filePath: string) => filePath,
};
expect(() => memoryPlugin.register(mockApi as any)).not.toThrow();
expect(registerService).toHaveBeenCalledWith({
id: "memory-lancedb",
start: expect.any(Function),
});
expect(mockApi.registerTool).not.toHaveBeenCalled();
expect(mockApi.on).not.toHaveBeenCalled();
registerService.mock.calls[0]?.[0].start({});
expect(logger.warn).toHaveBeenCalledWith(
"memory-lancedb: disabled until configured (embedding config required)",
);
});
test("registers auto-recall on before_prompt_build instead of the legacy hook", async () => {
const on = vi.fn();
const mockApi = {

View File

@@ -503,7 +503,19 @@ export default definePluginEntry({
configSchema: memoryConfigSchema,
register(api: OpenClawPluginApi) {
const cfg = memoryConfigSchema.parse(api.pluginConfig);
let cfg: MemoryConfig;
try {
cfg = memoryConfigSchema.parse(api.pluginConfig);
} catch (error) {
api.registerService({
id: "memory-lancedb",
start: () => {
const message = error instanceof Error ? error.message : String(error);
api.logger.warn(`memory-lancedb: disabled until configured (${message})`);
},
});
return;
}
const dbPath = cfg.dbPath!;
const resolvedDbPath = dbPath.includes("://") ? dbPath : api.resolvePath(dbPath);
const { model, dimensions } = cfg.embedding;

View File

@@ -300,6 +300,97 @@ describe("syncMemoryWikiBridgeSources", () => {
});
});
it("refuses to overwrite bridge source pages through vault symlinks", async () => {
const workspaceDir = await createBridgeWorkspace("symlink-workspace");
const { rootDir: vaultDir, config } = await createVault({
rootDir: nextCaseRoot("symlink-vault"),
config: {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexMemoryRoot: true,
},
},
});
const memoryPath = path.join(workspaceDir, "MEMORY.md");
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: memoryPath,
agentIds: ["main"],
contentType: "markdown",
},
]);
const appConfig: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
const pagePath = first.pagePaths[0] ?? "";
const pageAbsPath = path.join(vaultDir, pagePath);
const externalTarget = path.join(workspaceDir, "outside.md");
await fs.writeFile(externalTarget, "external target\n", "utf8");
await fs.rm(pageAbsPath);
await fs.symlink(externalTarget, pageAbsPath);
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
await expect(syncMemoryWikiBridgeSources({ config, appConfig })).rejects.toThrow(
"Refusing to write imported source page through symlink",
);
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
});
it("replaces bridge source page hardlinks without clobbering their target", async () => {
const workspaceDir = await createBridgeWorkspace("hardlink-workspace");
const { rootDir: vaultDir, config } = await createVault({
rootDir: nextCaseRoot("hardlink-vault"),
config: {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexMemoryRoot: true,
},
},
});
const memoryPath = path.join(workspaceDir, "MEMORY.md");
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: memoryPath,
agentIds: ["main"],
contentType: "markdown",
},
]);
const appConfig: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
const pagePath = first.pagePaths[0] ?? "";
const pageAbsPath = path.join(vaultDir, pagePath);
const externalTarget = path.join(workspaceDir, "outside-hardlink.md");
await fs.writeFile(externalTarget, "external target\n", "utf8");
await fs.rm(pageAbsPath);
await fs.link(externalTarget, pageAbsPath);
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
const second = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(second.updatedCount).toBe(1);
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
await expect(fs.readFile(pageAbsPath, "utf8")).resolves.toContain("# Updated Durable Memory");
});
it("caps composed bridge source filenames to the filesystem component limit", async () => {
const workspaceDir = await createBridgeWorkspace(`${"漢".repeat(50)}-workspace`);
const { rootDir: vaultDir, config } = await createVault({

View File

@@ -3,11 +3,25 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerWikiCli, runWikiChatGptImport, runWikiChatGptRollback } from "./cli.js";
import {
registerWikiCli,
runWikiBridgeImport,
runWikiChatGptImport,
runWikiChatGptRollback,
runWikiDoctor,
runWikiStatus,
} from "./cli.js";
import type { MemoryWikiPluginConfig } from "./config.js";
import { parseWikiMarkdown, renderWikiMarkdown } from "./markdown.js";
import type { MemoryWikiDoctorReport, MemoryWikiStatus } from "./status.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const callGatewayFromCliMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
callGatewayFromCli: callGatewayFromCliMock,
}));
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
@@ -24,6 +38,7 @@ describe("memory-wiki cli", () => {
});
beforeEach(() => {
callGatewayFromCliMock.mockReset();
vi.spyOn(process.stdout, "write").mockImplementation(
(() => true) as typeof process.stdout.write,
);
@@ -88,6 +103,45 @@ describe("memory-wiki cli", () => {
return exportDir;
}
function createGatewayStatus(config: {
vault: { path: string };
bridge: MemoryWikiStatus["bridge"];
}): MemoryWikiStatus {
return {
vaultMode: "bridge",
renderMode: "native",
vaultPath: config.vault.path,
vaultExists: true,
bridge: config.bridge,
bridgePublicArtifactCount: 2,
obsidianCli: {
enabled: false,
requested: false,
available: false,
command: null,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: false,
pathCount: 0,
},
pageCounts: {
source: 0,
entity: 0,
concept: 0,
synthesis: 0,
report: 0,
},
sourceCounts: {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
},
warnings: [],
};
}
it("registers apply synthesis and writes a synthesis page", async () => {
const { rootDir, config } = await createCliVault();
const program = new Command();
@@ -193,6 +247,226 @@ cli note
await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
});
it("routes active bridge status and doctor through the gateway", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
const report: MemoryWikiDoctorReport = {
healthy: false,
warningCount: 1,
status: {
...status,
warnings: [
{
code: "bridge-artifacts-missing",
message: "No exported artifacts.",
},
],
},
fixes: [
{
code: "bridge-artifacts-missing",
message: "Create memory artifacts.",
},
],
};
callGatewayFromCliMock.mockResolvedValueOnce(status).mockResolvedValueOnce(report);
await expect(runWikiStatus({ config, json: true })).resolves.toBe(status);
await expect(runWikiDoctor({ config, json: true })).resolves.toBe(report);
expect(process.exitCode).toBe(1);
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
1,
"wiki.status",
{ timeout: "30000" },
undefined,
{ progress: false },
);
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
2,
"wiki.doctor",
{ timeout: "30000" },
undefined,
{ progress: false },
);
});
it("sanitizes gateway status text output without changing JSON output", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const unsafeStatus = createGatewayStatus({
...config,
vault: { path: "\u001B[2J/tmp/wiki\nforged prompt\u202E" },
});
unsafeStatus.warnings = [
{
code: "bridge-artifacts-missing",
message: "missing artifacts\r\nfake success\u001B[31m\u202E",
},
];
const textOutput: string[] = [];
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
await runWikiStatus({
config,
stdout: {
write: ((chunk: string) => textOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
},
});
const renderedText = textOutput.join("");
expect(renderedText).not.toContain("\u001B");
expect(renderedText).not.toContain("\u202E");
expect(renderedText).toContain("(/tmp/wiki forged prompt)");
expect(renderedText).toContain("- missing artifacts fake success");
const jsonOutput: string[] = [];
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
await runWikiStatus({
config,
json: true,
stdout: {
write: ((chunk: string) => jsonOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
},
});
const renderedJson = jsonOutput.join("");
expect(renderedJson).not.toContain("\u001B");
expect(renderedJson).not.toContain("\u202E");
expect(renderedJson).not.toContain("\r");
expect(renderedJson).toContain("\\u001b[2J/tmp/wiki\\nforged prompt\\u202e");
expect(renderedJson).toContain("missing artifacts\\r\\nfake success\\u001b[31m\\u202e");
const parsed = JSON.parse(renderedJson) as MemoryWikiStatus;
expect(parsed.vaultPath).toBe("\u001B[2J/tmp/wiki\nforged prompt\u202E");
expect(parsed.warnings[0]?.message).toBe("missing artifacts\r\nfake success\u001B[31m\u202E");
});
it("rejects malformed gateway responses before rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
callGatewayFromCliMock.mockResolvedValueOnce({ vaultMode: "bridge" });
await expect(runWikiStatus({ config })).rejects.toThrow(
"Invalid Gateway response for wiki.status.",
);
});
it("rejects oversized gateway strings before rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
status.warnings = [
{
code: "bridge-artifacts-missing",
message: "x".repeat(10_001),
},
];
callGatewayFromCliMock.mockResolvedValueOnce(status);
await expect(runWikiStatus({ config })).rejects.toThrow(
"Invalid Gateway response for wiki.status.",
);
});
it("truncates gateway status text output after rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
status.warnings = [
{
code: "bridge-artifacts-missing",
message: `${"warning ".repeat(500)}tail`,
},
];
const textOutput: string[] = [];
callGatewayFromCliMock.mockResolvedValueOnce(status);
await runWikiStatus({
config,
stdout: {
write: ((chunk: string) => textOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
},
});
const renderedText = textOutput.join("");
expect(renderedText).toContain("... [truncated]");
expect(renderedText).not.toContain("tail");
});
it("routes active bridge imports through the gateway and keeps disabled bridge imports local", async () => {
const active = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
callGatewayFromCliMock.mockResolvedValueOnce({
importedCount: 1,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 1,
workspaces: 1,
pagePaths: ["sources/bridge-alpha.md"],
indexesRefreshed: true,
indexUpdatedFiles: ["index.md"],
indexRefreshReason: "import-changed",
});
const activeResult = await runWikiBridgeImport({ config: active.config, json: true });
expect(activeResult.importedCount).toBe(1);
expect(callGatewayFromCliMock).toHaveBeenCalledWith(
"wiki.bridge.import",
{ timeout: "30000" },
undefined,
{ progress: false },
);
callGatewayFromCliMock.mockClear();
const disabled = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: false },
},
});
const disabledResult = await runWikiBridgeImport({ config: disabled.config, json: true });
expect(disabledResult.artifactCount).toBe(0);
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
});
it("imports ChatGPT exports with dry-run, apply, and rollback", async () => {

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
import type { OpenClawConfig } from "../api.js";
import { applyMemoryWikiMutation } from "./apply.js";
import {
@@ -27,14 +28,29 @@ import {
} from "./obsidian.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import type { MemoryWikiImportedSourceSyncResult } from "./source-sync.js";
import {
buildMemoryWikiDoctorReport,
renderMemoryWikiDoctor,
renderMemoryWikiStatus,
type MemoryWikiDoctorReport,
type MemoryWikiStatus,
resolveMemoryWikiStatus,
} from "./status.js";
import { initializeMemoryWikiVault } from "./vault.js";
const WIKI_GATEWAY_TIMEOUT_MS = "30000";
const GATEWAY_TERMINAL_STRING_MAX_CHARS = 2_000;
const GATEWAY_RESPONSE_MAX_ARRAY_ITEMS = 10_000;
const GATEWAY_RESPONSE_MAX_STRING_CHARS = 10_000;
const GATEWAY_RESPONSE_MAX_CODE_CHARS = 256;
const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp(
String.raw`(?:\x1B\[[0-?]*[ -/]*[@-~]|\x1B[@-Z\\-_]|\x9B[0-?]*[ -/]*[@-~])`,
"g",
);
const TERMINAL_CONTROL_CHARACTER_PATTERN = new RegExp(String.raw`[\x00-\x1F\x7F-\x9F]+`, "g");
const UNICODE_FORMAT_CONTROL_PATTERN = /[\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g;
type WikiStatusCommandOptions = {
json?: boolean;
};
@@ -143,10 +159,173 @@ function isResolvedMemoryWikiConfig(
);
}
function sanitizeGatewayStringForTerminal(value: string): string {
const truncated =
value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS
? value.slice(0, GATEWAY_TERMINAL_STRING_MAX_CHARS)
: value;
const sanitized = truncated
.replace(ANSI_ESCAPE_SEQUENCE_PATTERN, "")
.replace(TERMINAL_CONTROL_CHARACTER_PATTERN, " ")
.replace(UNICODE_FORMAT_CONTROL_PATTERN, "");
return value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS
? `${sanitized}... [truncated]`
: sanitized;
}
function escapeGatewayJsonForTerminal(json: string): string {
return json.replace(UNICODE_FORMAT_CONTROL_PATTERN, (char) => {
const codePoint = char.codePointAt(0);
return typeof codePoint === "number" ? `\\u${codePoint.toString(16).padStart(4, "0")}` : "";
});
}
function writeOutput(output: string, writer: Pick<NodeJS.WriteStream, "write"> = process.stdout) {
writer.write(output.endsWith("\n") ? output : `${output}\n`);
}
function shouldRouteBridgeRuntimeThroughGateway(config: ResolvedMemoryWikiConfig): boolean {
return (
config.vaultMode === "bridge" && config.bridge.enabled && config.bridge.readMemoryArtifacts
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isBoundedGatewayString(
value: unknown,
maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS,
): value is string {
return typeof value === "string" && value.length <= maxChars;
}
function isStringArray(
value: unknown,
maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS,
): value is string[] {
return (
Array.isArray(value) &&
value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
value.every((item) => isBoundedGatewayString(item, maxChars))
);
}
function hasNumberFields(value: Record<string, unknown>, keys: readonly string[]): boolean {
return keys.every((key) => typeof value[key] === "number");
}
function isWarningList(value: unknown): boolean {
return (
Array.isArray(value) &&
value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
value.every(
(item) =>
isRecord(item) &&
isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(item.message),
)
);
}
function isMemoryWikiStatus(value: unknown): value is MemoryWikiStatus {
if (!isRecord(value)) {
return false;
}
const bridge = value.bridge;
const obsidianCli = value.obsidianCli;
const unsafeLocal = value.unsafeLocal;
const pageCounts = value.pageCounts;
const sourceCounts = value.sourceCounts;
return (
isBoundedGatewayString(value.vaultMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(value.renderMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(value.vaultPath) &&
typeof value.vaultExists === "boolean" &&
(typeof value.bridgePublicArtifactCount === "number" ||
value.bridgePublicArtifactCount === null) &&
isRecord(bridge) &&
typeof bridge.enabled === "boolean" &&
isRecord(obsidianCli) &&
typeof obsidianCli.enabled === "boolean" &&
typeof obsidianCli.requested === "boolean" &&
typeof obsidianCli.available === "boolean" &&
(isBoundedGatewayString(obsidianCli.command) || obsidianCli.command === null) &&
isRecord(unsafeLocal) &&
typeof unsafeLocal.allowPrivateMemoryCoreAccess === "boolean" &&
typeof unsafeLocal.pathCount === "number" &&
isRecord(pageCounts) &&
hasNumberFields(pageCounts, ["source", "entity", "concept", "synthesis", "report"]) &&
isRecord(sourceCounts) &&
hasNumberFields(sourceCounts, ["native", "bridge", "bridgeEvents", "unsafeLocal", "other"]) &&
isWarningList(value.warnings)
);
}
function isMemoryWikiDoctorReport(value: unknown): value is MemoryWikiDoctorReport {
return (
isRecord(value) &&
typeof value.healthy === "boolean" &&
typeof value.warningCount === "number" &&
isMemoryWikiStatus(value.status) &&
Array.isArray(value.fixes) &&
value.fixes.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
value.fixes.every(
(item) =>
isRecord(item) &&
isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(item.message),
)
);
}
function isMemoryWikiImportResult(value: unknown): value is MemoryWikiImportedSourceSyncResult {
return (
isRecord(value) &&
hasNumberFields(value, [
"importedCount",
"updatedCount",
"skippedCount",
"removedCount",
"artifactCount",
"workspaces",
]) &&
isStringArray(value.pagePaths) &&
typeof value.indexesRefreshed === "boolean" &&
isStringArray(value.indexUpdatedFiles) &&
isBoundedGatewayString(value.indexRefreshReason, GATEWAY_RESPONSE_MAX_CODE_CHARS)
);
}
function validateWikiGatewayResult(
method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import",
value: unknown,
): MemoryWikiStatus | MemoryWikiDoctorReport | MemoryWikiImportedSourceSyncResult {
if (method === "wiki.status" && isMemoryWikiStatus(value)) {
return value;
}
if (method === "wiki.doctor" && isMemoryWikiDoctorReport(value)) {
return value;
}
if (method === "wiki.bridge.import" && isMemoryWikiImportResult(value)) {
return value;
}
throw new Error(`Invalid Gateway response for ${method}.`);
}
async function callWikiGateway(method: "wiki.status"): Promise<MemoryWikiStatus>;
async function callWikiGateway(method: "wiki.doctor"): Promise<MemoryWikiDoctorReport>;
async function callWikiGateway(
method: "wiki.bridge.import",
): Promise<MemoryWikiImportedSourceSyncResult>;
async function callWikiGateway(method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import") {
const result = await callGatewayFromCli(method, { timeout: WIKI_GATEWAY_TIMEOUT_MS }, undefined, {
progress: false,
});
return validateWikiGatewayResult(method, result);
}
function normalizeCliStringList(values?: string[]): string[] | undefined {
if (!values) {
return undefined;
@@ -201,6 +380,16 @@ function formatJsonOrText<T>(
return json ? JSON.stringify(result, null, 2) : render(result);
}
function formatGatewayJsonOrText<T>(
result: T,
json: boolean | undefined,
render: (result: T) => string,
): string {
return json
? escapeGatewayJsonForTerminal(JSON.stringify(result, null, 2))
: sanitizeGatewayStringForTerminal(render(result));
}
async function runWikiCommandWithSummary<T>(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
@@ -255,12 +444,19 @@ export async function runWikiStatus(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const status = await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
});
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
const status = routeThroughGateway
? await callWikiGateway("wiki.status")
: await (async () => {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
return await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
});
})();
writeOutput(
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
routeThroughGateway
? formatGatewayJsonOrText(status, params.json, renderMemoryWikiStatus)
: formatJsonOrText(status, params.json, renderMemoryWikiStatus),
params.stdout,
);
return status;
@@ -272,17 +468,24 @@ export async function runWikiDoctor(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const report = buildMemoryWikiDoctorReport(
await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
}),
);
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
const report = routeThroughGateway
? await callWikiGateway("wiki.doctor")
: await (async () => {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
return buildMemoryWikiDoctorReport(
await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
}),
);
})();
if (!report.healthy) {
process.exitCode = 1;
}
writeOutput(
params.json ? JSON.stringify(report, null, 2) : renderMemoryWikiDoctor(report),
routeThroughGateway
? formatGatewayJsonOrText(report, params.json, renderMemoryWikiDoctor)
: formatJsonOrText(report, params.json, renderMemoryWikiDoctor),
params.stdout,
);
return report;
@@ -505,6 +708,13 @@ export async function runWikiBridgeImport(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const render = (value: MemoryWikiImportedSourceSyncResult) =>
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`;
if (shouldRouteBridgeRuntimeThroughGateway(params.config)) {
const result = await callWikiGateway("wiki.bridge.import");
writeOutput(formatGatewayJsonOrText(result, params.json, render), params.stdout);
return result;
}
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -513,8 +723,7 @@ export async function runWikiBridgeImport(params: {
config: params.config,
appConfig: params.appConfig,
}),
render: (value) =>
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`,
render,
});
}

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { pathExists } from "./source-path-shared.js";
import {
setImportedSourceEntry,
shouldSkipImportedSourceWrite,
@@ -8,6 +9,123 @@ import {
} from "./source-sync-state.js";
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
type FileStats = Awaited<ReturnType<typeof fs.lstat>>;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function resolveWritableVaultPagePath(params: {
vaultRoot: string;
pagePath: string;
}): Promise<{
pageAbsPath: string;
pageDir: string;
pageDirRealPath: string;
vaultRealPath: string;
existing: FileStats | null;
}> {
const vaultAbsPath = path.resolve(params.vaultRoot);
const pageAbsPath = path.resolve(vaultAbsPath, params.pagePath);
if (!isPathInside(vaultAbsPath, pageAbsPath)) {
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
}
const vaultRealPath = await fs.realpath(vaultAbsPath);
const pageDir = path.dirname(pageAbsPath);
await fs.mkdir(pageDir, { recursive: true });
const pageDirRealPath = await fs.realpath(pageDir);
if (!isPathInside(vaultRealPath, pageDirRealPath)) {
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
}
const existing = await fs.lstat(pageAbsPath).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return null;
}
throw err;
});
if (existing?.isSymbolicLink()) {
throw new Error(`Refusing to write imported source page through symlink: ${params.pagePath}`);
}
if (existing && !existing.isFile()) {
throw new Error(`Refusing to write imported source page over non-file: ${params.pagePath}`);
}
return { pageAbsPath, pageDir, pageDirRealPath, vaultRealPath, existing };
}
async function assertWritablePageDir(params: {
pageDir: string;
pageDirRealPath: string;
vaultRealPath: string;
pagePath: string;
}): Promise<void> {
const currentPageDirRealPath = await fs.realpath(params.pageDir);
if (
currentPageDirRealPath !== params.pageDirRealPath ||
!isPathInside(params.vaultRealPath, currentPageDirRealPath)
) {
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
}
}
async function validateDestinationForReplace(filePath: string, pagePath: string): Promise<void> {
const existing = await fs.lstat(filePath).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return null;
}
throw err;
});
if (existing?.isSymbolicLink()) {
throw new Error(`Refusing to write imported source page through symlink: ${pagePath}`);
}
if (existing && !existing.isFile()) {
throw new Error(`Refusing to write imported source page over non-file: ${pagePath}`);
}
}
async function writeFileAtomicInVault(params: {
filePath: string;
pageDir: string;
pageDirRealPath: string;
vaultRealPath: string;
pagePath: string;
content: string;
}): Promise<void> {
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
await assertWritablePageDir(params);
const tempPath = path.join(params.pageDir, `.openclaw-wiki-${process.pid}-${randomUUID()}.tmp`);
let shouldRemoveTemp = true;
try {
const handle = await fs.open(
tempPath,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
0o600,
);
try {
const tempStat = await handle.stat();
if (!tempStat.isFile() || tempStat.nlink !== 1) {
throw new Error(
`Refusing to write imported source page through unsafe temp file: ${params.pagePath}`,
);
}
await handle.writeFile(params.content, "utf8");
} finally {
await handle.close();
}
await assertWritablePageDir(params);
await validateDestinationForReplace(params.filePath, params.pagePath);
await fs.rename(tempPath, params.filePath);
shouldRemoveTemp = false;
await assertWritablePageDir(params);
} finally {
if (shouldRemoveTemp) {
await fs.rm(tempPath, { force: true });
}
}
}
export async function writeImportedSourcePage(params: {
vaultRoot: string;
@@ -21,8 +139,17 @@ export async function writeImportedSourcePage(params: {
state: ImportedSourceState;
buildRendered: (raw: string, updatedAt: string) => string;
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
const pageAbsPath = path.join(params.vaultRoot, params.pagePath);
const created = !(await pathExists(pageAbsPath));
const {
pageAbsPath,
pageDir,
pageDirRealPath,
vaultRealPath,
existing: pageStat,
} = await resolveWritableVaultPagePath({
vaultRoot: params.vaultRoot,
pagePath: params.pagePath,
});
const created = !pageStat;
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
const shouldSkip = await shouldSkipImportedSourceWrite({
vaultRoot: params.vaultRoot,
@@ -40,9 +167,16 @@ export async function writeImportedSourcePage(params: {
const raw = await fs.readFile(params.sourcePath, "utf8");
const rendered = params.buildRendered(raw, updatedAt);
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
const existing = pageStat ? await fs.readFile(pageAbsPath, "utf8").catch(() => "") : "";
if (existing !== rendered) {
await fs.writeFile(pageAbsPath, rendered, "utf8");
await writeFileAtomicInVault({
filePath: pageAbsPath,
pageDir,
pageDirRealPath,
vaultRealPath,
pagePath: params.pagePath,
content: rendered,
});
}
setImportedSourceEntry({

View File

@@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -212,6 +212,55 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
);
});
it("does not overwrite configured native Ollama params.thinking with implicit off", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const baseStreamFn = createOllamaStreamFn("http://ollama-host:11434");
const model = {
api: "ollama",
provider: "ollama",
id: "qwen3:32b",
contextWindow: 131072,
params: { thinking: "medium" },
};
const wrapped = createConfiguredOllamaCompatStreamWrapper({
provider: "ollama",
modelId: "qwen3:32b",
model,
streamFn: baseStreamFn,
thinkingLevel: "off",
} as never);
if (!wrapped) {
throw new Error("Expected wrapped Ollama stream function");
}
const stream = await Promise.resolve(
wrapped(
model as never,
{
messages: [{ role: "user", content: "hello" }],
} as never,
{} as never,
),
);
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as { think?: string };
expect(requestBody.think).toBe("medium");
},
);
});
it("forwards the native think effort on native Ollama chat requests when thinking is enabled", async () => {
await withMockNdjsonFetch(
[
@@ -1248,6 +1297,69 @@ describe("createOllamaStreamFn streaming events", () => {
);
});
it("emits an error instead of accepting garbled Kimi visible text", async () => {
const garbled =
'$$"##"%#"##"####""$""""##""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' +
'#"$"$"""$""""#$"""$"""%"%###"""#%""""&"#"""$"""#"#""""%#""""&"#"""$"""$"""#%"""';
await withMockNdjsonFetch(
[
JSON.stringify({
model: "kimi-k2.5:cloud",
created_at: "t",
message: { role: "assistant", content: garbled },
done: false,
}),
'{"model":"kimi-k2.5:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":20,"eval_count":40}',
],
async () => {
const stream = await createOllamaTestStream({
baseUrl: "http://ollama-host:11434",
model: { id: "kimi-k2.5:cloud", provider: "ollama" },
});
const events = await collectStreamEvents(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["start", "text_start", "text_delta", "error"]);
const errorEvent = events.at(-1);
expect(errorEvent).toMatchObject({
type: "error",
error: expect.objectContaining({
errorMessage: expect.stringContaining("garbled visible text"),
}),
});
},
);
});
it("does not reject punctuation-heavy text from unrelated Ollama models", async () => {
const punctuationHeavy =
'$$"##"%#"##"####""$""""##""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' +
'#"$"$"""$""""#$"""$"""%"%###"""#%""""&"#"""$"""#"#""""%#""""&"#"""$"""$"""#%"""';
await withMockNdjsonFetch(
[
JSON.stringify({
model: "qwen3:32b",
created_at: "t",
message: { role: "assistant", content: punctuationHeavy },
done: false,
}),
'{"model":"qwen3:32b","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":20,"eval_count":40}',
],
async () => {
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
const events = await collectStreamEvents(stream);
expect(events.map((e) => e.type)).toEqual([
"start",
"text_start",
"text_delta",
"text_end",
"done",
]);
},
);
});
it("emits a single text_delta for single-chunk responses", async () => {
await withMockNdjsonFetch(
[

Some files were not shown because too many files have changed in this diff Show More