Compare commits
298 Commits
v2026.4.14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 943cb47274 | |||
| 4caa882476 | |||
| 52ef42302e | |||
| 4de56b18ba | |||
| a177d8d454 | |||
| 23dca0a089 | |||
| 4efd3c3d74 | |||
| 41699cfc2d | |||
| 0a57279309 | |||
| 1470de5d3e | |||
| 84185cb3eb | |||
| be7f4a2342 | |||
| 2bfd808a83 | |||
| 4f00b76925 | |||
| 89d2c145df | |||
| 4dfcc030ae | |||
| 893d0635b6 | |||
| 6e58f1f9f5 | |||
| 7c6f2c0a5a | |||
| f8705f512b | |||
| ed28df48a4 | |||
| 229eb72cf6 | |||
| 78ac118427 | |||
| ee6b7daca3 | |||
| 32222812ea | |||
| b2753fd0de | |||
| 568df95736 | |||
| 3e60eaa884 | |||
| 8f4331e3b4 | |||
| 0149ca0669 | |||
| dc86349c02 | |||
| 69ba56d2c8 | |||
| b3fa5880dd | |||
| cb790c858b | |||
| ef98bcf630 | |||
| 33154ce745 | |||
| 20cce166ef | |||
| ec4c2cb62c | |||
| d2a219ea44 | |||
| b9d0fc5630 | |||
| 931581070a | |||
| 963ad1df06 | |||
| 3830e687dd | |||
| 1bca9ba479 | |||
| 7d2e068b27 | |||
| c5b3f00d11 | |||
| 890e299e30 | |||
| bb4498cef7 | |||
| b855b1d047 | |||
| c727388f93 | |||
| a780151fd1 | |||
| f09a4d9ba0 | |||
| 7883412294 | |||
| ec3bbae49b | |||
| edfa074e0f | |||
| 8dd1abedec | |||
| becd14424d | |||
| 804bb0f2c3 | |||
| e99a24d645 | |||
| dcaccdc5c4 | |||
| 7bb670c0bc | |||
| f6eb671d62 | |||
| 9c32c2bf26 | |||
| 88d3620a85 | |||
| 7821fae05d | |||
| bb669df26a | |||
| 7320dfc1ff | |||
| 7611d41136 | |||
| f49d9bcae9 | |||
| 7734a40a56 | |||
| fb4395c1fe | |||
| ea4889ecdc | |||
| 9e665e4328 | |||
| 7f35f76914 | |||
| df918c4de5 | |||
| 94d5c3dd6b | |||
| 2e61d2ce3f | |||
| a1d4eb255a | |||
| 2791b00e72 | |||
| 8b79141997 | |||
| 2a8226f8e2 | |||
| 64f258fc49 | |||
| 60e2ccbd5b | |||
| aaa6b05f3b | |||
| 9e1df98475 | |||
| 5e7306bcfc | |||
| 1077cb74f9 | |||
| 5754667c87 | |||
| 18d0af3a13 | |||
| 277885f0a4 | |||
| dd90297dfc | |||
| 059d4b6d47 | |||
| 6aa4515798 | |||
| 732db75279 | |||
| 9b1b56aad1 | |||
| 8602c81068 | |||
| 2e230021b6 | |||
| b778253cca | |||
| 1c3c9c9d29 | |||
| 0c3354c320 | |||
| bf136ab1d9 | |||
| 1d8713bae3 | |||
| 0ac265f418 | |||
| d204471879 | |||
| adff956863 | |||
| 808ba47a89 | |||
| 507b718917 | |||
| 3d2f51c0a4 | |||
| 7fc5a18d89 | |||
| 2cc97989d3 | |||
| ccedc506a5 | |||
| 0aea99883c | |||
| 7d7dc7510e | |||
| 2e2cbdd19d | |||
| cd3e6e1faf | |||
| ec7635256b | |||
| 5ca65c84cc | |||
| 90c06c04c8 | |||
| fb92ca1a4d | |||
| 5042b8b8e3 | |||
| 8db4bb7583 | |||
| 0bc4472b7e | |||
| e0bf756b50 | |||
| 0c0463b2b7 | |||
| b1d03b4057 | |||
| 6f1d321aab | |||
| ff4edd0559 | |||
| d974ceac21 | |||
| 1c46fa0031 | |||
| 3c03d41f13 | |||
| 4c52731051 | |||
| da43277cc9 | |||
| e49be93f2c | |||
| 9463f1c498 | |||
| 734bb9c2e7 | |||
| 0c4e0d7030 | |||
| 5702ab695b | |||
| 9727ed4547 | |||
| 55ee327981 | |||
| 3aae0fb16d | |||
| 874eebe539 | |||
| f3a5b96b62 | |||
| 9577d6609b | |||
| 0329ec40db | |||
| 81b66e5bf3 | |||
| 5ed9016914 | |||
| 778ac4330a | |||
| 5e77cbd9ec | |||
| 711b1a8f64 | |||
| 956b04975d | |||
| 97ee0c6fd3 | |||
| a2888f8f7d | |||
| 16c949ed5f | |||
| 3d5c0c3b87 | |||
| f1c2be7d32 | |||
| 87ef32c937 | |||
| 06715db218 | |||
| ed1dfe23d4 | |||
| 1769fb2aa1 | |||
| 4491bdad76 | |||
| 95be2c1605 | |||
| 58742acaab | |||
| 59b5db5cbf | |||
| d5b1329bf3 | |||
| e1e0120c0d | |||
| 75e7fc97f8 | |||
| 58d0c179d7 | |||
| 2d26929ff1 | |||
| 7026ddadba | |||
| ef3ac6a58e | |||
| 9b25c8f8e1 | |||
| 5977579da4 | |||
| 54cf4cd857 | |||
| e7dfc88bfa | |||
| c6c222ba84 | |||
| 85eac42d34 | |||
| 5ddca5dd56 | |||
| 20463d1272 | |||
| 06a4bf5701 | |||
| 653100488d | |||
| 731d4666d2 | |||
| d21f07a39e | |||
| 5bf30d258f | |||
| 70b67b0c68 | |||
| f958e311d2 | |||
| bb14412e87 | |||
| 62430d9f3a | |||
| 82a2db71e8 | |||
| 3425823dfb | |||
| c96871db30 | |||
| 472bcbbccc | |||
| 9386e3a9d4 | |||
| d35bdf6311 | |||
| fdbb0fb561 | |||
| c8003f1b33 | |||
| 1f14c8d96b | |||
| bd288e7683 | |||
| 34f9211e5c | |||
| df956f8162 | |||
| c2a192a48a | |||
| c7f08d19ea | |||
| 5012c38adc | |||
| 95cdaf957b | |||
| 58a9905976 | |||
| a848ddaa7e | |||
| 8d1510eb7b | |||
| 09d7f276cb | |||
| 64f32418b9 | |||
| 2aaa17dc6f | |||
| e0d1810632 | |||
| 7fbd31818b | |||
| 66e06b50ba | |||
| 088b41b04b | |||
| 17c4f62312 | |||
| 1898b2093f | |||
| 4bc46ccfed | |||
| 1169dd7039 | |||
| 2cab81d9a7 | |||
| 6821b8bfaa | |||
| 0a9616caa8 | |||
| 3362cccc20 | |||
| 6b2d418973 | |||
| acd4e0a32f | |||
| 0a87707092 | |||
| 3745d5b135 | |||
| 665a8496d7 | |||
| 30073feb6f | |||
| 16851e2d55 | |||
| e31dfa9897 | |||
| 4d6eeebda2 | |||
| 3a371a32e2 | |||
| 5a9ee98419 | |||
| 4c090accd3 | |||
| 074efc94dc | |||
| a80ecb9937 | |||
| 604a5e07d0 | |||
| f190bf0a07 | |||
| 7b05b4b68e | |||
| f8610da4c5 | |||
| 9843a4f1fc | |||
| f12d6bf3bb | |||
| 1b73ce9193 | |||
| 8d3bd4859e | |||
| 87eac5377c | |||
| 2f29a58b4e | |||
| 450c3a8ed2 | |||
| daabbce9a0 | |||
| 8fa63ac380 | |||
| 5c28cfbf09 | |||
| 27b14124d0 | |||
| 41d649c31a | |||
| 8b404eccff | |||
| 3624dda67d | |||
| b2b3bf35cd | |||
| eea7ba5345 | |||
| 0bf3b84669 | |||
| 60ea8e9a1c | |||
| 34afe10b00 | |||
| f366c38df8 | |||
| b4e4f96fd5 | |||
| 3bb9e5f580 | |||
| 0d2a4b4fec | |||
| a7436c8b4a | |||
| 905b18530f | |||
| 37d5971db3 | |||
| b4e38a7eb0 | |||
| 30dcebae80 | |||
| 546edcaa03 | |||
| 66701d5a1e | |||
| f95c706298 | |||
| 25efa8cf81 | |||
| 36f4913e30 | |||
| 4e46488d1b | |||
| e5c38290a6 | |||
| 4c15f1310b | |||
| 20bfa3cce3 | |||
| 356110c52f | |||
| a14f7c5c6d | |||
| 60961a7f55 | |||
| 9c42e6424d | |||
| 50e5f95cc6 | |||
| 2f378ecd1d | |||
| 5237a149ff | |||
| 3f8c6dd341 | |||
| 3487e7f8e2 | |||
| e121889a9f | |||
| 135c3848b9 | |||
| e3c58e04c9 | |||
| 1558a352f8 | |||
| 8f0628d43b | |||
| f08b1cd972 | |||
| 1795a426c9 | |||
| 02a4dc1a91 | |||
| 7184200c17 | |||
| a88c6f0fe7 | |||
| 4015138df9 | |||
| dae060390b | |||
| d86527d8c6 |
@@ -16,7 +16,22 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Pass `--json` for machine-readable summaries.
|
||||
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
|
||||
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
|
||||
- Hard-cap every top-level Parallels lane with host `timeout --foreground` (or `gtimeout --foreground` if that is the available binary) so a stalled install, snapshot switch, or `prlctl exec` transport cannot consume the rest of the testing window. Defaults:
|
||||
- macOS: `75m`
|
||||
- Linux: `75m`
|
||||
- Windows: `90m`
|
||||
- aggregate npm-update wrapper: `150m`
|
||||
If a lane hits the cap, stop there, inspect the newest `/tmp/openclaw-parallels-*` run directory and phase log, then fix or rerun the smallest affected lane. Do not keep waiting on a capped lane.
|
||||
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: they should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, `install-baseline-package`, `update-dev`, or same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
|
||||
- For a full OS matrix, prefer running independent guest-family lanes in parallel when host capacity allows:
|
||||
- `timeout --foreground 75m pnpm test:parallels:macos -- --json`
|
||||
- `timeout --foreground 90m pnpm test:parallels:windows -- --json`
|
||||
- `timeout --foreground 75m pnpm test:parallels:linux -- --json`
|
||||
Keep each lane in its own shell/session and track the run directory for each one.
|
||||
- Do not run multiple smoke lanes against the same guest family at once. Tahoe lanes share the host HTTP port, and Windows/Linux lanes can collide on snapshot restore/start state if two jobs touch the same VM concurrently.
|
||||
- Do not run the aggregate `pnpm test:parallels:npm-update` wrapper in parallel with individual macOS/Windows/Linux smoke lanes; it touches the same guest families and snapshots.
|
||||
- Do not start Parallels lanes while any host command may rebuild, clean, or restage `dist` (`pnpm build`, `pnpm ui:build`, `pnpm release:check`, `pnpm test:install:smoke`, npm pack/install smoke, or Docker lanes that run package/build prep). Run the build/package gates first, let them finish, then start the VM matrix. Concurrent `dist` mutation can make host `npm pack` fail with missing files and wastes a full VM cycle.
|
||||
- While running or optimizing the matrix, record wall-clock duration per lane and the slowest phase from `/tmp/openclaw-parallels-*` logs. Use that timing before changing smoke order, timeouts, or helper behavior.
|
||||
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
|
||||
- For `openclaw update --channel dev` lanes, remember the guest clones GitHub `main`, not your local worktree. If a local fix exists but the rerun still fails inside the cloned dev checkout, do not treat that as disproof of the fix until the branch has been pushed.
|
||||
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
|
||||
|
||||
@@ -12,8 +12,8 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
|
||||
- `docs/concepts/qa-e2e-automation.md`
|
||||
- `docs/help/testing.md`
|
||||
- `docs/channels/qa-channel.md`
|
||||
- `qa/QA_KICKOFF_TASK.md`
|
||||
- `qa/seed-scenarios.json`
|
||||
- `qa/README.md`
|
||||
- `qa/scenarios/index.md`
|
||||
- `extensions/qa-lab/src/suite.ts`
|
||||
- `extensions/qa-lab/src/character-eval.ts`
|
||||
|
||||
@@ -28,24 +28,24 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
|
||||
|
||||
## Default workflow
|
||||
|
||||
1. Read the seed plan and current suite implementation.
|
||||
1. Read the scenario pack and current suite implementation.
|
||||
2. Decide lane:
|
||||
- mock/dev: `mock-openai`
|
||||
- real validation: `live-openai`
|
||||
- real validation: `live-frontier`
|
||||
3. For live OpenAI, use:
|
||||
|
||||
```bash
|
||||
OPENCLAW_LIVE_OPENAI_KEY="${OPENAI_API_KEY}" \
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode live-openai \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
|
||||
--output-dir .artifacts/qa-e2e/run-all-live-frontier-<tag>
|
||||
```
|
||||
|
||||
4. Watch outputs:
|
||||
- summary: `.artifacts/qa-e2e/run-all-live-openai-<tag>/qa-suite-summary.json`
|
||||
- report: `.artifacts/qa-e2e/run-all-live-openai-<tag>/qa-suite-report.md`
|
||||
- summary: `.artifacts/qa-e2e/run-all-live-frontier-<tag>/qa-suite-summary.json`
|
||||
- report: `.artifacts/qa-e2e/run-all-live-frontier-<tag>/qa-suite-report.md`
|
||||
5. If the user wants to watch the live UI, find the current `openclaw-qa` listen port and report `http://127.0.0.1:<port>`.
|
||||
6. If a scenario fails, fix the product or harness root cause, then rerun the full lane.
|
||||
|
||||
@@ -141,8 +141,8 @@ pnpm openclaw qa manual \
|
||||
|
||||
## When adding scenarios
|
||||
|
||||
- Add scenario metadata to `qa/seed-scenarios.json`
|
||||
- Keep kickoff expectations in `qa/QA_KICKOFF_TASK.md` aligned
|
||||
- Add or update scenario markdown under `qa/scenarios/`
|
||||
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
|
||||
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
|
||||
- Prefer end-to-end assertions over mock-only checks
|
||||
- Save outputs under `.artifacts/qa-e2e/`
|
||||
|
||||
@@ -90,6 +90,18 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
now fails the candidate update tarball when npm reports an oversized
|
||||
`unpackedSize`, so release-time e2e cannot miss pack bloat that would risk
|
||||
low-memory install/startup failures.
|
||||
- Keep direct npm global coverage enabled in install smoke. It exercises plain
|
||||
`npm install -g <candidate>` fresh installs and npm-driven update installs,
|
||||
because many users install with npm even when docs prefer pnpm.
|
||||
- Use `pnpm test:live:media video` for bounded video-provider smoke when video
|
||||
generation is in release scope. The default video smoke skips `fal`, runs one
|
||||
text-to-video attempt per provider with a one-second lobster prompt, and caps
|
||||
each provider operation with `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS`
|
||||
(`180000` by default).
|
||||
- Run `pnpm test:live:media video --video-providers fal` only when FAL-specific
|
||||
proof is required. Its queue latency can dominate release time.
|
||||
- Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` only when intentionally
|
||||
validating the slower image-to-video and video-to-video transform lanes.
|
||||
|
||||
## Check all relevant release builds
|
||||
|
||||
@@ -101,6 +113,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- `pnpm release:check`
|
||||
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
|
||||
- Check all release-related build surfaces touched by the release, not only the npm package.
|
||||
- For beta-style full e2e batteries, hard-cap top-level long lanes instead of letting them run indefinitely. Use host `timeout --foreground`/`gtimeout --foreground` caps such as:
|
||||
- `45m` for `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
|
||||
- `90m` for `pnpm test:docker:all`
|
||||
- Parallels caps from the `openclaw-parallels-smoke` skill
|
||||
If a lane hits its cap, stop and inspect/fix the affected lane before continuing; do not continue to wait on the same process.
|
||||
- Actual npm install/update phases are capped at 5 minutes. If `npm install -g`, installer package install, or `openclaw update` takes longer than 300s in release e2e, stop treating the run as healthy progress and debug the installer/updater or harness.
|
||||
- Serialize host build/package mutations ahead of VM lanes. Finish `pnpm build`, `pnpm ui:build`, `pnpm release:check`, install smoke, and any Docker/package-prep lanes before starting Parallels `npm pack` lanes; otherwise `dist` can disappear during VM pack prep and produce false failures.
|
||||
- Include mac release readiness in preflight by running the public validation
|
||||
workflow in `openclaw/openclaw` and the real mac preflight in
|
||||
`openclaw/releases-private` for every release.
|
||||
|
||||
@@ -61,18 +61,20 @@ The `fetch-content` output includes:
|
||||
- `issue_number` / `pr_number`: where it is
|
||||
- `edit_history_count`: number of existing edits
|
||||
- `type`: location type for routing
|
||||
- For `discussion_comment`, it also includes `comment_node_id`, `discussion_node_id`, and `reply_to_node_id` when the original comment was a reply.
|
||||
|
||||
### Location type routing
|
||||
|
||||
| type | Flow |
|
||||
| ----------------------------- | ------------------------ |
|
||||
| `issue_comment` | Comment: delete+recreate |
|
||||
| `pull_request_comment` | Comment: delete+recreate |
|
||||
| `pull_request_review_comment` | Comment: delete+recreate |
|
||||
| `issue_body` | Body: redact in place |
|
||||
| `pull_request_body` | Body: redact in place |
|
||||
| `commit` | Notify only |
|
||||
| _other_ | Skip and report |
|
||||
| type | Flow |
|
||||
| ----------------------------- | --------------------------------------------- |
|
||||
| `issue_comment` | Comment: delete+recreate |
|
||||
| `pull_request_comment` | Comment: delete+recreate |
|
||||
| `pull_request_review_comment` | Comment: delete+recreate |
|
||||
| `discussion_comment` | Discussion comment: delete+recreate (GraphQL) |
|
||||
| `issue_body` | Body: redact in place |
|
||||
| `pull_request_body` | Body: redact in place |
|
||||
| `commit` | Notify only |
|
||||
| _other_ | Skip and report |
|
||||
|
||||
## Step 2: Decide (Agent)
|
||||
|
||||
@@ -100,15 +102,28 @@ node secret-scanning.mjs redact-body <issue|pr> <NUMBER> <redacted-body-file>
|
||||
|
||||
### Comments — Delete and Recreate
|
||||
|
||||
For issue/PR comments:
|
||||
|
||||
```bash
|
||||
# Delete original (all edit history gone)
|
||||
node secret-scanning.mjs delete-comment <COMMENT_ID>
|
||||
|
||||
# Recreate with redacted content
|
||||
# Agent prepares the body file with maintainer header + redacted content
|
||||
node secret-scanning.mjs recreate-comment <ISSUE_NUMBER> <body-file>
|
||||
```
|
||||
|
||||
For discussion comments (uses GraphQL):
|
||||
|
||||
```bash
|
||||
# Delete original
|
||||
node secret-scanning.mjs delete-discussion-comment <COMMENT_NODE_ID>
|
||||
|
||||
# Recreate with redacted content
|
||||
node secret-scanning.mjs recreate-discussion-comment <DISCUSSION_NODE_ID> <body-file> [REPLY_TO_NODE_ID]
|
||||
```
|
||||
|
||||
The `fetch-content` output for `discussion_comment` includes `comment_node_id` and `discussion_node_id` for these commands. When the original discussion comment was a reply, it also includes `reply_to_node_id`; pass that optional third argument so the redacted replacement stays in the original thread.
|
||||
|
||||
The recreated comment should follow this format:
|
||||
|
||||
```
|
||||
@@ -140,9 +155,13 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
|
||||
## Step 5: Notify
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs notify <ISSUE_NUMBER> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES>
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
|
||||
```
|
||||
|
||||
- For non-discussion types, `<TARGET>` is the issue/PR number.
|
||||
- For `discussion_comment`, `<TARGET>` is the `discussion_node_id` returned by `fetch-content`.
|
||||
- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.
|
||||
|
||||
Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"`
|
||||
|
||||
The script picks the right template:
|
||||
|
||||
@@ -30,6 +30,14 @@ function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
if (proc.status !== 0 && !allowFailure) {
|
||||
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
|
||||
}
|
||||
if (proc.status !== 0) {
|
||||
return {
|
||||
gh_failed: true,
|
||||
status: proc.status,
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
};
|
||||
}
|
||||
if (!json) return proc.stdout;
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
@@ -38,8 +46,159 @@ function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function ghGraphQL(query) {
|
||||
return gh(["api", "graphql", "-f", `query=${query}`]);
|
||||
function ghGraphQL(query, options = {}) {
|
||||
return gh(["api", "graphql", "-f", `query=${query}`], options);
|
||||
}
|
||||
|
||||
function failOnGraphQLFailure(result, message) {
|
||||
if (result?.gh_failed) {
|
||||
const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim();
|
||||
fail(`${message}: ${details}`);
|
||||
}
|
||||
if (Array.isArray(result?.errors) && result.errors.length > 0) {
|
||||
fail(`${message}: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeGraphQLString(value) {
|
||||
return String(value)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
function formatGraphQLAfterClause(cursor) {
|
||||
return cursor ? `, after: "${escapeGraphQLString(cursor)}"` : "";
|
||||
}
|
||||
|
||||
function findDiscussionCommentNode(nodes, discussionCommentDbId) {
|
||||
return (
|
||||
nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function fetchDiscussionReplyPage(commentNodeId, cursor) {
|
||||
const afterClause = formatGraphQLAfterClause(cursor);
|
||||
return ghGraphQL(`{
|
||||
node(id: "${escapeGraphQLString(commentNodeId)}") {
|
||||
... on DiscussionComment {
|
||||
replies(first: 100${afterClause}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { login }
|
||||
body
|
||||
url
|
||||
replyTo { id }
|
||||
userContentEdits(first: 50) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}`);
|
||||
}
|
||||
|
||||
function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
|
||||
const [owner, name] = REPO.split("/");
|
||||
let discussionId = null;
|
||||
let cursor = null;
|
||||
let hasNextPage = true;
|
||||
|
||||
while (hasNextPage) {
|
||||
const afterClause = formatGraphQLAfterClause(cursor);
|
||||
const gql = ghGraphQL(
|
||||
`{
|
||||
repository(owner: "${owner}", name: "${name}") {
|
||||
discussion(number: ${discussionNumber}) {
|
||||
id
|
||||
comments(first: 50${afterClause}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { login }
|
||||
body
|
||||
url
|
||||
replyTo { id }
|
||||
userContentEdits(first: 50) {
|
||||
totalCount
|
||||
}
|
||||
replies(first: 100) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { login }
|
||||
body
|
||||
url
|
||||
replyTo { id }
|
||||
userContentEdits(first: 50) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ allowFailure: true },
|
||||
);
|
||||
failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);
|
||||
|
||||
const discussion = gql?.data?.repository?.discussion;
|
||||
if (!discussion)
|
||||
fail(
|
||||
`Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,
|
||||
);
|
||||
|
||||
discussionId = discussion.id;
|
||||
|
||||
for (const topLevelComment of discussion.comments.nodes) {
|
||||
if (String(topLevelComment.databaseId) === String(discussionCommentDbId)) {
|
||||
return { discussionId, comment: topLevelComment };
|
||||
}
|
||||
|
||||
let reply = findDiscussionCommentNode(topLevelComment.replies.nodes, discussionCommentDbId);
|
||||
let replyCursor = topLevelComment.replies.pageInfo.endCursor;
|
||||
let hasMoreReplies = topLevelComment.replies.pageInfo.hasNextPage;
|
||||
|
||||
while (!reply && hasMoreReplies) {
|
||||
const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);
|
||||
failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`);
|
||||
const replies = replyPage?.data?.node?.replies;
|
||||
if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
|
||||
|
||||
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
|
||||
hasMoreReplies = replies.pageInfo.hasNextPage;
|
||||
replyCursor = replies.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
if (reply) return { discussionId, comment: reply };
|
||||
}
|
||||
|
||||
hasNextPage = discussion.comments.pageInfo.hasNextPage;
|
||||
cursor = discussion.comments.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
return { discussionId, comment: null };
|
||||
}
|
||||
|
||||
function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
|
||||
const replyToClause = replyToNodeId
|
||||
? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"`
|
||||
: "";
|
||||
const result = ghGraphQL(
|
||||
`mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`,
|
||||
);
|
||||
if (result?.errors) {
|
||||
fail(`Failed to create discussion comment: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
return result?.data?.addDiscussionComment?.comment;
|
||||
}
|
||||
|
||||
// ─── Commands ───────────────────────────────────────────────────────────────
|
||||
@@ -93,12 +252,48 @@ function cmdFetchContent(locationJson) {
|
||||
const type = location.type;
|
||||
const details = location.details;
|
||||
|
||||
if (
|
||||
if (type === "discussion_comment") {
|
||||
const commentUrl = details.discussion_comment_url;
|
||||
if (!commentUrl) fail("No discussion_comment_url in location details");
|
||||
|
||||
const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/);
|
||||
if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);
|
||||
const discussionNumber = urlMatch[1];
|
||||
const discussionCommentDbId = urlMatch[2];
|
||||
|
||||
const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId);
|
||||
if (!comment)
|
||||
fail(
|
||||
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
|
||||
);
|
||||
|
||||
const bodyFile = tmpFile("body.md");
|
||||
fs.writeFileSync(bodyFile, comment.body || "");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
type,
|
||||
comment_node_id: comment.id,
|
||||
discussion_node_id: discussionId,
|
||||
reply_to_node_id: comment.replyTo?.id ?? null,
|
||||
discussion_number: Number(discussionNumber),
|
||||
discussion_comment_db_id: Number(discussionCommentDbId),
|
||||
author: comment.author?.login,
|
||||
html_url: comment.url || commentUrl,
|
||||
edit_history_count: comment.userContentEdits?.totalCount ?? 0,
|
||||
body_file: bodyFile,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
type === "issue_comment" ||
|
||||
type === "pull_request_comment" ||
|
||||
type === "pull_request_review_comment"
|
||||
) {
|
||||
// 从 url 中提取 comment ID
|
||||
// Extract comment ID from URL
|
||||
const commentUrl =
|
||||
details.issue_comment_url ||
|
||||
details.pull_request_comment_url ||
|
||||
@@ -109,7 +304,7 @@ function cmdFetchContent(locationJson) {
|
||||
const bodyFile = tmpFile("body.md");
|
||||
fs.writeFileSync(bodyFile, comment.body || "");
|
||||
|
||||
// 获取编辑历史
|
||||
// Fetch edit history
|
||||
const nodeId = comment.node_id;
|
||||
const typeName =
|
||||
type === "pull_request_review_comment" ? "PullRequestReviewComment" : "IssueComment";
|
||||
@@ -124,7 +319,7 @@ function cmdFetchContent(locationJson) {
|
||||
}`);
|
||||
const editCount = gql?.data?.node?.userContentEdits?.totalCount ?? 0;
|
||||
|
||||
// 提取 issue number(从 html_url)
|
||||
// Extract issue number from html_url
|
||||
const htmlUrl = comment.html_url || details.html_url || "";
|
||||
const issueMatch = htmlUrl.match(/\/(issues|pull)\/(\d+)/);
|
||||
const issueNumber = issueMatch ? issueMatch[2] : null;
|
||||
@@ -229,7 +424,7 @@ function cmdFetchContent(locationJson) {
|
||||
start_line: details.start_line,
|
||||
end_line: details.end_line,
|
||||
html_url: details.html_url || details.commit_url || details.blob_url || null,
|
||||
// commit 没有 body 文件
|
||||
// No body file for commits
|
||||
body_file: null,
|
||||
},
|
||||
null,
|
||||
@@ -278,6 +473,41 @@ function cmdDeleteComment(commentId) {
|
||||
console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* delete-discussion-comment <node-id>
|
||||
* Delete a discussion comment via GraphQL (and all its edit history).
|
||||
*/
|
||||
function cmdDeleteDiscussionComment(nodeId) {
|
||||
if (!nodeId) fail("Usage: delete-discussion-comment <node-id>");
|
||||
const result = ghGraphQL(
|
||||
`mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`,
|
||||
);
|
||||
if (result?.errors) {
|
||||
fail(`Failed to delete discussion comment: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, deleted_node_id: nodeId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]
|
||||
* Create a new discussion comment via GraphQL.
|
||||
*/
|
||||
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
|
||||
if (!discussionNodeId || !bodyFile)
|
||||
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
|
||||
const body = fs.readFileSync(bodyFile, "utf8");
|
||||
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
node_id: newComment?.id,
|
||||
html_url: newComment?.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* recreate-comment <issue-number> <body-file>
|
||||
* Create a new comment from a file.
|
||||
@@ -305,12 +535,15 @@ function cmdRecreateComment(issueNumber, bodyFile) {
|
||||
}
|
||||
|
||||
/**
|
||||
* notify <issue-or-pr-number> <author> <location-type> <secret-types>
|
||||
* notify <target> <author> <location-type> <secret-types> [reply-to-node-id]
|
||||
* Post a notification comment with the correct template for the location type.
|
||||
* target = issue/PR number for non-discussion types, discussion node ID for discussion_comment.
|
||||
*/
|
||||
function cmdNotify(issueNumber, author, locationType, secretTypes) {
|
||||
if (!issueNumber || !author || !locationType || !secretTypes) {
|
||||
fail("Usage: notify <issue-or-pr-number> <author> <location-type> <secret-types-comma-sep>");
|
||||
function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
|
||||
if (!target || !author || !locationType || !secretTypes) {
|
||||
fail(
|
||||
"Usage: notify <target> <author> <location-type> <secret-types-comma-sep> [reply-to-node-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const types = secretTypes.split(",").map((s) => s.trim());
|
||||
@@ -321,7 +554,8 @@ function cmdNotify(issueNumber, author, locationType, secretTypes) {
|
||||
if (
|
||||
locationType === "issue_comment" ||
|
||||
locationType === "pull_request_comment" ||
|
||||
locationType === "pull_request_review_comment"
|
||||
locationType === "pull_request_review_comment" ||
|
||||
locationType === "discussion_comment"
|
||||
) {
|
||||
locationDesc = "your comment";
|
||||
actionDesc = "The affected comment has been removed and replaced with a redacted version.";
|
||||
@@ -355,12 +589,26 @@ function cmdNotify(issueNumber, author, locationType, secretTypes) {
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n");
|
||||
|
||||
// Discussion comments must be notified via GraphQL
|
||||
if (locationType === "discussion_comment") {
|
||||
const newComment = createDiscussionComment(target, body, replyToNodeId);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
node_id: newComment?.id,
|
||||
html_url: newComment?.url,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Issue/PR comments via REST
|
||||
const bodyFile = tmpFile("notify.md");
|
||||
fs.writeFileSync(bodyFile, body);
|
||||
|
||||
const result = gh([
|
||||
"api",
|
||||
`repos/${REPO}/issues/${issueNumber}/comments`,
|
||||
`repos/${REPO}/issues/${target}/comments`,
|
||||
"-X",
|
||||
"POST",
|
||||
"-F",
|
||||
@@ -508,8 +756,10 @@ const commands = {
|
||||
"fetch-content": () => cmdFetchContent(args[0]),
|
||||
"redact-body": () => cmdRedactBody(args[0], args[1], args[2]),
|
||||
"delete-comment": () => cmdDeleteComment(args[0]),
|
||||
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
|
||||
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
|
||||
notify: () => cmdNotify(args[0], args[1], args[2], args[3]),
|
||||
"recreate-discussion-comment": () => cmdRecreateDiscussionComment(args[0], args[1], args[2]),
|
||||
notify: () => cmdNotify(args[0], args[1], args[2], args[3], args[4]),
|
||||
resolve: () => cmdResolve(args[0], args[1], args[2]),
|
||||
"list-open": () => cmdListOpen(),
|
||||
summary: () => cmdSummary(args[0]),
|
||||
@@ -525,8 +775,10 @@ if (!command || !commands[command]) {
|
||||
" fetch-content '<location-json>' Fetch content for a location",
|
||||
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
|
||||
" delete-comment <comment-id> Delete a comment",
|
||||
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
|
||||
" recreate-comment <issue-n> <file> Create replacement comment",
|
||||
" notify <n> <author> <type> <types> Post notification",
|
||||
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
|
||||
" resolve <n> [resolution] [comment] Close alert",
|
||||
" list-open List open alerts",
|
||||
" summary <json-file> Print formatted summary",
|
||||
|
||||
@@ -243,6 +243,7 @@ jobs:
|
||||
task: "test-shard",
|
||||
shard_name: shard.shardName,
|
||||
configs: shard.configs,
|
||||
requires_dist: shard.requiresDist,
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
@@ -420,16 +421,23 @@ jobs:
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
run: pnpm build:ci-artifacts
|
||||
|
||||
- name: Build Control UI
|
||||
run: pnpm ui:build
|
||||
|
||||
- name: Cache dist build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: dist/
|
||||
key: ${{ runner.os }}-dist-build-${{ github.sha }}
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
compression-level: 0
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload A2UI bundle artifact
|
||||
@@ -640,7 +648,16 @@ jobs:
|
||||
- name: Configure Node test resources
|
||||
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore dist cache
|
||||
id: dist-cache
|
||||
if: matrix.requires_dist == true
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: dist/
|
||||
key: ${{ runner.os }}-dist-build-${{ github.sha }}
|
||||
|
||||
- name: Download dist artifact
|
||||
if: matrix.requires_dist == true && steps.dist-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
@@ -871,6 +888,8 @@ jobs:
|
||||
- name: Run extension package boundary TypeScript check
|
||||
id: extension_package_boundary_tsc
|
||||
continue-on-error: true
|
||||
env:
|
||||
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
|
||||
run: pnpm run test:extensions:package-boundary
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
@@ -988,8 +1007,16 @@ jobs:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Download dist artifact
|
||||
- name: Restore dist cache
|
||||
id: build-smoke-dist-cache
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: dist/
|
||||
key: ${{ runner.os }}-dist-build-${{ github.sha }}
|
||||
|
||||
- name: Download dist artifact
|
||||
if: github.event_name == 'push' && steps.build-smoke-dist-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
|
||||
@@ -211,4 +211,6 @@ jobs:
|
||||
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }}
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: openclaw-dockerfile-smoke:local
|
||||
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
|
||||
run: bash scripts/test-install-sh-docker.sh
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
name: OpenClaw Cross-OS Release Checks (Reusable)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
|
||||
required: true
|
||||
type: string
|
||||
provider:
|
||||
description: Provider lane to use for onboarding and the end-to-end turn
|
||||
required: true
|
||||
type: string
|
||||
mode:
|
||||
description: Which release-check lanes to run
|
||||
required: true
|
||||
type: string
|
||||
previous_version:
|
||||
description: Optional baseline version for the upgrade lane (defaults to npm latest)
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ubuntu_runner:
|
||||
description: Optional Linux runner label override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
windows_runner:
|
||||
description: Optional Windows runner label override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
macos_runner:
|
||||
description: Optional macOS runner label override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
required: false
|
||||
ANTHROPIC_API_KEY:
|
||||
required: false
|
||||
MINIMAX_API_KEY:
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: openclaw-cross-os-release-checks-${{ inputs.ref }}-${{ inputs.provider }}-${{ inputs.mode }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
OPENCLAW_REPOSITORY: openclaw/openclaw
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
|
||||
baseline_spec: ${{ steps.baseline.outputs.value }}
|
||||
candidate_file_name: ${{ steps.candidate_metadata.outputs.file_name }}
|
||||
candidate_version: ${{ steps.candidate_metadata.outputs.version }}
|
||||
matrix: ${{ steps.matrix.outputs.value }}
|
||||
source_sha: ${{ steps.candidate_metadata.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Validate provider secret availability
|
||||
env:
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${PROVIDER}" in
|
||||
openai)
|
||||
[[ -n "${OPENAI_API_KEY}" ]] || { echo "Missing OPENAI_API_KEY secret." >&2; exit 1; }
|
||||
;;
|
||||
anthropic)
|
||||
[[ -n "${ANTHROPIC_API_KEY}" ]] || { echo "Missing ANTHROPIC_API_KEY secret." >&2; exit 1; }
|
||||
;;
|
||||
minimax)
|
||||
[[ -n "${MINIMAX_API_KEY}" ]] || { echo "Missing MINIMAX_API_KEY secret." >&2; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported provider: ${PROVIDER}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout caller release workflow repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout public source ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.OPENCLAW_REPOSITORY }}
|
||||
ref: ${{ inputs.ref }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: source/pnpm-lock.yaml
|
||||
|
||||
- name: Build candidate artifact once
|
||||
env:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare
|
||||
run: |
|
||||
node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \
|
||||
--prepare-only \
|
||||
--source-dir source \
|
||||
--output-dir "${OUTPUT_DIR}"
|
||||
|
||||
- name: Resolve baseline package spec
|
||||
if: ${{ inputs.mode != 'fresh' }}
|
||||
id: baseline
|
||||
env:
|
||||
INPUT_PREVIOUS_VERSION: ${{ inputs.previous_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${INPUT_PREVIOUS_VERSION}" ]]; then
|
||||
echo "value=openclaw@${INPUT_PREVIOUS_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
BASELINE_VERSION="$(npm view openclaw@latest version)"
|
||||
echo "value=openclaw@${BASELINE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Pack baseline artifact
|
||||
if: ${{ inputs.mode != 'fresh' }}
|
||||
env:
|
||||
BASELINE_SPEC: ${{ steps.baseline.outputs.value }}
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline
|
||||
run: |
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
|
||||
- name: Capture candidate metadata
|
||||
id: candidate_metadata
|
||||
env:
|
||||
CANDIDATE_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/candidate.json
|
||||
run: |
|
||||
node <<'NODE' >>"$GITHUB_OUTPUT"
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync(process.env.CANDIDATE_JSON, "utf8"));
|
||||
process.stdout.write(`file_name=${payload.candidateFileName}\n`);
|
||||
process.stdout.write(`version=${payload.candidateVersion}\n`);
|
||||
process.stdout.write(`source_sha=${payload.sourceSha}\n`);
|
||||
NODE
|
||||
|
||||
- name: Capture baseline metadata
|
||||
if: ${{ inputs.mode != 'fresh' }}
|
||||
id: baseline_metadata
|
||||
env:
|
||||
BASELINE_PACK_JSON: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline/pack.json
|
||||
run: |
|
||||
node <<'NODE' >>"$GITHUB_OUTPUT"
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync(process.env.BASELINE_PACK_JSON, "utf8"));
|
||||
const entry = Array.isArray(payload) ? payload.at(-1) : null;
|
||||
if (!entry?.filename) {
|
||||
throw new Error("Baseline npm pack did not produce a filename.");
|
||||
}
|
||||
process.stdout.write(`file_name=${entry.filename}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload candidate artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package/${{ steps.candidate_metadata.outputs.file_name }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload baseline artifact
|
||||
if: ${{ inputs.mode != 'fresh' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline/${{ steps.baseline_metadata.outputs.file_name }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Resolve runner matrix
|
||||
id: matrix
|
||||
env:
|
||||
INPUT_MODE: ${{ inputs.mode }}
|
||||
INPUT_UBUNTU_RUNNER: ${{ inputs.ubuntu_runner }}
|
||||
INPUT_WINDOWS_RUNNER: ${{ inputs.windows_runner }}
|
||||
INPUT_MACOS_RUNNER: ${{ inputs.macos_runner }}
|
||||
VAR_UBUNTU_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER }}
|
||||
VAR_WINDOWS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER }}
|
||||
VAR_MACOS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER }}
|
||||
run: |
|
||||
node <<'NODE' >>"$GITHUB_OUTPUT"
|
||||
const pick = (...values) => values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
|
||||
const lanes = (process.env.INPUT_MODE ?? "both") === "both" ? ["fresh", "upgrade"] : [process.env.INPUT_MODE ?? "both"];
|
||||
const runners = [
|
||||
{
|
||||
os_id: "ubuntu",
|
||||
display_name: "Linux",
|
||||
runner: pick(process.env.INPUT_UBUNTU_RUNNER, process.env.VAR_UBUNTU_RUNNER, "ubuntu-latest"),
|
||||
artifact_name: "linux",
|
||||
},
|
||||
{
|
||||
os_id: "windows",
|
||||
display_name: "Windows",
|
||||
runner: pick(
|
||||
process.env.INPUT_WINDOWS_RUNNER,
|
||||
process.env.VAR_WINDOWS_RUNNER,
|
||||
"blacksmith-32vcpu-windows-2025",
|
||||
),
|
||||
artifact_name: "windows",
|
||||
},
|
||||
{
|
||||
os_id: "macos",
|
||||
display_name: "macOS",
|
||||
runner: pick(process.env.INPUT_MACOS_RUNNER, process.env.VAR_MACOS_RUNNER, "macos-latest-xlarge"),
|
||||
artifact_name: "macos",
|
||||
},
|
||||
];
|
||||
const matrix = {
|
||||
include: runners.flatMap((runner) => lanes.map((lane) => ({ ...runner, lane }))),
|
||||
};
|
||||
process.stdout.write(`value=${JSON.stringify(matrix)}\n`);
|
||||
NODE
|
||||
|
||||
cross_os_release_checks:
|
||||
name: "${{ matrix.display_name }} / ${{ matrix.lane }}"
|
||||
needs: prepare
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout caller release workflow repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Download candidate artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate
|
||||
|
||||
- name: Download baseline artifact
|
||||
if: ${{ matrix.lane == 'upgrade' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline
|
||||
|
||||
- name: Run cross-OS release checks
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }}
|
||||
OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }}
|
||||
run: |
|
||||
node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \
|
||||
--candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \
|
||||
--candidate-version "${{ needs.prepare.outputs.candidate_version }}" \
|
||||
--source-sha "${{ needs.prepare.outputs.source_sha }}" \
|
||||
--baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \
|
||||
--baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \
|
||||
--provider "${{ inputs.provider }}" \
|
||||
--mode "${{ matrix.lane }}" \
|
||||
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}"
|
||||
|
||||
- name: Summarize release checks
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}/summary.md
|
||||
run: |
|
||||
if [[ -f "${SUMMARY_PATH}" ]]; then
|
||||
cat "${SUMMARY_PATH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No summary generated." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload release-check artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.lane }}-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}
|
||||
if-no-files-found: error
|
||||
@@ -24,19 +24,9 @@ on:
|
||||
options:
|
||||
- beta
|
||||
- latest
|
||||
promote_beta_to_latest:
|
||||
description: Skip publish and promote the stable version already on npm beta to latest
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
sync_stable_dist_tags:
|
||||
description: Skip publish and point both latest and beta at an already-published stable version
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}-{2}-{3}', inputs.tag, inputs.npm_dist_tag, inputs.promote_beta_to_latest, inputs.sync_stable_dist_tags) || github.ref }}
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -48,8 +38,11 @@ jobs:
|
||||
# PLEASE DON'T ADD LONG-RUNNING OR FLAKY CHECKS TO THE npm RELEASE PATH.
|
||||
# KEEP THIS WORKFLOW SHORT AND DETERMINISTIC OR IT CAN GET STUCK AND JEOPARDIZE THE RELEASE.
|
||||
# RELEASE-TIME LIVE OR END-TO-END VALIDATION BELONGS IN openclaw-release-checks.yml.
|
||||
# SECURITY NOTE: TOKEN-BASED npm dist-tag mutation moved to
|
||||
# openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this public workflow can stay focused on OIDC publish only.
|
||||
preflight_openclaw_npm:
|
||||
if: ${{ inputs.preflight_only && !inputs.promote_beta_to_latest && !inputs.sync_stable_dist_tags }}
|
||||
if: ${{ inputs.preflight_only }}
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -246,7 +239,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest && !inputs.sync_stable_dist_tags }}
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -275,7 +268,7 @@ jobs:
|
||||
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
|
||||
# npm trusted publishing + provenance requires this to stay on ubuntu-latest.
|
||||
needs: [validate_publish_request]
|
||||
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest && !inputs.sync_stable_dist_tags }}
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
@@ -411,201 +404,3 @@ jobs:
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
bash scripts/openclaw-npm-publish.sh --publish "${publish_target}"
|
||||
|
||||
promote_beta_to_latest:
|
||||
# KEEP THE MUTATING RELEASE PATH ON A GITHUB-HOSTED RUNNER TOO.
|
||||
# This job changes the public npm dist-tags, so we keep it aligned with the
|
||||
# real release path instead of moving it onto the larger Blacksmith runners.
|
||||
if: ${{ inputs.promote_beta_to_latest && !inputs.sync_stable_dist_tags }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for promotion
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Promotion runs must be dispatched from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate promotion inputs
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "Promotion mode cannot run with preflight_only=true."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Promotion mode does not use preflight_run_id."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Promotion mode expects npm_dist_tag=beta because it moves beta to latest without publishing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate stable tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid stable release tag format: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Validate npm dist-tags
|
||||
env:
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
beta_version="$(npm view openclaw dist-tags.beta)"
|
||||
latest_version="$(npm view openclaw dist-tags.latest)"
|
||||
|
||||
echo "Current beta dist-tag: ${beta_version}"
|
||||
echo "Current latest dist-tag: ${latest_version}"
|
||||
|
||||
if [[ "${beta_version}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm beta points at ${beta_version}, expected ${RELEASE_VERSION}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! npm view "openclaw@${RELEASE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "openclaw@${RELEASE_VERSION} is not published on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Promote beta to latest
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '//registry.npmjs.org/:_authToken=%s\n' "${NODE_AUTH_TOKEN}" > "${HOME}/.npmrc"
|
||||
npm whoami >/dev/null
|
||||
npm dist-tag add "openclaw@${RELEASE_VERSION}" latest
|
||||
promoted_latest="$(npm view openclaw dist-tags.latest)"
|
||||
if [[ "${promoted_latest}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm latest points at ${promoted_latest}, expected ${RELEASE_VERSION} after promotion." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Promoted openclaw@${RELEASE_VERSION} from beta to latest."
|
||||
|
||||
sync_stable_dist_tags:
|
||||
# This mode is for direct stable publishes where latest is correct but beta
|
||||
# should also point at the same stable build after release.
|
||||
if: ${{ inputs.sync_stable_dist_tags && !inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for dist-tag sync
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Dist-tag sync runs must be dispatched from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate sync inputs
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "Dist-tag sync mode cannot run with preflight_only=true."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Dist-tag sync mode does not use preflight_run_id."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_NPM_DIST_TAG}" != "latest" ]]; then
|
||||
echo "Dist-tag sync mode expects npm_dist_tag=latest because it points latest and beta at the stable version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate stable tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid stable release tag format: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Validate published stable version
|
||||
env:
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! npm view "openclaw@${RELEASE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "openclaw@${RELEASE_VERSION} is not published on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Current latest dist-tag: $(npm view openclaw dist-tags.latest)"
|
||||
echo "Current beta dist-tag: $(npm view openclaw dist-tags.beta)"
|
||||
|
||||
- name: Sync stable dist-tags
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '//registry.npmjs.org/:_authToken=%s\n' "${NODE_AUTH_TOKEN}" > "${HOME}/.npmrc"
|
||||
npm whoami >/dev/null
|
||||
npm dist-tag add "openclaw@${RELEASE_VERSION}" latest
|
||||
npm dist-tag add "openclaw@${RELEASE_VERSION}" beta
|
||||
|
||||
synced_latest="$(npm view openclaw dist-tags.latest)"
|
||||
synced_beta="$(npm view openclaw dist-tags.beta)"
|
||||
if [[ "${synced_latest}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm latest points at ${synced_latest}, expected ${RELEASE_VERSION} after sync." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${synced_beta}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm beta points at ${synced_beta}, expected ${RELEASE_VERSION} after sync." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Synced openclaw@${RELEASE_VERSION} to npm latest and beta."
|
||||
|
||||
@@ -117,10 +117,10 @@ repos:
|
||||
# Project checks (same commands as CI)
|
||||
- repo: local
|
||||
hooks:
|
||||
# pnpm audit --prod --audit-level=high
|
||||
# node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
- id: pnpm-audit-prod
|
||||
name: pnpm-audit-prod
|
||||
entry: pnpm audit --prod --audit-level=high
|
||||
entry: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
language: system
|
||||
pass_filenames: false
|
||||
|
||||
|
||||
@@ -8,6 +8,77 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/tools: anchor trusted local `MEDIA:` tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (`400 invalid_request_error` on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303)
|
||||
|
||||
## 2026.4.15-beta.1
|
||||
|
||||
### Changes
|
||||
|
||||
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
|
||||
- Memory/LanceDB: add cloud storage support to `memory-lancedb` so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.
|
||||
- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.
|
||||
- Agents/local models: add experimental `agents.defaults.experimental.localModelLean: true` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.
|
||||
- Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.
|
||||
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
|
||||
- Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)
|
||||
- CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)
|
||||
- CLI/update: prune stale packaged `dist` chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.
|
||||
- Onboarding/CLI: fix channel-selection crashes on globally installed CLI setups during onboarding. (#66736)
|
||||
- Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.
|
||||
- Memory-core/QMD `memory_get`: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (`MEMORY.md`, `memory.md`, `DREAMS.md`, `dreams.md`, `memory/**`) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses `read` tool-policy denials. (#66026) Thanks @eleqtrizit.
|
||||
- Cron/agents: forward embedded-run tool policy and internal event params into the attempt layer so `--tools` allowlists, cron-owned message-tool suppression, explicit message targeting, and command-path internal events all take effect at runtime again. (#62675) Thanks @hexsprite.
|
||||
- Setup/providers: guard preferred-provider lookup during setup so malformed plugin metadata with a missing provider id no longer crashes the wizard with `Cannot read properties of undefined (reading 'trim')`. (#66649) Thanks @Tianworld.
|
||||
- Matrix/security: normalize sandboxed profile avatar params, preserve `mxc://` avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.
|
||||
- Telegram/documents: drop leaked binary caption bytes from inbound Telegram text handling so document uploads like `.mobi` or `.epub` no longer explode prompt token counts. (#66663) Thanks @joelnishanth.
|
||||
- Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via `getResolvedAuth()`, mirroring the WebSocket path, so a secret rotated through `secrets.reload` or config hot-reload stops authenticating on `/v1/*`, `/tools/invoke`, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps.
|
||||
- Agents/compaction: cap the compaction reserve-token floor to the model context window so small-context local models (e.g. Ollama with 16K tokens) no longer trigger context-overflow errors or infinite compaction loops on every prompt. (#65671) Thanks @openperf.
|
||||
- Agents/OpenAI Responses: classify the exact `Unknown error (no error details in response)` transport failure as failover reason `unknown` so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer.
|
||||
- Models/probe: surface invalid-model probe failures as `format` instead of `unknown` in `models list --probe`, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi.
|
||||
- Agents/failover: classify OpenAI-compatible `finish_reason: network_error` stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.
|
||||
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
|
||||
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
|
||||
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
|
||||
- Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.
|
||||
- Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost`↔`127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit.
|
||||
- Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit.
|
||||
- Agents/fallback: preserve the original prompt body on model fallback retries with session history so the retrying model keeps the active task instead of only seeing a generic continue message. (#66029) Thanks @WuKongAI-CMU.
|
||||
- Reply/secrets: resolve active reply channel/account SecretRefs before reply-run message-action discovery so channel token SecretRefs (for example Discord) do not degrade into discovery-time unresolved-secret failures. (#66796) Thanks @joshavant.
|
||||
- Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid `max_tokens` values no longer reach the provider API. (#66664) thanks @jalehman
|
||||
- Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman.
|
||||
- BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine.
|
||||
- Secrets/plugins/status: align SecretRef inspect-vs-strict handling across plugin preload, read-only status/agents surfaces, and runtime auth paths so unresolved refs no longer crash read-only CLI flows while runtime-required non-env refs stay strict. (#66818) Thanks @joshavant.
|
||||
- Memory/dreaming: stop ordinary transcripts that merely quote the dream-diary prompt from being classified as internal dreaming runs and silently dropped from session recall ingestion. (#66852) Thanks @gumadeiras.
|
||||
- Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so `.epub` and `.mobi` uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-`text/plain` coercion. (#66877) Thanks @martinfrancois.
|
||||
- Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when `commands.native` and `commands.nativeSkills` stay on `auto`. (#66843) Thanks @kashevk0.
|
||||
- OpenRouter/Qwen3: parse `reasoning_details` stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.
|
||||
- fix(bluebubbles): replay missed webhook messages after gateway restart via a persistent per-account cursor and `/api/v1/message/query?after=<ts>` pass, so messages delivered while the gateway was down no longer disappear. Uses the existing `processMessage` path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.
|
||||
- Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.
|
||||
- Audio/self-hosted STT: restore `models.providers.*.request.allowPrivateNetwork` for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.
|
||||
- Auto-reply/media: allow workspace-rooted absolute media paths in auto-reply send flows so valid local media references no longer fail path validation. (#66689)
|
||||
- WhatsApp/Baileys media upload: harden encrypted upload handling so large outbound media sends avoid buffer spikes and reliability regressions. (#65966) Thanks @frankekn.
|
||||
- QQBot/cron: guard against undefined `event.content` in `parseFaceTags` and `filterInternalMarkers` so cron-triggered agent turns with no content payload no longer crash with `TypeError: Cannot read properties of undefined (reading 'startsWith')`. (#66302) Thanks @xinmotlanthua.
|
||||
- CLI/plugins: stop `--dangerously-force-unsafe-install` plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.
|
||||
- Claude CLI/sessions: classify `No conversation found with session ID` as `session_expired` so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn.
|
||||
- Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf.
|
||||
- Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.
|
||||
- Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to `.csv` or `.md` slip past the host-read guard. (#67047) Thanks @Unayung.
|
||||
- Ollama/onboarding: split setup into `Cloud + Local`, `Cloud only`, and `Local only`, support direct `OLLAMA_API_KEY` cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.
|
||||
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) Thanks @ly85206559.
|
||||
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
|
||||
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) Thanks @neo1027144-creator.
|
||||
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
|
||||
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) Thanks @gumadeiras.
|
||||
- Agents/context + Memory: trim default startup/skills prompt budgets, cap `memory_get` excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.
|
||||
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
|
||||
- Matrix/security: block DM pairing-store entries from authorizing room control commands. (#67294) Thanks @pgondhi987.
|
||||
- Gateway/security: enforce `localRoots` containment on the webchat audio embedding path. (#67298) Thanks @pgondhi987.
|
||||
- Webchat/security: reject remote-host `file://` URLs in the media embedding path. (#67293) Thanks @pgondhi987.
|
||||
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
### Changes
|
||||
@@ -44,6 +115,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
|
||||
- Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit `agents.defaults.timeoutSeconds` override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.
|
||||
- Media/transcription: remap `.aac` filenames to `.m4a` for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.
|
||||
- WhatsApp/Baileys media upload: keep encrypted upload POSTs streaming while still guarding generic-agent dispatcher wiring, so large outbound media sends avoid full-buffer RSS spikes and OOM regressions. (#65966) Thanks @frankekn.
|
||||
- UI/chat: replace marked.js with markdown-it so maliciously crafted markdown can no longer freeze the Control UI via ReDoS. (#46707) Thanks @zhangfnf.
|
||||
- Auto-reply/send policy: keep `sendPolicy: "deny"` from blocking inbound message processing, so the agent still runs its turn while all outbound delivery is suppressed for observer-style setups. (#65461, #53328) Thanks @omarshahine.
|
||||
- BlueBubbles: lazy-refresh the Private API server-info cache on send when reply threading or message effects are requested but status is unknown, so sends no longer silently degrade to plain messages when the 10-minute cache expires. (#65447, #43764) Thanks @omarshahine.
|
||||
@@ -83,6 +155,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.
|
||||
- CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.
|
||||
- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.
|
||||
- Plugins/setup-entry: preserve separate setup-entry secrets exports when loading bundled setup-runtime channels, so setup-mode flows keep the channel secret contract for split plugin + secrets entrypoints. (#66261) Thanks @hxy91819.
|
||||
- CLI/update: prune stale packaged `dist` chunks after npm upgrades, verify installed package inventory, and keep downgrade/update verification working across older releases. (#66959) Thanks @obviyus.
|
||||
|
||||
## 2026.4.12
|
||||
|
||||
@@ -164,6 +238,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Setup/config/install: stop setup, config dry-runs, and daemon install from eagerly booting auth-profile and plugin repair runtime when those paths are not needed, so onboarding and local service setup avoid long cold-start stalls. Thanks @vincentkoc.
|
||||
- Cron/direct delivery: slim isolated-agent delivery cold paths so direct channel delivery and related cron execution spend less time loading unrelated auth, plugin, and channel runtime. Thanks @vincentkoc.
|
||||
- Channels/replay dedupe: standardize replay claims, retryable-failure release, and post-success commit behavior across Telegram, Discord, Slack, Mattermost, WhatsApp, Matrix, LINE, Feishu, Zalo, Nextcloud Talk, TLON, Nostr, Voice Call, and shared plugin interactive callbacks so duplicate deliveries stay reply-once after success but retry cleanly after pre-delivery failures. Thanks @vincentkoc.
|
||||
- Agents/OpenAI mini reasoning: remap unsupported `low` and `minimal` reasoning effort to `medium` for affected OpenAI mini models, and add a live regression lane to keep the compatibility fix covered. (#65478) Thanks @vincentkoc.
|
||||
- Configure/wizard: replay wizard edits onto the latest config snapshot after a hash conflict so plugin-auth writes no longer get dropped during `openclaw configure`, including nested config under shared sections such as `plugins`. (#64188) Thanks @feiskyer and @vincentkoc.
|
||||
|
||||
## 2026.4.11
|
||||
|
||||
@@ -199,6 +275,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: scope assistant-side fallback classification and surfaced provider errors to the current attempt instead of stale session history, so cross-provider fallback runs stop inheriting the previous provider's failure. (#62907) Thanks @stainlu.
|
||||
- MiniMax/OAuth: write `api: "anthropic-messages"` and `authHeader: true` into the `minimax-portal` config patch during `openclaw configure`, so re-authenticated portal setups keep Bearer auth routing working. (#64964) Thanks @ryanlee666.
|
||||
- Agents/tools: stop repeated unavailable-tool retries from escaping loop detection when the model changes arguments, and rewrite over-threshold unknown tool calls into plain assistant text before dispatch. (#65922) Thanks @dutifulbob.
|
||||
- Cron/announce delivery: tell isolated cron jobs to return the full response exactly instead of a summary, so structured `--announce` deliveries stop dropping fields nondeterministically. (#65638) Thanks @srinivaspavan9 and @vincentkoc.
|
||||
- Security/exec approvals: redact bearer tokens, API keys, and similar secrets in exec approval prompt command text before those prompts are posted back to chat channels, regardless of logging redaction settings. (#61077) Thanks @feiskyer and @vincentkoc.
|
||||
|
||||
## 2026.4.10
|
||||
|
||||
@@ -1002,6 +1080,8 @@ Docs: https://docs.openclaw.ai
|
||||
- ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana
|
||||
- ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.
|
||||
- Tools/web_search (Kimi): replay native Moonshot `$web_search` arguments verbatim, disable thinking for `kimi-k2.5`, and add Moonshot region/model setup prompts so bundled Kimi web search works again. (#59356) Thanks @Innocent-children.
|
||||
- Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to `auth-profiles.json` before returning them, so rotated Codex refresh tokens survive restart and stop falling into `refresh_token_reused` loops. (#53082)
|
||||
- Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
|
||||
+4
-1
@@ -80,10 +80,13 @@ Welcome to the lobster tank! 🦞
|
||||
- **Sliverp** - Chinese Channel: QQ, WeChat, Wecom, Dingtalk, Feishu
|
||||
- GitHub: [@sliverp](https://github.com/sliverp) · X: [@sliver01234](https://x.com/sliver01234)
|
||||
|
||||
- **Mason Huang** - Stability, Security, Speed
|
||||
- GitHub: [@hxy91819](https://github.com/hxy91819) · X: [@chenjingtalk](https://x.com/chenjingtalk)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
|
||||
2. **New features / architecture** → Start a [GitHub Issue](https://github.com/openclaw/openclaw/issues/new/choose) or ask in Discord first. Most features are not accepted and should be third party plugins instead using our plugin SDK.
|
||||
3. **Refactor-only PRs** → Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix.
|
||||
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
+7
-1
@@ -65,7 +65,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY openclaw.mjs ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
|
||||
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
|
||||
|
||||
@@ -74,6 +74,12 @@ COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Fail fast here if the Matrix native binding did not materialize after install.
|
||||
RUN echo "==> Verifying critical native addons..." && \
|
||||
find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q . || \
|
||||
(echo "ERROR: matrix-sdk-crypto native addon missing (pnpm install may have silently failed on this arch)" >&2 && exit 1)
|
||||
|
||||
COPY . .
|
||||
|
||||
# Normalize extension paths now so runtime COPY preserves safe modes
|
||||
|
||||
@@ -19,16 +19,19 @@
|
||||
</p>
|
||||
|
||||
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Supported channels include: WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, QQ, WebChat.
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
Preferred setup: run `openclaw onboard` in your terminal.
|
||||
OpenClaw Onboard guides you step by step through setting up the gateway, workspace, channels, and skills. It is the recommended CLI setup path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -91,11 +94,6 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
@@ -123,40 +121,13 @@ openclaw gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WeChat/WebChat)
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WeChat/QQ/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models). Auth profile rotation + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover).
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
@@ -175,7 +146,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, QQ, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS and continuous voice on Android (ElevenLabs + system TTS fallback).
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -183,152 +154,30 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — onboarding-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
## Security model (important)
|
||||
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
- Default: tools run on the host for the `main` session, so the agent has full access when it is just you.
|
||||
- Group/channel safety: set `agents.defaults.sandbox.mode: "non-main"` to run non-`main` sessions inside per-session Docker sandboxes.
|
||||
- Typical sandbox default: allow `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; deny `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
- Before exposing anything remotely, read [Security](https://docs.openclaw.ai/gateway/security), [Docker sandboxing](https://docs.openclaw.ai/install/docker), and [Configuration](https://docs.openclaw.ai/gateway/configuration).
|
||||
|
||||
## Everything we built so far
|
||||
## Operator quick refs
|
||||
|
||||
### Core platform
|
||||
- Chat commands: `/status`, `/new`, `/reset`, `/compact`, `/think <level>`, `/verbose on|off`, `/trace on|off`, `/usage off|tokens|full`, `/restart`, `/activation mention|always`
|
||||
- Session tools: `sessions_list`, `sessions_history`, `sessions_send`
|
||||
- Skills registry: [ClawHub](https://clawhub.com)
|
||||
- Architecture overview: [Architecture](https://docs.openclaw.ai/concepts/architecture)
|
||||
|
||||
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [onboarding](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups).
|
||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
||||
## Docs by goal
|
||||
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), WeChat (`@tencent-weixin/openclaw-weixin`), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour + device pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): Connect tab (setup code/manual), chat sessions, voice tab, [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), camera/screen recording, and Android device commands (notifications/location/SMS/photos/contacts/calendar/motion/app update).
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
|
||||
- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
|
||||
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
|
||||
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WeChat / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (openclaw …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS plus continuous voice on Android.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions\_\* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawHub)
|
||||
|
||||
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawHub](https://clawhub.com)
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/trace on|off` — plugin trace/debug lines only
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
- New here: [Getting started](https://docs.openclaw.ai/start/getting-started), [Onboarding](https://docs.openclaw.ai/start/wizard), [Updating](https://docs.openclaw.ai/install/updating)
|
||||
- Channel setup: [Channels index](https://docs.openclaw.ai/channels), [WhatsApp](https://docs.openclaw.ai/channels/whatsapp), [Telegram](https://docs.openclaw.ai/channels/telegram), [Discord](https://docs.openclaw.ai/channels/discord), [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
- Apps + nodes: [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android), [Nodes](https://docs.openclaw.ai/nodes)
|
||||
- Config + security: [Configuration](https://docs.openclaw.ai/gateway/configuration), [Security](https://docs.openclaw.ai/gateway/security), [Docker sandboxing](https://docs.openclaw.ai/install/docker)
|
||||
- Remote + web: [Gateway](https://docs.openclaw.ai/gateway), [Remote access](https://docs.openclaw.ai/gateway/remote), [Tailscale](https://docs.openclaw.ai/gateway/tailscale), [Web surfaces](https://docs.openclaw.ai/web)
|
||||
- Tools + automation: [Tools](https://docs.openclaw.ai/tools), [Skills](https://docs.openclaw.ai/tools/skills), [Cron jobs](https://docs.openclaw.ai/automation/cron-jobs), [Webhooks](https://docs.openclaw.ai/automation/webhook), [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- Internals: [Architecture](https://docs.openclaw.ai/concepts/architecture), [Agent](https://docs.openclaw.ai/concepts/agent), [Session model](https://docs.openclaw.ai/concepts/session), [Gateway protocol](https://docs.openclaw.ai/reference/rpc)
|
||||
- Troubleshooting: [Channel troubleshooting](https://docs.openclaw.ai/channels/troubleshooting), [Logging](https://docs.openclaw.ai/logging), [Docs home](https://docs.openclaw.ai)
|
||||
|
||||
## Apps (optional)
|
||||
|
||||
@@ -359,6 +208,35 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
- Exposes Connect/Chat/Voice tabs plus Canvas, Camera, Screen capture, and Android device command families.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
@@ -379,162 +257,9 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
|
||||
## Security model (important)
|
||||
## Star History
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### WeChat
|
||||
|
||||
- Official Tencent plugin via [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) (iLink Bot API). Private chats only; v2.x requires OpenClaw `>=2026.3.22`.
|
||||
- Install: `openclaw plugins install "@tencent-weixin/openclaw-weixin"`, then `openclaw channels login --channel openclaw-weixin` to scan the QR code.
|
||||
- Requires the WeChat ClawBot plugin (WeChat > Me > Settings > Plugins); gradual rollout by Tencent.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow OpenClaw Onboard for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
|
||||
## Molty
|
||||
|
||||
@@ -553,63 +278,257 @@ AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
Special thanks to Adam Doppelt for the lobster.bot domain.
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/Sid-Qin"><img src="https://avatars.githubusercontent.com/u/201593046?v=4&s=48" width="48" height="48" alt="Sid-Qin" title="Sid-Qin"/></a> <a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/bmendonca3"><img src="https://avatars.githubusercontent.com/u/208517100?v=4&s=48" width="48" height="48" alt="bmendonca3" title="bmendonca3"/></a>
|
||||
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="Elonito" title="Elonito"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a>
|
||||
<a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a> <a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chilu18"><img src="https://avatars.githubusercontent.com/u/7957943?v=4&s=48" width="48" height="48" alt="chilu18" title="chilu18"/></a> <a href="https://github.com/byungsker"><img src="https://avatars.githubusercontent.com/u/72309817?v=4&s=48" width="48" height="48" alt="byungsker" title="byungsker"/></a>
|
||||
<a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/huntharo"><img src="https://avatars.githubusercontent.com/u/5617868?v=4&s=48" width="48" height="48" alt="huntharo" title="huntharo"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/adhitShet"><img src="https://avatars.githubusercontent.com/u/131381638?v=4&s=48" width="48" height="48" alt="adhitShet" title="adhitShet"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
|
||||
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="stakeswky" title="stakeswky"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/MisterGuy420"><img src="https://avatars.githubusercontent.com/u/255743668?v=4&s=48" width="48" height="48" alt="MisterGuy420" title="MisterGuy420"/></a>
|
||||
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Phineas1500"><img src="https://avatars.githubusercontent.com/u/41450967?v=4&s=48" width="48" height="48" alt="Phineas1500" title="Phineas1500"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
|
||||
<a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/thebenignhacker"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="thebenignhacker" title="thebenignhacker"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="carrotRakko" title="carrotRakko"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/kevinWangSheng"><img src="https://avatars.githubusercontent.com/u/118158941?v=4&s=48" width="48" height="48" alt="kevinWangSheng" title="kevinWangSheng"/></a> <a href="https://github.com/gregmousseau"><img src="https://avatars.githubusercontent.com/u/5036458?v=4&s=48" width="48" height="48" alt="gregmousseau" title="gregmousseau"/></a> <a href="https://github.com/rrenamed"><img src="https://avatars.githubusercontent.com/u/87486610?v=4&s=48" width="48" height="48" alt="rrenamed" title="rrenamed"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a>
|
||||
<a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/pandego"><img src="https://avatars.githubusercontent.com/u/7780875?v=4&s=48" width="48" height="48" alt="pandego" title="pandego"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/graysurf"><img src="https://avatars.githubusercontent.com/u/10785178?v=4&s=48" width="48" height="48" alt="graysurf" title="graysurf"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a>
|
||||
<a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
|
||||
<a href="https://github.com/YuzuruS"><img src="https://avatars.githubusercontent.com/u/1485195?v=4&s=48" width="48" height="48" alt="YuzuruS" title="YuzuruS"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="Harald Buerbaumer" title="Harald Buerbaumer"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/openperf"><img src="https://avatars.githubusercontent.com/u/80630709?v=4&s=48" width="48" height="48" alt="openperf" title="openperf"/></a> <a href="https://github.com/BUGKillerKing"><img src="https://avatars.githubusercontent.com/u/117326392?v=4&s=48" width="48" height="48" alt="BUGKillerKing" title="BUGKillerKing"/></a> <a href="https://github.com/Oceanswave"><img src="https://avatars.githubusercontent.com/u/760674?v=4&s=48" width="48" height="48" alt="Oceanswave" title="Oceanswave"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="Hiren Patel" title="Hiren Patel"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/luijoc"><img src="https://avatars.githubusercontent.com/u/96428056?v=4&s=48" width="48" height="48" alt="luijoc" title="luijoc"/></a> <a href="https://github.com/niceysam"><img src="https://avatars.githubusercontent.com/u/256747835?v=4&s=48" width="48" height="48" alt="niceysam" title="niceysam"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a>
|
||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="Simone Macario" title="Simone Macario"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||
<a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a> <a href="https://github.com/Ryan-Haines"><img src="https://avatars.githubusercontent.com/u/1855752?v=4&s=48" width="48" height="48" alt="Ryan Haines" title="Ryan Haines"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hcl" title="hcl"/></a> <a href="https://github.com/xuhao1"><img src="https://avatars.githubusercontent.com/u/5087930?v=4&s=48" width="48" height="48" alt="XuHao" title="XuHao"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bitfoundry-ai"><img src="https://avatars.githubusercontent.com/u/239082898?v=4&s=48" width="48" height="48" alt="bitfoundry-ai" title="bitfoundry-ai"/></a>
|
||||
<a href="https://github.com/HeMuling"><img src="https://avatars.githubusercontent.com/u/74801533?v=4&s=48" width="48" height="48" alt="HeMuling" title="HeMuling"/></a> <a href="https://github.com/markmusson"><img src="https://avatars.githubusercontent.com/u/4801649?v=4&s=48" width="48" height="48" alt="markmusson" title="markmusson"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/guirguispierre"><img src="https://avatars.githubusercontent.com/u/22091706?v=4&s=48" width="48" height="48" alt="guirguispierre" title="guirguispierre"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a>
|
||||
<a href="https://github.com/loganprit"><img src="https://avatars.githubusercontent.com/u/72722788?v=4&s=48" width="48" height="48" alt="loganprit" title="loganprit"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/dbachelder"><img src="https://avatars.githubusercontent.com/u/325706?v=4&s=48" width="48" height="48" alt="dbachelder" title="dbachelder"/></a> <a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="Divanoli Mydeen Pitchai" title="Divanoli Mydeen Pitchai"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="liuxiaopai-ai" title="liuxiaopai-ai"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||
<a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/lewiswigmore"><img src="https://avatars.githubusercontent.com/u/58551848?v=4&s=48" width="48" height="48" alt="Lewis" title="Lewis"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/sahilsatralkar"><img src="https://avatars.githubusercontent.com/u/62758655?v=4&s=48" width="48" height="48" alt="sahilsatralkar" title="sahilsatralkar"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="miloudbelarebia" title="miloudbelarebia"/></a> <a href="https://github.com/Mellowambience"><img src="https://avatars.githubusercontent.com/u/40958792?v=4&s=48" width="48" height="48" alt="Mars" title="Mars"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/PeterShanxin"><img src="https://avatars.githubusercontent.com/u/128674037?v=4&s=48" width="48" height="48" alt="LI SHANXIN" title="LI SHANXIN"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="Jessy LANGE" title="Jessy LANGE"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
|
||||
<a href="https://github.com/scz2011"><img src="https://avatars.githubusercontent.com/u/9337506?v=4&s=48" width="48" height="48" alt="scz2011" title="scz2011"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="Jay Caldwell" title="Jay Caldwell"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/KirillShchetinin"><img src="https://avatars.githubusercontent.com/u/13061871?v=4&s=48" width="48" height="48" alt="Kirill Shchetynin" title="Kirill Shchetynin"/></a> <a href="https://github.com/ruypang"><img src="https://avatars.githubusercontent.com/u/46941315?v=4&s=48" width="48" height="48" alt="ruypang" title="ruypang"/></a>
|
||||
<a href="https://github.com/mitchmcalister"><img src="https://avatars.githubusercontent.com/u/209334?v=4&s=48" width="48" height="48" alt="mitchmcalister" title="mitchmcalister"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/guxu11"><img src="https://avatars.githubusercontent.com/u/53551744?v=4&s=48" width="48" height="48" alt="Xu Gu" title="Xu Gu"/></a> <a href="https://github.com/lml2468"><img src="https://avatars.githubusercontent.com/u/39320777?v=4&s=48" width="48" height="48" alt="Menglin Li" title="Menglin Li"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/jackheuberger"><img src="https://avatars.githubusercontent.com/u/7830838?v=4&s=48" width="48" height="48" alt="jackheuberger" title="jackheuberger"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/Zitzak"><img src="https://avatars.githubusercontent.com/u/43185740?v=4&s=48" width="48" height="48" alt="Marvin" title="Marvin"/></a>
|
||||
<a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/krizpoon"><img src="https://avatars.githubusercontent.com/u/1977532?v=4&s=48" width="48" height="48" alt="krizpoon" title="krizpoon"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/hydro13"><img src="https://avatars.githubusercontent.com/u/6640526?v=4&s=48" width="48" height="48" alt="hydro13" title="hydro13"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16" title="kunalk16"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/SleuthCo"><img src="https://avatars.githubusercontent.com/u/259695222?v=4&s=48" width="48" height="48" alt="SleuthCo" title="SleuthCo"/></a>
|
||||
<a href="https://github.com/TaKO8Ki"><img src="https://avatars.githubusercontent.com/u/41065217?v=4&s=48" width="48" height="48" alt="TaKO8Ki" title="TaKO8Ki"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/BigUncle"><img src="https://avatars.githubusercontent.com/u/9360607?v=4&s=48" width="48" height="48" alt="BigUncle" title="BigUncle"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="Igor Markelov" title="Igor Markelov"/></a> <a href="https://github.com/zhoulongchao77"><img src="https://avatars.githubusercontent.com/u/65058500?v=4&s=48" width="48" height="48" alt="zhoulc777" title="zhoulc777"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/paceyw"><img src="https://avatars.githubusercontent.com/u/44923937?v=4&s=48" width="48" height="48" alt="TIHU" title="TIHU"/></a> <a href="https://github.com/tonydehnke"><img src="https://avatars.githubusercontent.com/u/36720180?v=4&s=48" width="48" height="48" alt="Tony Dehnke" title="Tony Dehnke"/></a>
|
||||
<a href="https://github.com/pablohrcarvalho"><img src="https://avatars.githubusercontent.com/u/66948122?v=4&s=48" width="48" height="48" alt="pablohrcarvalho" title="pablohrcarvalho"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a> <a href="https://github.com/ozbillwang"><img src="https://avatars.githubusercontent.com/u/8954908?v=4&s=48" width="48" height="48" alt="ozbillwang" title="ozbillwang"/></a> <a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/ianderrington"><img src="https://avatars.githubusercontent.com/u/76016868?v=4&s=48" width="48" height="48" alt="Ian Derrington" title="Ian Derrington"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
|
||||
<a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a>
|
||||
<a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/Kepler2024"><img src="https://avatars.githubusercontent.com/u/166882517?v=4&s=48" width="48" height="48" alt="Harry Cui Kepler" title="Harry Cui Kepler"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/Youyou972"><img src="https://avatars.githubusercontent.com/u/50808411?v=4&s=48" width="48" height="48" alt="Youyou972" title="Youyou972"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="Dominic" title="Dominic"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a>
|
||||
<a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/dashed"><img src="https://avatars.githubusercontent.com/u/139499?v=4&s=48" width="48" height="48" alt="dashed" title="dashed"/></a> <a href="https://github.com/minupla"><img src="https://avatars.githubusercontent.com/u/42547246?v=4&s=48" width="48" height="48" alt="Dale Babiy" title="Dale Babiy"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/brianleach"><img src="https://avatars.githubusercontent.com/u/1900805?v=4&s=48" width="48" height="48" alt="brianleach" title="brianleach"/></a> <a href="https://github.com/codexGW"><img src="https://avatars.githubusercontent.com/u/9350182?v=4&s=48" width="48" height="48" alt="codexGW" title="codexGW"/></a>
|
||||
<a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="Chase Dorsey" title="Chase Dorsey"/></a> <a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4&s=48" width="48" height="48" alt="Joly0" title="Joly0"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/tumf"><img src="https://avatars.githubusercontent.com/u/69994?v=4&s=48" width="48" height="48" alt="tumf" title="tumf"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a>
|
||||
<a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="Jeremiah Lowin" title="Jeremiah Lowin"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a>
|
||||
<a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/santiagomed"><img src="https://avatars.githubusercontent.com/u/30184543?v=4&s=48" width="48" height="48" alt="santiagomed" title="santiagomed"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/CornBrother0x"><img src="https://avatars.githubusercontent.com/u/101160087?v=4&s=48" width="48" height="48" alt="CornBrother0x" title="CornBrother0x"/></a> <a href="https://github.com/DukeDeSouth"><img src="https://avatars.githubusercontent.com/u/51200688?v=4&s=48" width="48" height="48" alt="DukeDeSouth" title="DukeDeSouth"/></a>
|
||||
<a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="Cklee" title="Cklee"/></a> <a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="Garnet Liu" title="Garnet Liu"/></a> <a href="https://github.com/Bermudarat"><img src="https://avatars.githubusercontent.com/u/10937319?v=4&s=48" width="48" height="48" alt="neverland" title="neverland"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/benediktjohannes"><img src="https://avatars.githubusercontent.com/u/253604130?v=4&s=48" width="48" height="48" alt="benediktjohannes" title="benediktjohannes"/></a>
|
||||
<a href="https://github.com/zhangzhefang-github"><img src="https://avatars.githubusercontent.com/u/34058239?v=4&s=48" width="48" height="48" alt="张哲芳" title="张哲芳"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/joelnishanth"><img src="https://avatars.githubusercontent.com/u/140015627?v=4&s=48" width="48" height="48" alt="OfflynAI" title="OfflynAI"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/pahdo"><img src="https://avatars.githubusercontent.com/u/12799392?v=4&s=48" width="48" height="48" alt="Daniel Zou" title="Daniel Zou"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/ProspectOre"><img src="https://avatars.githubusercontent.com/u/54486432?v=4&s=48" width="48" height="48" alt="ProspectOre" title="ProspectOre"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/Kansodata"><img src="https://avatars.githubusercontent.com/u/225288021?v=4&s=48" width="48" height="48" alt="Kansodata" title="Kansodata"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/dddabtc"><img src="https://avatars.githubusercontent.com/u/104875499?v=4&s=48" width="48" height="48" alt="dddabtc" title="dddabtc"/></a> <a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a>
|
||||
<a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="Ganghyun Kim" title="Ganghyun Kim"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John Rood" title="John Rood"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="JINNYEONG KIM" title="JINNYEONG KIM"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="Dinakar Sarbada" title="Dinakar Sarbada"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a>
|
||||
<a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a> <a href="https://github.com/fwhite13"><img src="https://avatars.githubusercontent.com/u/173006051?v=4&s=48" width="48" height="48" alt="Fred White" title="Fred White"/></a> <a href="https://github.com/Anandesh-Sharma"><img src="https://avatars.githubusercontent.com/u/30695364?v=4&s=48" width="48" height="48" alt="Anandesh-Sharma" title="Anandesh-Sharma"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a>
|
||||
<a href="https://github.com/DevSecTim"><img src="https://avatars.githubusercontent.com/u/2226767?v=4&s=48" width="48" height="48" alt="DevSecTim" title="DevSecTim"/></a> <a href="https://github.com/edincampara"><img src="https://avatars.githubusercontent.com/u/142477787?v=4&s=48" width="48" height="48" alt="edincampara" title="edincampara"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a>
|
||||
<a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/JonathanWorks"><img src="https://avatars.githubusercontent.com/u/124476234?v=4&s=48" width="48" height="48" alt="Jonathan Works" title="Jonathan Works"/></a> <a href="https://github.com/yassine20011"><img src="https://avatars.githubusercontent.com/u/59234686?v=4&s=48" width="48" height="48" alt="Yassine Amjad" title="Yassine Amjad"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="Frank Harris" title="Frank Harris"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/AytuncYildizli"><img src="https://avatars.githubusercontent.com/u/47717026?v=4&s=48" width="48" height="48" alt="AytuncYildizli" title="AytuncYildizli"/></a>
|
||||
<a href="https://github.com/KnHack"><img src="https://avatars.githubusercontent.com/u/2346724?v=4&s=48" width="48" height="48" alt="Charlie Niño" title="Charlie Niño"/></a> <a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="Jeremy Mumford" title="Jeremy Mumford"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/junjunjunbong"><img src="https://avatars.githubusercontent.com/u/153147718?v=4&s=48" width="48" height="48" alt="junwon" title="junwon"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/Slats24"><img src="https://avatars.githubusercontent.com/u/42514321?v=4&s=48" width="48" height="48" alt="Slats" title="Slats"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a>
|
||||
<a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/ZetiMente"><img src="https://avatars.githubusercontent.com/u/76985631?v=4&s=48" width="48" height="48" alt="Matthew" title="Matthew"/></a> <a href="https://github.com/Cassius0924"><img src="https://avatars.githubusercontent.com/u/62874592?v=4&s=48" width="48" height="48" alt="Cassius0924" title="Cassius0924"/></a> <a href="https://github.com/0xbrak"><img src="https://avatars.githubusercontent.com/u/181251288?v=4&s=48" width="48" height="48" alt="0xbrak" title="0xbrak"/></a> <a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a>
|
||||
<a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/albertlieyingadrian"><img src="https://avatars.githubusercontent.com/u/12984659?v=4&s=48" width="48" height="48" alt="albertlieyingadrian" title="albertlieyingadrian"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/ali-aljufairi"><img src="https://avatars.githubusercontent.com/u/85583841?v=4&s=48" width="48" height="48" alt="ali-aljufairi" title="ali-aljufairi"/></a> <a href="https://github.com/altaywtf"><img src="https://avatars.githubusercontent.com/u/9790196?v=4&s=48" width="48" height="48" alt="altaywtf" title="altaywtf"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/avacadobanana352"><img src="https://avatars.githubusercontent.com/u/263496834?v=4&s=48" width="48" height="48" alt="avacadobanana352" title="avacadobanana352"/></a>
|
||||
<a href="https://github.com/barronlroth"><img src="https://avatars.githubusercontent.com/u/5567884?v=4&s=48" width="48" height="48" alt="barronlroth" title="barronlroth"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bigwest60"><img src="https://avatars.githubusercontent.com/u/12373979?v=4&s=48" width="48" height="48" alt="bigwest60" title="bigwest60"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/dutifulbob"><img src="https://avatars.githubusercontent.com/u/261991368?v=4&s=48" width="48" height="48" alt="dutifulbob" title="dutifulbob"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/gittb"><img src="https://avatars.githubusercontent.com/u/8284364?v=4&s=48" width="48" height="48" alt="gittb" title="gittb"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/junsuwhy"><img src="https://avatars.githubusercontent.com/u/4645498?v=4&s=48" width="48" height="48" alt="junsuwhy" title="junsuwhy"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a> <a href="https://github.com/algal"><img src="https://avatars.githubusercontent.com/u/264412?v=4&s=48" width="48" height="48" alt="Alexis Gallagher" title="Alexis Gallagher"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="Ethan Palm" title="Ethan Palm"/></a>
|
||||
<a href="https://github.com/yingchunbai"><img src="https://avatars.githubusercontent.com/u/33477283?v=4&s=48" width="48" height="48" alt="yingchunbai" title="yingchunbai"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="Dan Ballance" title="Dan Ballance"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="Eric Su" title="Eric Su"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/efe-buken"><img src="https://avatars.githubusercontent.com/u/262546946?v=4&s=48" width="48" height="48" alt="efe-buken" title="efe-buken"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/easternbloc"><img src="https://avatars.githubusercontent.com/u/92585?v=4&s=48" width="48" height="48" alt="easternbloc" title="easternbloc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||
<a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/Mind-Dragon"><img src="https://avatars.githubusercontent.com/u/262945885?v=4&s=48" width="48" height="48" alt="Mind-Dragon" title="Mind-Dragon"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/mgratch"><img src="https://avatars.githubusercontent.com/u/2238658?v=4&s=48" width="48" height="48" alt="Marc Gratch" title="Marc Gratch"/></a> <a href="https://github.com/JackyWay"><img src="https://avatars.githubusercontent.com/u/53031570?v=4&s=48" width="48" height="48" alt="JackyWay" title="JackyWay"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a>
|
||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/mujiannan"><img src="https://avatars.githubusercontent.com/u/46643837?v=4&s=48" width="48" height="48" alt="mujiannan" title="mujiannan"/></a> <a href="https://github.com/marcodd23"><img src="https://avatars.githubusercontent.com/u/3519682?v=4&s=48" width="48" height="48" alt="Marco Di Dionisio" title="Marco Di Dionisio"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/afern247"><img src="https://avatars.githubusercontent.com/u/34192856?v=4&s=48" width="48" height="48" alt="afern247" title="afern247"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a>
|
||||
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
</p>
|
||||
<!-- clawtributors:start -->
|
||||
|
||||
[](https://github.com/steipete) [](https://github.com/vincentkoc) [](https://github.com/Takhoffman) [](https://github.com/obviyus) [](https://github.com/gumadeiras) [](https://github.com/mbelinky) [](https://github.com/vignesh07) [](https://github.com/joshavant) [](https://github.com/scoootscooob) [](https://github.com/jacobtomlinson)
|
||||
|
||||
[](https://github.com/shakkernerd) [](https://github.com/sebslight) [](https://github.com/tyler6204) [](https://github.com/ngutman) [](https://github.com/thewilloftheshadow) [](https://github.com/Sid-Qin) [](https://github.com/mcaxtr) [](https://github.com/eleqtrizit) [](https://github.com/BunsDev) [](https://github.com/cpojer)
|
||||
|
||||
[](https://github.com/Glucksberg) [](https://github.com/osolmaz) [](https://github.com/bmendonca3) [](https://github.com/jalehman) [](https://github.com/huntharo) [](https://github.com/neeravmakwana) [](https://github.com/openperf) [](https://github.com/joshp123) [](https://github.com/pgondhi987) [](https://github.com/altaywtf)
|
||||
|
||||
[](https://github.com/quotentiroler) [](https://github.com/liuxiaopai-ai) [](https://github.com/rodrigouroz) [](https://github.com/frankekn) [](https://github.com/drobison00) [](https://github.com/zerone0x) [](https://github.com/onutc) [](https://github.com/ademczuk) [](https://github.com/ImLukeF) [](https://github.com/hydro13)
|
||||
|
||||
[](https://github.com/hxy91819) [](https://github.com/coygeek) [](https://github.com/dutifulbob) [](https://github.com/sliverp) [](https://github.com/0xRaini) [](https://github.com/robbyczgw-cla) [](https://github.com/joelnishanth) [](https://github.com/echoVic) [](https://github.com/sallyom) [](https://github.com/yinghaosang)
|
||||
|
||||
[](https://github.com/BradGroux) [](https://github.com/christianklotz) [](https://github.com/odysseus0) [](https://github.com/hclsys) [](https://github.com/byungsker) [](https://github.com/pashpashpash) [](https://github.com/stakeswky) [![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4&s=48)](https://github.com/apps/github-actions) [](https://github.com/xinhuagu) [](https://github.com/MonkeyLeeT)
|
||||
|
||||
[](https://github.com/100yenadmin) [](https://github.com/mcinteerj) [](https://github.com/samzong) [](https://github.com/chilu18) [](https://github.com/darkamenosa) [](https://github.com/widingmarcus-cyber) [](https://github.com/cgdusek) [](https://github.com/Lukavyi) [](https://github.com/davidrudduck) [](https://github.com/VACInc)
|
||||
|
||||
[](https://github.com/MoerAI) [](https://github.com/velvet-shark) [](https://github.com/HenryLoenwind) [](https://github.com/omarshahine) [](https://github.com/bohdanpodvirnyi) [](https://github.com/VeriteIgiraneza) [](https://github.com/akramcodez) [](https://github.com/Kaneki-x) [](https://github.com/aether-ai-agent) [](https://github.com/joaohlisboa)
|
||||
|
||||
[](https://github.com/MaudeBot) [](https://github.com/davidguttman) [](https://github.com/justinhuangcode) [](https://github.com/lml2468) [](https://github.com/wirjo) [](https://github.com/iHildy) [](https://github.com/mudrii) [](https://github.com/advaitpaliwal) [](https://github.com/czekaj) [](https://github.com/dlauer)
|
||||
|
||||
[](https://github.com/Solvely-Colin) [](https://github.com/feiskyer) [](https://github.com/brandonwise) [](https://github.com/conroywhitney) [](https://github.com/mneves75) [](https://github.com/jaydenfyi) [](https://github.com/davemorin) [](https://github.com/joeykrug) [](https://github.com/kevinWangSheng) [](https://github.com/pejmanjohn)
|
||||
|
||||
[](https://github.com/Lanfei) [](https://github.com/liuy) [](https://github.com/lc0rp) [](https://github.com/teconomix) [](https://github.com/omair445) [](https://github.com/dorukardahan) [](https://github.com/mmaps) [](https://github.com/tobiasbischoff) [](https://github.com/adhitShet) [](https://github.com/pandego)
|
||||
|
||||
[](https://github.com/bradleypriest) [](https://github.com/bjesuiter) [](https://github.com/grp06) [](https://github.com/shadril238) [](https://github.com/kesku) [](https://github.com/YuriNachos) [](https://github.com/vrknetha) [](https://github.com/smartprogrammer93) [](https://github.com/Nachx639) [](https://github.com/jnMetaCode)
|
||||
|
||||
[](https://github.com/Phineas1500) [](https://github.com/dingn42) [](https://github.com/geekhuashan) [](https://github.com/Nanako0129) [](https://github.com/AytuncYildizli) [](https://github.com/BruceMacD) [](https://github.com/jjjojoj) [](https://github.com/mvanhorn) [](https://github.com/bugkill3r) [](https://github.com/rahthakor)
|
||||
|
||||
[](https://github.com/GodsBoy) [](https://github.com/SARAMALI15792) [](https://github.com/radek-paclt) [](https://github.com/Elarwei001) [](https://github.com/ingyukoh) [](https://github.com/SnowSky1) [](https://github.com/lewiswigmore) [](https://github.com/solavrc) [](https://github.com/aldoeliacim) [](https://github.com/jrusz)
|
||||
|
||||
[](https://github.com/tonydehnke) [](https://github.com/roshanasingh4) [](https://github.com/zssggle-rgb) [](https://github.com/adam91holt) [](https://github.com/graysurf) [](https://github.com/xadenryan) [](https://github.com/sfo2001) [](https://github.com/orlyjamie) [](https://github.com/hsrvc) [](https://github.com/tomsun28)
|
||||
|
||||
[](https://github.com/BillChirico) [](https://github.com/carrotRakko) [](https://github.com/ranausmanai) [](https://github.com/arkyu2077) [](https://github.com/hoyyeva) [](https://github.com/luoyanglang) [](https://github.com/sibbl) [](https://github.com/gregmousseau) [](https://github.com/sahilsatralkar) [](https://github.com/akoscz)
|
||||
|
||||
[](https://github.com/rrenamed) [](https://github.com/YuzuruS) [](https://github.com/Marvae) [](https://github.com/mitchmcalister) [](https://github.com/juanpablodlc) [](https://github.com/shtse8) [](https://github.com/thebenignhacker) [](https://github.com/nimbleenigma) [](https://github.com/Linux2010) [](https://github.com/shichangs)
|
||||
|
||||
[](https://github.com/efe-arv) [](https://github.com/hsiaoa) [](https://github.com/nabbilkhan) [](https://github.com/ayanesakura) [](https://github.com/lupuletic) [](https://github.com/polooooo) [](https://github.com/xaeon2026) [](https://github.com/shrey150) [](https://github.com/taw0002) [](https://github.com/dinakars777)
|
||||
|
||||
[](https://github.com/giulio-leone) [](https://github.com/nyanjou) [](https://github.com/meaningfool) [](https://github.com/kunalk16) [](https://github.com/ide-rea) [](https://github.com/JonathanJing) [](https://github.com/yelog) [](https://github.com/markmusson) [](https://github.com/kiranvk-2011) [](https://github.com/Sathvik-Chowdary-Veerapaneni)
|
||||
|
||||
[](https://github.com/rogerdigital) [](https://github.com/artwalker) [](https://github.com/azade-c) [](https://github.com/chinar-amrutkar) [](https://github.com/maxsumrall) [](https://github.com/Minidoracat) [](https://github.com/unisone) [](https://github.com/ly85206559) [](https://github.com/theSamPadilla) [](https://github.com/AnonO6)
|
||||
|
||||
[](https://github.com/afurm) [](https://github.com/jwchmodx) [](https://github.com/leszekszpunar) [](https://github.com/Mrseenz) [](https://github.com/Yida-Dev) [](https://github.com/kesor) [](https://github.com/mazhe-nerd) [](https://github.com/buerbaumer) [](https://github.com/magimetal) [](https://github.com/patelhiren)
|
||||
|
||||
[](https://github.com/BinHPdev) [](https://github.com/RyanLee-Dev) [](https://github.com/cathrynlavery) [](https://github.com/al3mart) [](https://github.com/JustYannicc) [](https://github.com/AbhisekBasu1) [](https://github.com/dbhurley) [](https://github.com/mpz4life) [](https://github.com/tmimmanuel) [](https://github.com/JustasMonkev)
|
||||
|
||||
[](https://github.com/simantak-dabhade) [](https://github.com/NicholasSpisak) [](https://github.com/natefikru) [](https://github.com/dunamismax) [](https://github.com/simonemacario) [](https://github.com/ENCHIGO) [](https://github.com/xingsy97) [](https://github.com/emonty) [](https://github.com/jadilson12) [](https://github.com/kirisame-wang)
|
||||
|
||||
[](https://github.com/mathiasnagler) [](https://github.com/Oceanswave) [](https://github.com/gumclaw) [](https://github.com/RichardCao) [](https://github.com/MKV21) [](https://github.com/petter-b) [](https://github.com/CodeForgeNet) [](https://github.com/johnsonshi) [](https://github.com/durenzidu) [](https://github.com/dougvk)
|
||||
|
||||
[](https://github.com/Whoaa512) [](https://github.com/zimeg) [](https://github.com/TsekaLuk) [](https://github.com/Ryan-Haines) [](https://github.com/uf-hy) [](https://github.com/Daanvdplas) [](https://github.com/bittoby) [](https://github.com/xuhao1) [](https://github.com/Lucenx9) [](https://github.com/HeMuling)
|
||||
|
||||
[](https://github.com/AaronLuo00) [](https://github.com/YUJIE2002) [](https://github.com/DhruvBhatia0) [](https://github.com/divanoli) [](https://github.com/derbronko) [](https://github.com/rubyrunsstuff) [](https://github.com/rabsef-bicrym) [](https://github.com/IVY-AI-gif) [](https://github.com/pvtclawn) [](https://github.com/stephenschoettler)
|
||||
|
||||
[](https://github.com/minupla) [](https://github.com/xzq-xu) [](https://github.com/mousberg) [](https://github.com/arifahmedjoy) [](https://github.com/harhogefoo) [](https://github.com/2233admin) [](https://github.com/ameno-) [](https://github.com/battman21) [](https://github.com/bcherny) [](https://github.com/bobashopcashier)
|
||||
|
||||
[](https://github.com/dguido) [](https://github.com/druide67) [](https://github.com/guirguispierre) [](https://github.com/jzakirov) [](https://github.com/loganprit) [](https://github.com/martinfrancois) [](https://github.com/neo1027144-creator) [](https://github.com/RealKai42) [](https://github.com/schumilin) [](https://github.com/shuofengzhang)
|
||||
|
||||
[](https://github.com/solstead) [](https://github.com/hengm3467) [](https://github.com/chziyue) [](https://github.com/jameslcowan) [](https://github.com/scifantastic) [](https://github.com/ryan-crabbe) [](https://github.com/alexfilatov) [](https://github.com/Luckymingxuan) [](https://github.com/Hollychou924) [](https://github.com/badlogic)
|
||||
|
||||
[](https://github.com/hnykda) [](https://github.com/dbachelder) [](https://github.com/heavenlost) [](https://github.com/shad0wca7) [](https://github.com/jared596) [](https://github.com/kiranjd) [](https://github.com/Mellowambience) [](https://github.com/KimGLee) [](https://github.com/seheepeak) [](https://github.com/TSavo)
|
||||
|
||||
[](https://github.com/mcrolly) [](https://github.com/dashed) [](https://github.com/Shuai-DaiDai) [](https://github.com/suboss87) [](https://github.com/emanuelst) [](https://github.com/magendary) [](https://github.com/PeterShanxin) [](https://github.com/j2h4u) [](https://github.com/bsormagec) [](https://github.com/mjamiv)
|
||||
|
||||
[](https://github.com/aerolalit) [](https://github.com/jessy2027) [](https://github.com/buddyh) [](https://github.com/aaron-he-zhu) [](https://github.com/hhhhao28) [](https://github.com/benostein) [](https://github.com/LyleLiu666) [](https://github.com/pingren) [](https://github.com/popomore) [](https://github.com/Dithilli)
|
||||
|
||||
[](https://github.com/fal3) [](https://github.com/mkbehr) [](https://github.com/mteam88) [](https://github.com/gupsammy) [](https://github.com/gut-puncture) [](https://github.com/garnetlyx) [](https://github.com/miloudbelarebia) [](https://github.com/Protocol-zero-0) [](https://github.com/pvoo) [](https://github.com/patrick-yingxi-pan)
|
||||
|
||||
[](https://github.com/ptahdunbar) [](https://github.com/keepitmello) [](https://github.com/artuskg) [](https://github.com/Anandesh-Sharma) [](https://github.com/zidongdesign) [](https://github.com/Innocent-children) [](https://github.com/El-Fitz) [](https://github.com/arthurbr11) [](https://github.com/jackheuberger) [](https://github.com/serkonyc)
|
||||
|
||||
[](https://github.com/guxu11) [](https://github.com/hyojin) [](https://github.com/jeann2013) [](https://github.com/jogelin) [](https://github.com/rmorse) [](https://github.com/scz2011) [](https://github.com/andyliu) [](https://github.com/benithors) [](https://github.com/xiwuqi) [](https://github.com/TigerInYourDream)
|
||||
|
||||
[](https://github.com/aaronagent) [](https://github.com/TonyDerek-dot) [](https://github.com/Zitzak) [](https://github.com/ruypang) [](https://github.com/stainlu) [](https://github.com/OpenCils) [](https://github.com/stefangalescu) [](https://github.com/sp-hk2ldn) [](https://github.com/MikeORed) [](https://github.com/graciegould)
|
||||
|
||||
[](https://github.com/cash-echo-bot) [](https://github.com/visionik) [](https://github.com/WalterSumbon) [](https://github.com/SubtleSpark) [](https://github.com/krizpoon) [](https://github.com/rodbland2021) [](https://github.com/thomasxm) [](https://github.com/sar618) [](https://github.com/fagemx) [](https://github.com/daymade)
|
||||
|
||||
[](https://github.com/tysoncung) [](https://github.com/pycckuu) [](https://github.com/omniwired) [](https://github.com/connorshea) [](https://github.com/bonald) [](https://github.com/BeeSting50) [](https://github.com/nachoiacovino) [](https://github.com/zhumengzhu) [](https://github.com/Vitalcheffe) [](https://github.com/zhoulongchao77)
|
||||
|
||||
[](https://github.com/navarrotech) [](https://github.com/CommanderCrowCode) [](https://github.com/paceyw) [](https://github.com/Aftabbs) [](https://github.com/Alex-Alaniz) [](https://github.com/jarvis-medmatic) [](https://github.com/tomron87) [](https://github.com/day253) [](https://github.com/Jaaneek) [](https://github.com/AnCoSONG)
|
||||
|
||||
[](https://github.com/ziomancer) [](https://github.com/shayan919293) [](https://github.com/edwluo) [](https://github.com/rjchien728) [](https://github.com/TinyTb) [](https://github.com/No898) [](https://github.com/ianderrington) [](https://github.com/L-U-C-K-Y) [](https://github.com/peschee) [](https://github.com/Kepler2024)
|
||||
|
||||
[](https://github.com/julianengel) [](https://github.com/markfietje) [](https://github.com/dakshaymehta) [](https://github.com/DavidNitZ) [](https://github.com/dominicnunez) [](https://github.com/danielwanwx) [](https://github.com/hongsw) [](https://github.com/Youyou972) [](https://github.com/boris721) [](https://github.com/damoahdominic)
|
||||
|
||||
[](https://github.com/dan-dr) [](https://github.com/doodlewind) [](https://github.com/kkarimi) [](https://github.com/brokemac79) [](https://github.com/ozbillwang) [](https://github.com/ravyg) [](https://github.com/jasonhargrove) [](https://github.com/BrianWang1990) [](https://github.com/hackersifu) [](https://github.com/Fologan)
|
||||
|
||||
[](https://github.com/AnonAmit) [](https://github.com/v1p0r) [](https://github.com/ajay99511) [](https://github.com/Iranb) [](https://github.com/yhyatt) [](https://github.com/codexGW) [](https://github.com/ShaunTsai) [](https://github.com/papago2355) [](https://github.com/cdorsey) [](https://github.com/tda1017)
|
||||
|
||||
[](https://github.com/0xJonHoldsCrypto) [](https://github.com/akyourowngames) [![clawdinator[bot]](https://avatars.githubusercontent.com/in/2607181?v=4&s=48)](https://github.com/apps/clawdinator) [](https://github.com/koala73) [](https://github.com/sircrumpet) [](https://github.com/thesomewhatyou) [](https://github.com/zats) [](https://github.com/duqaXxX) [](https://github.com/Joly0) [](https://github.com/hannasdev)
|
||||
|
||||
[](https://github.com/jlowin) [](https://github.com/peetzweg) [](https://github.com/adao-max) [](https://github.com/tumf) [](https://github.com/Huntterxx) [](https://github.com/nk1tz) [](https://github.com/lidamao633) [](https://github.com/liebertar) [](https://github.com/CornBrother0x) [](https://github.com/DukeDeSouth)
|
||||
|
||||
[](https://github.com/sahancava) [](https://github.com/CashWilliams) [](https://github.com/lumpinif) [](https://github.com/AdeboyeDN) [](https://github.com/Rohan5commit) [](https://github.com/srinivaspavan9) [](https://github.com/h0tp-ftw) [](https://github.com/neooriginal) [](https://github.com/Tianworld) [](https://github.com/Bermudarat)
|
||||
|
||||
[](https://github.com/asklee-klawd) [](https://github.com/yuting0624) [](https://github.com/constansino) [](https://github.com/ghsmc) [](https://github.com/ibrahimq21) [](https://github.com/irtiq7) [](https://github.com/kelvinCB) [](https://github.com/mitsuhiko) [](https://github.com/nohat) [](https://github.com/santiagomed)
|
||||
|
||||
[](https://github.com/suminhthanh) [](https://github.com/svkozak) [](https://github.com/zhangzhefang-github) [](https://github.com/HOYALIM) [](https://github.com/ping-Toven) [](https://github.com/0-CYBERDYNE-SYSTEMS-0) [](https://github.com/ylc0919) [](https://github.com/reed1898) [](https://github.com/ItsAditya-xyz) [](https://github.com/samrusani)
|
||||
|
||||
[](https://github.com/andyk-ms) [](https://github.com/18-RAJAT) [](https://github.com/cyb1278588254) [](https://github.com/zoherghadyali) [](https://github.com/manikv12) [](https://github.com/manueltarouca) [](https://github.com/GaosCode) [](https://github.com/pahdo) [](https://github.com/detecti1) [](https://github.com/JasonOA888)
|
||||
|
||||
[](https://github.com/sumukhj1219) [](https://github.com/bakhtiersizhaev) [](https://github.com/kyleok) [](https://github.com/AkashKobal) [](https://github.com/zhuisDEV) [](https://github.com/wu-tian807) [](https://github.com/vsabavat) [](https://github.com/kinfey) [](https://github.com/crimeacs) [](https://github.com/VibhorGautam)
|
||||
|
||||
[](https://github.com/John-Rood) [](https://github.com/velamints2) [](https://github.com/benjipeng) [](https://github.com/divisonofficer) [](https://github.com/Rahulkumar070) [](https://github.com/rockcent) [](https://github.com/Limitless2023) [](https://github.com/24601) [](https://github.com/awkoy) [](https://github.com/dawondyifraw)
|
||||
|
||||
[![google-labs-jules[bot]](https://avatars.githubusercontent.com/in/842251?v=4&s=48)](https://github.com/apps/google-labs-jules) [](https://github.com/henrino3) [](https://github.com/Kansodata) [](https://github.com/kaonash) [](https://github.com/p6l-richard) [](https://github.com/pi0) [](https://github.com/skainguyen1412) [](https://github.com/Starhappysh) [](https://github.com/xdanger) [](https://github.com/p3nchan)
|
||||
|
||||
[](https://github.com/scald) [](https://github.com/kashevk0) [](https://github.com/Yuandiaodiaodiao) [](https://github.com/doguabaris) [](https://github.com/ysqander) [](https://github.com/andranik-sahakyan) [](https://github.com/Wangnov) [](https://github.com/rixau) [](https://github.com/lisitan) [](https://github.com/kaizen403)
|
||||
|
||||
[](https://github.com/hirefrank) [](https://github.com/kennyklee) [](https://github.com/dddabtc) [](https://github.com/edincampara) [](https://github.com/fellanH) [](https://github.com/VarunChopra11) [](https://github.com/wangai-studio) [](https://github.com/sleontenko) [](https://github.com/yassine20011) [](https://github.com/ant1eicher)
|
||||
|
||||
[](https://github.com/ThomsenDrake) [](https://github.com/kakuteki) [](https://github.com/andreabadesso) [](https://github.com/chenxin-yan) [](https://github.com/cordx56) [](https://github.com/dvrshil) [](https://github.com/MarvinCui) [](https://github.com/Yeom-JinHo) [](https://github.com/17jmumford) [](https://github.com/KnHack)
|
||||
|
||||
[](https://github.com/SharoonSharif) [](https://github.com/orenyomtov) [](https://github.com/mattqdev) [](https://github.com/parkertoddbrooks) [](https://github.com/he-yufeng) [](https://github.com/Milofax) [](https://github.com/stevebot-alive) [](https://github.com/zhoulf1006) [](https://github.com/jrrcdev) [](https://github.com/feniix)
|
||||
|
||||
[](https://github.com/ZetiMente) [](https://github.com/QuantDeveloperUSA) [](https://github.com/alexstyl) [](https://github.com/ethanpalm) [](https://github.com/qkal) [](https://github.com/cygaar) [](https://github.com/U-C4N) [](https://github.com/jakobdylanc) [](https://github.com/antons) [](https://github.com/austinm911)
|
||||
|
||||
[](https://github.com/mahmoudashraf93) [](https://github.com/philipp-spiess) [](https://github.com/pkrmf) [](https://github.com/joshrad-dev) [](https://github.com/factnest365-ops) [](https://github.com/yingchunbai) [](https://github.com/aj47) [](https://github.com/Alg0rix) [](https://github.com/futhgar) [](https://github.com/YonganZhang)
|
||||
|
||||
[](https://github.com/remusao) [](https://github.com/danballance) [](https://github.com/GHesericsu) [](https://github.com/kimitaka) [](https://github.com/itsjling) [](https://github.com/RayBB) [](https://github.com/lutr0) [](https://github.com/claude) [](https://github.com/angrybirddd) [](https://github.com/fabianwilliams)
|
||||
|
||||
[](https://github.com/haoruilee) [](https://github.com/8BlT) [](https://github.com/atalovesyou) [](https://github.com/erikpr1994) [](https://github.com/jonasjancarik) [](https://github.com/longmaba) [](https://github.com/mitschabaude-bot) [](https://github.com/thesash) [](https://github.com/rdev) [](https://github.com/easternbloc)
|
||||
|
||||
[](https://github.com/chrisrodz) [](https://github.com/gabriel-trigo) [](https://github.com/manmal) [](https://github.com/neist) [](https://github.com/wes-davis) [](https://github.com/ManuelHettich) [](https://github.com/sktbrd) [](https://github.com/larlyssa) [](https://github.com/pcty-nextgen-service-account) [](https://github.com/Syhids)
|
||||
|
||||
[](https://github.com/tmchow) [](https://github.com/mgratch) [](https://github.com/xtao) [](https://github.com/JackyWay) [](https://github.com/j1philli) [](https://github.com/T5-AndyML) [](https://github.com/huohua-dev) [](https://github.com/imfing) [](https://github.com/RandyVentures) [](https://github.com/marcodd23)
|
||||
|
||||
[](https://github.com/Iamadig) [](https://github.com/humanwritten) [](https://github.com/robaxelsen) [](https://github.com/prathamdby) [](https://github.com/0oAstro) [](https://github.com/aaronn) [](https://github.com/afern247) [](https://github.com/Asleep123) [](https://github.com/dantelex) [](https://github.com/fcatuhe)
|
||||
|
||||
[](https://github.com/gtsifrikas) [](https://github.com/hrdwdmrbl) [](https://github.com/hugobarauna) [](https://github.com/jayhickey) [](https://github.com/jiulingyun) [](https://github.com/jdrhyne) [](https://github.com/jverdi) [](https://github.com/kitze) [](https://github.com/loukotal) [](https://github.com/minghinmatthewlam)
|
||||
|
||||
[](https://github.com/MSch) [](https://github.com/odrobnik) [](https://github.com/oswalpalash) [](https://github.com/ratulsarna) [](https://github.com/reeltimeapps) [](https://github.com/snopoke) [](https://github.com/sreekaransrinath) [](https://github.com/timkrase)
|
||||
|
||||
<!-- clawtributors:end -->
|
||||
<!-- clawtributors:hidden:start
|
||||
default-avatar-cache: hidden from the rendered wall because these users still use GitHub's default avatar
|
||||
13otkmdr
|
||||
aaronveklabs
|
||||
adityashaw2
|
||||
ai-reviewer-qs
|
||||
alexyyyander
|
||||
alphonse-arianee
|
||||
amitbiswal007
|
||||
bbblending
|
||||
bbddbb1
|
||||
bitfoundry-ai
|
||||
bugkillerking
|
||||
carlulsoe
|
||||
charzhou
|
||||
cheeeee
|
||||
dalomeve
|
||||
danielz1z
|
||||
diaspar4u
|
||||
dirbalak
|
||||
djangonavarro220
|
||||
dobbylorenzbot
|
||||
drcrinkle
|
||||
drickon
|
||||
eddertalmor
|
||||
eengad
|
||||
efe-buken
|
||||
eric-fr4
|
||||
eronfan
|
||||
evandance
|
||||
extrasmall0
|
||||
ezhikkk
|
||||
fuller-stack-dev
|
||||
fwhite13
|
||||
gambletan
|
||||
gejifeng
|
||||
harrington-bot
|
||||
heimdallstrategy
|
||||
heyhudson
|
||||
hougangdev
|
||||
jamesgroat
|
||||
jamtujest
|
||||
jaymishra-source
|
||||
joe2643
|
||||
joetomasone
|
||||
jonathanworks
|
||||
jonisjongithub
|
||||
jscaldwell55
|
||||
julbarth
|
||||
junjunjunbong
|
||||
kirillshchetinin
|
||||
kyohwang
|
||||
lailoo
|
||||
latitudeki5223
|
||||
lawrence3699
|
||||
liaosvcaf
|
||||
livingghost
|
||||
luijoc
|
||||
lukeboyett
|
||||
lurebat
|
||||
mahanandhi
|
||||
maple778
|
||||
martingarramon
|
||||
matthew19990919
|
||||
moktamd
|
||||
moltbot886
|
||||
mujiannan
|
||||
mukhtharcm
|
||||
mylszd
|
||||
natedenh
|
||||
nicholascyh
|
||||
nickhood1984
|
||||
nico-hoff
|
||||
nikus-pan
|
||||
nonggialiang
|
||||
oliviareid-svg
|
||||
openclaw-bot
|
||||
pablohrcarvalho
|
||||
patrick-barletta
|
||||
pinghuachiu
|
||||
private-peter
|
||||
prospectore
|
||||
rafaelreis-r
|
||||
rexl2018
|
||||
rexlunae
|
||||
rhjoh
|
||||
ronak-guliani
|
||||
ryancontent
|
||||
ryanngit
|
||||
rybnikov
|
||||
sandpile
|
||||
sbking
|
||||
shivamraut101
|
||||
shuicici
|
||||
slats24
|
||||
slepybear
|
||||
sline
|
||||
socialnerd42069
|
||||
solodmd
|
||||
sudie-codes
|
||||
sumleo
|
||||
superman32432432
|
||||
ted-developer
|
||||
tempeste
|
||||
theonejvo
|
||||
tosh-hamburg
|
||||
uli-will-code
|
||||
w-sss
|
||||
whiskyboy
|
||||
wittam-01
|
||||
xieyongliang
|
||||
yassinebkr
|
||||
yuna78
|
||||
yuweuii
|
||||
yxjsxy
|
||||
zijiess
|
||||
clawtributors:hidden:end -->
|
||||
|
||||
@@ -67,6 +67,7 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
|
||||
@@ -129,6 +130,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
|
||||
- Public Internet Exposure
|
||||
- Using OpenClaw in ways that the docs recommend not to
|
||||
- Test-only code and maintainer harnesses, including QA Lab, QE Lab, E2E fixtures, benchmark rigs, smoke-test containers, and local debugging proxies, unless the report demonstrates that the same vulnerable behavior is reachable from shipped OpenClaw production code or a published package artifact intended for users.
|
||||
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
|
||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||
|
||||
+86
-57
@@ -2,6 +2,92 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.4.14</title>
|
||||
<pubDate>Tue, 14 Apr 2026 14:08:09 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026041490</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.14</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.14</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>OpenAI Codex/models: add forward-compat support for <code>gpt-5.4-pro</code>, including Codex pricing/limits and list/status visibility before the upstream catalog catches up. (#66453) Thanks @jepson-liu.</li>
|
||||
<li>Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.</li>
|
||||
<li>Models/Codex: include <code>apiKey</code> in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in <code>models.json</code>. (#66180) Thanks @hoyyeva.</li>
|
||||
<li>Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.</li>
|
||||
<li>Slack/interactions: apply the configured global <code>allowFrom</code> owner allowlist to channel block-action and modal interactive events, require an expected sender id for cross-verification, and reject ambiguous channel types so interactive triggers can no longer bypass the documented allowlist intent in channels without a <code>users</code> list. Open-by-default behavior is preserved when no allowlists are configured. (#66028) Thanks @eleqtrizit.</li>
|
||||
<li>Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via <code>realpath</code>, so a <code>realpath</code> error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit.</li>
|
||||
<li>Agents/gateway-tool: reject <code>config.patch</code> and <code>config.apply</code> calls from the model-facing gateway tool when they would newly enable any flag enumerated by <code>openclaw security audit</code> (for example <code>dangerouslyDisableDeviceAuth</code>, <code>allowInsecureAuth</code>, <code>dangerouslyAllowHostHeaderOriginFallback</code>, <code>hooks.gmail.allowUnsafeExternalContent</code>, <code>tools.exec.applyPatch.workspaceOnly: false</code>); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit.</li>
|
||||
<li>Google image generation: strip a trailing <code>/openai</code> suffix from configured Google base URLs only when calling the native Gemini image API so Gemini image requests stop 404ing without breaking explicit OpenAI-compatible Google endpoints. (#66445) Thanks @dapzthelegend.</li>
|
||||
<li>Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus.</li>
|
||||
<li>Doctor/systemd: keep <code>openclaw doctor --repair</code> and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir <code>.env</code> values. (#66249) Thanks @tmimmanuel.</li>
|
||||
<li>Ollama/OpenAI-compat: send <code>stream_options.include_usage</code> for Ollama streaming completions so local Ollama runs report real usage instead of falling back to bogus prompt-token counts that trigger premature compaction. (#64568) Thanks @xchunzhao and @vincentkoc.</li>
|
||||
<li>Doctor/plugins: cache external <code>preferOver</code> catalog lookups within each plugin auto-enable pass so large <code>agents.list</code> configs no longer peg CPU and repeatedly reread plugin catalogs during doctor/plugins resolution. (#66246) Thanks @yfge.</li>
|
||||
<li>GitHub Copilot/thinking: allow <code>github-copilot/gpt-5.4</code> to use <code>xhigh</code> reasoning so Copilot GPT-5.4 matches the rest of the GPT-5.4 family. (#50168) Thanks @jakepresent and @vincentkoc.</li>
|
||||
<li>Memory/embeddings: preserve non-OpenAI provider prefixes when normalizing OpenAI-compatible embedding model refs so proxy-backed memory providers stop failing with <code>Unknown memory embedding provider</code>. (#66452) Thanks @jlapenna.</li>
|
||||
<li>Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when <code>agents.defaults.contextTokens</code> is the real limit. (#66236) Thanks @ImLukeF.</li>
|
||||
<li>Browser/SSRF: restore hostname navigation under the default browser SSRF policy while keeping explicit strict mode reachable from config, and keep managed loopback CDP <code>/json/new</code> fallback requests on the local CDP control policy so browser follow-up fixes stop regressing normal navigation or self-blocking local CDP control. (#66386) Thanks @obviyus.</li>
|
||||
<li>Models/Codex: canonicalize the legacy <code>openai-codex/gpt-5.4-codex</code> runtime alias to <code>openai-codex/gpt-5.4</code> while still honoring alias-specific and canonical per-model overrides. (#43060) Thanks @Sapientropic and @vincentkoc.</li>
|
||||
<li>Browser/SSRF: preserve explicit strict browser navigation mode for legacy <code>browser.ssrfPolicy.allowPrivateNetwork: false</code> configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path.</li>
|
||||
<li>Onboarding/custom providers: use <code>max_tokens=16</code> for OpenAI-compatible verification probes so stricter custom endpoints stop rejecting onboarding checks that only need a tiny completion. (#66450) Thanks @WuKongAI-CMU.</li>
|
||||
<li>Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with <code>ERR_MODULE_NOT_FOUND</code> at runtime. (#66420) Thanks @obviyus.</li>
|
||||
<li>Media-understanding/proxy env: auto-upgrade provider HTTP helper requests to trusted env-proxy mode only when <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code> is active and the target is not bypassed by <code>NO_PROXY</code>, so remote media-understanding and transcription requests stop failing local DNS pre-resolution in proxy-only environments without widening SSRF bypasses. (#52162) Thanks @mjamiv and @vincentkoc.</li>
|
||||
<li>Telegram/media downloads: let Telegram media fetches trust an operator-configured explicit proxy for target DNS resolution after hostname-policy checks, so proxy-backed installs stop failing <code>could not download media</code> on Bot API file downloads after the DNS-pinning regression. (#66245) Thanks @dawei41468 and @vincentkoc.</li>
|
||||
<li>Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.</li>
|
||||
<li>Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when <code>afterTurn</code> is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.</li>
|
||||
<li>OpenAI Codex/auth: keep malformed Codex CLI auth-file diagnostics on the debug logger instead of stdout so interactive command output stays clean while auth read failures remain traceable. (#66451) Thanks @SimbaKingjoe.</li>
|
||||
<li>Discord/native commands: return the real status card for native <code>/status</code> interactions instead of falling through to the synthetic <code>✅ Done.</code> ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.</li>
|
||||
<li>Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit <code>agents.defaults.timeoutSeconds</code> override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.</li>
|
||||
<li>Media/transcription: remap <code>.aac</code> filenames to <code>.m4a</code> for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.</li>
|
||||
<li>UI/chat: replace marked.js with markdown-it so maliciously crafted markdown can no longer freeze the Control UI via ReDoS. (#46707) Thanks @zhangfnf.</li>
|
||||
<li>Auto-reply/send policy: keep <code>sendPolicy: "deny"</code> from blocking inbound message processing, so the agent still runs its turn while all outbound delivery is suppressed for observer-style setups. (#65461, #53328) Thanks @omarshahine.</li>
|
||||
<li>BlueBubbles: lazy-refresh the Private API server-info cache on send when reply threading or message effects are requested but status is unknown, so sends no longer silently degrade to plain messages when the 10-minute cache expires. (#65447, #43764) Thanks @omarshahine.</li>
|
||||
<li>Heartbeat/security: force owner downgrade for untrusted <code>hook:wake</code> system events [AI-assisted]. (#66031) Thanks @pgondhi987.</li>
|
||||
<li>Browser/security: enforce SSRF policy on snapshot, screenshot, and tab routes [AI]. (#66040) Thanks @pgondhi987.</li>
|
||||
<li>Microsoft Teams/security: enforce sender allowlist checks on SSO signin invokes [AI]. (#66033) Thanks @pgondhi987.</li>
|
||||
<li>Config/security: redact <code>sourceConfig</code> and <code>runtimeConfig</code> alias fields in <code>redactConfigSnapshot</code> [AI]. (#66030) Thanks @pgondhi987.</li>
|
||||
<li>Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) Thanks @100yenadmin.</li>
|
||||
<li>Plugins/status: report the registered context-engine IDs in <code>plugins inspect</code> instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) Thanks @zhuisDEV.</li>
|
||||
<li>Context engines: reject resolved plugin engines whose reported <code>info.id</code> does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev.</li>
|
||||
<li>WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient <code>ENOENT</code> crashes on image sends. (#65896) Thanks @frankekn.</li>
|
||||
<li>Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale <code>dist/entry.js</code> and current <code>dist/index.js</code> paths. (#65984) Thanks @mbelinky.</li>
|
||||
<li>Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when <code>target=last</code>, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky.</li>
|
||||
<li>Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky.</li>
|
||||
<li>Gateway/sessions: stop heartbeat, cron-event, and exec-event turns from overwriting shared-session routing and origin metadata, preventing synthetic <code>heartbeat</code> targets from poisoning later cron or user delivery. (#66073, #63733, #35300) Thanks @mbelinky.</li>
|
||||
<li>Browser/CDP: let local attach-only <code>manual-cdp</code> profiles reuse the local loopback CDP control plane under strict default policy and remote-class probe timeouts, so tabs/snapshot stop falsely reporting a live local browser session as not running. (#65611, #66080) Thanks @mbelinky.</li>
|
||||
<li>Cron/scheduler: stop inventing short retries when cron next-run calculation returns no valid future slot, and keep a maintenance wake armed so enabled unscheduled jobs recover without entering a refire loop. (#66019, #66083) Thanks @mbelinky.</li>
|
||||
<li>Cron/scheduler: preserve the active error-backoff floor when maintenance repair recomputes a missing cron next-run, so recurring errored jobs do not resume early after a transient next-run resolution failure. (#66019, #66083, #66113) Thanks @mbelinky.</li>
|
||||
<li>Outbound/delivery-queue: persist the originating outbound <code>session</code> context on queued delivery entries and replay it during recovery, so write-ahead-queued sends keep their original outbound media policy context after restart instead of evaluating against a missing session. (#66025) Thanks @eleqtrizit.</li>
|
||||
<li>Memory/Ollama: restore the built-in <code>ollama</code> embedding adapter in memory-core so explicit <code>memorySearch.provider: "ollama"</code> works again, and include endpoint-aware cache keys so different Ollama hosts do not reuse each other's embeddings. (#63429, #66078, #66163) Thanks @nnish16 and @vincentkoc.</li>
|
||||
<li>Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit.</li>
|
||||
<li>Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky.</li>
|
||||
<li>Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional <code>memory-wiki</code> gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky.</li>
|
||||
<li>Agents/tools: only mark streamed unknown-tool retries as counted when a streamed message actually classifies an unavailable tool, and keep incomplete streamed tool names from resetting the retry streak before the final assistant message arrives. (#66145) Thanks @dutifulbob.</li>
|
||||
<li>Memory/active-memory: move recalled memory onto the hidden untrusted prompt-prefix path instead of system prompt injection, label the visible Active Memory status line fields, and include the resolved recall provider/model in gateway debug logs so trace/debug output matches what the model actually saw. (#66144) Thanks @Takhoffman.</li>
|
||||
<li>Memory/QMD: stop treating legacy lowercase <code>memory.md</code> as a second default root collection, so QMD recall no longer searches phantom <code>memory-alt-*</code> collections and builtin/QMD root-memory fallback stays aligned. (#66141) Thanks @mbelinky.</li>
|
||||
<li>Agents/subagents: ship <code>dist/agents/subagent-registry.runtime.js</code> in npm builds so <code>runtime: "subagent"</code> runs stop stalling in <code>queued</code> after the registry import fails. (#66189) Thanks @yqli2420 and @vincentkoc.</li>
|
||||
<li>Agents/OpenAI: map <code>minimal</code> thinking to OpenAI's supported <code>low</code> reasoning effort for GPT-5.4 requests, so embedded runs stop failing request validation. Thanks @steipete.</li>
|
||||
<li>Voice-call/media-stream: resolve the source IP from trusted forwarding headers for per-IP pending-connection limits when <code>webhookSecurity.trustForwardingHeaders</code> and <code>trustedProxyIPs</code> are configured, and reserve <code>maxConnections</code> capacity for in-flight WebSocket upgrades so concurrent handshakes can no longer momentarily exceed the operator-set cap. (#66027) Thanks @eleqtrizit.</li>
|
||||
<li>Feishu/allowlist: canonicalize allowlist entries by explicit <code>user</code>/<code>chat</code> kind, strip repeated <code>feishu:</code>/<code>lark:</code> provider prefixes, and stop folding opaque Feishu IDs to lowercase, so allowlist matching no longer crosses user/chat namespaces or widens to case-insensitive ID matches the operator did not intend. (#66021) Thanks @eleqtrizit.</li>
|
||||
<li>Telegram/status commands: let read-only status slash commands bypass busy topic turns, while keeping <code>/export-session</code> on the normal lane so it cannot interleave with an in-flight session mutation. (#66226) Thanks @VACInc and @vincentkoc.</li>
|
||||
<li>TTS/reply media: persist OpenClaw temp voice outputs into managed outbound media and allow them through reply-media normalization, so voice-note replies stop silently dropping. (#63511) Thanks @jetd1.</li>
|
||||
<li>Agents/tools: treat Windows drive-letter paths (<code>C:\\...</code>) as absolute when resolving sandbox and read-tool paths so workspace root is not prepended under POSIX path rules. (#54039) Thanks @ly85206559 and @vincentkoc.</li>
|
||||
<li>Agents/OpenAI: recover embedded GPT-style runs when reasoning-only or empty turns need bounded continuation, with replay-safe retry gating and incomplete-turn fallback when no visible answer arrives. (#66167) thanks @jalehman</li>
|
||||
<li>Outbound/relay-status: suppress internal relay-status placeholder payloads (<code>No channel reply.</code>, <code>Replied in-thread.</code>, <code>Replied in #...</code>, wiki-update status variants ending in <code>No channel reply.</code>) before channel delivery so internal housekeeping text does not leak to users.</li>
|
||||
<li>Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as <code>openclaw cron</code> no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson.</li>
|
||||
<li>Hooks/session-memory: pass the resolved agent workspace into gateway <code>/new</code> and <code>/reset</code> session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.</li>
|
||||
<li>CLI/approvals: raise the default <code>openclaw approvals get</code> gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading <code>Config unavailable.</code> note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.</li>
|
||||
<li>Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.14/OpenClaw-2026.4.14.zip" length="47490719" type="application/octet-stream" sparkle:edSignature="KW4gq3qjhKPSQebRVL/mSgttTOhLVKtnWz7pNCZt29oEZ96yU14OnxxSsmtNHmDi4m7G7gfVOfndp80XKFQlCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.4.11</title>
|
||||
<pubDate>Sun, 12 Apr 2026 00:37:09 +0000</pubDate>
|
||||
@@ -189,62 +275,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.10/OpenClaw-2026.4.10.zip" length="47259509" type="application/octet-stream" sparkle:edSignature="XY9FHxx09r2O9rlFs3t5UV9Zk2rGXSpWw5InazJhb661kgp6OKiOrrNTV631b2StWze5tnSEPXakkOCXq7O6DQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.4.9</title>
|
||||
<pubDate>Thu, 09 Apr 2026 02:38:08 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026040990</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.9</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.9</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Memory/dreaming: add a grounded REM backfill lane with historical <code>rem-harness --path</code>, diary commit/reset flows, cleaner durable-fact extraction, and live short-term promotion integration so old daily notes can replay into Dreams and durable memory without a second memory stack. Thanks @mbelinky.</li>
|
||||
<li>Control UI/dreaming: add a structured diary view with timeline navigation, backfill/reset controls, traceable dreaming summaries, and a grounded Scene lane with promotion hints plus a safe clear-grounded action for staged backfill signals. (#63395) Thanks @mbelinky.</li>
|
||||
<li>QA/lab: add character-vibes evaluation reports with model selection and parallel runs so live QA can compare candidate behavior faster.</li>
|
||||
<li>Plugins/provider-auth: let provider manifests declare <code>providerAuthAliases</code> so provider variants can share env vars, auth profiles, config-backed auth, and API-key onboarding choices without core-specific wiring.</li>
|
||||
<li>iOS: pin release versioning to an explicit CalVer in <code>apps/ios/version.json</code>, keep TestFlight iteration on the same short version until maintainers intentionally promote the next gateway version, and add the documented <code>pnpm ios:version:pin -- --from-gateway</code> workflow for release trains. (#63001) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Browser/security: re-run blocked-destination safety checks after interaction-driven main-frame navigations from click, evaluate, hook-triggered click, and batched action flows, so browser interactions cannot bypass the SSRF quarantine when they land on forbidden URLs. (#63226) Thanks @eleqtrizit.</li>
|
||||
<li>Security/dotenv: block runtime-control env vars plus browser-control override and skip-server env vars from untrusted workspace <code>.env</code> files, and reject unsafe URL-style browser control override specifiers before lazy loading. (#62660, #62663) Thanks @eleqtrizit.</li>
|
||||
<li>Gateway/node exec events: mark remote node <code>exec.started</code>, <code>exec.finished</code>, and <code>exec.denied</code> summaries as untrusted system events and sanitize node-provided command/output/reason text before enqueueing them, so remote node output cannot inject trusted <code>System:</code> content into later turns. (#62659) Thanks @eleqtrizit.</li>
|
||||
<li>Plugins/onboarding auth choices: prevent untrusted workspace plugins from colliding with bundled provider auth-choice ids during non-interactive onboarding, so bundled provider setup keeps operator secrets out of untrusted workspace plugin handlers unless those plugins are explicitly trusted. (#62368) Thanks @pgondhi987.</li>
|
||||
<li>Security/dependency audit: force <code>basic-ftp</code> to <code>5.2.1</code> for the CRLF command-injection fix and bump Hono plus <code>@hono/node-server</code> in production resolution paths.</li>
|
||||
<li>Android/pairing: clear stale setup-code auth on new QR scans, bootstrap operator and node sessions from fresh pairing, prefer stored device tokens after bootstrap handoff, and pause pairing auto-retry while the app is backgrounded so scan-once Android pairing recovers reliably again. (#63199) Thanks @obviyus.</li>
|
||||
<li>Matrix/gateway: wait for Matrix sync readiness before marking startup successful, keep Matrix background handler failures contained, and route fatal Matrix sync stops through channel-level restart handling instead of crashing the whole gateway. (#62779) Thanks @gumadeiras.</li>
|
||||
<li>Slack/media: preserve bearer auth across same-origin <code>files.slack.com</code> redirects while still stripping it on cross-origin Slack CDN hops, so <code>url_private_download</code> image attachments load again. (#62960) Thanks @vincentkoc.</li>
|
||||
<li>Reply/doctor: use the active runtime snapshot for queued reply runs, resolve reply-run SecretRefs before preflight helpers touch config, surface gateway OAuth reauth failures to users, and make <code>openclaw doctor</code> call out exact reauth commands. (#62693, #63217) Thanks @mbelinky.</li>
|
||||
<li>Control UI: guard stale session-history reloads during fast session switches so the selected session and rendered transcript stay in sync. (#62975) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/chat: suppress exact and streamed <code>ANNOUNCE_SKIP</code> / <code>REPLY_SKIP</code> control replies across live chat updates and history sanitization so internal agent-to-agent control tokens no longer leak into user-facing gateway chat surfaces. (#51739) Thanks @Pinghuachiu.</li>
|
||||
<li>Auto-reply/NO_REPLY: strip glued leading <code>NO_REPLY</code> tokens before reply normalization and ACP-visible streaming so silent sentinel text no longer leaks into user-visible replies while preserving substantive <code>NO_REPLY ...</code> text. Thanks @frankekn.</li>
|
||||
<li>Sessions/routing: preserve established external routes on inter-session announce traffic so <code>sessions_send</code> follow-ups do not steal delivery from Telegram, Discord, or other external channels. (#58013) Thanks @duqaXxX.</li>
|
||||
<li>Gateway/sessions: clear auto-fallback-pinned model overrides on <code>/reset</code> and <code>/new</code> while still preserving explicit user model selections, including legacy sessions created before override-source tracking existed. (#63155) Thanks @frankekn.</li>
|
||||
<li>Slack/ACP: treat Slack ACP block replies as visible delivered output so OpenClaw stops re-sending the final fallback text after Slack already rendered the reply. (#62858) Thanks @gumadeiras.</li>
|
||||
<li>Slack/partial streaming: key turn-local dedupe by dispatch kind and keep the final fallback reply path active when preview finalization fails so stale preview text cannot suppress the actual final answer. (#62859) Thanks @gumadeiras.</li>
|
||||
<li>Matrix/doctor: migrate legacy <code>channels.matrix.dm.policy: "trusted"</code> configs back to compatible DM policies during <code>openclaw doctor --fix</code>, preserving explicit <code>allowFrom</code> boundaries as <code>allowlist</code> and defaulting empty legacy configs to <code>pairing</code>. (#62942) Thanks @lukeboyett.</li>
|
||||
<li>npm packaging: mirror bundled channel runtime deps, stage Nostr runtime deps, derive required root mirrors from manifests and built chunks, and test packed release tarballs without repo <code>node_modules</code> so fresh installs fail fast on missing plugin deps instead of crashing at runtime. (#63065) Thanks @scoootscooob.</li>
|
||||
<li>QA/live auth: fail fast when live QA scenarios hit classified auth or runtime failure replies, including raw scenario wait paths, and sanitize missing-key guidance so gateway auth problems surface as actionable errors instead of timeouts. (#63333) Thanks @shakkernerd.</li>
|
||||
<li>Providers/OpenAI: default missing reasoning effort to <code>high</code> on OpenAI Responses, WebSocket, and compatible completions transports, while still honoring explicit per-run reasoning levels.</li>
|
||||
<li>Providers/Ollama: allow Ollama models using the native <code>api: "ollama"</code> path to optionally display thinking output when <code>/think</code> is set to a non-off level. (#62712) Thanks @hoyyeva.</li>
|
||||
<li>Codex CLI: pass OpenClaw's system prompt through Codex's <code>model_instructions_file</code> config override so fresh Codex CLI sessions receive the same prompt guidance as Claude CLI sessions.</li>
|
||||
<li>Auth/profiles: persist explicit auth-profile upserts directly and skip external CLI sync for local writes so profile changes are saved without stale external credential state.</li>
|
||||
<li>Agents/timeouts: make the LLM idle timeout inherit <code>agents.defaults.timeoutSeconds</code> when configured, disable the unconfigured idle watchdog for cron runs, and point idle-timeout errors at <code>agents.defaults.llm.idleTimeoutSeconds</code>. Thanks @drvoss.</li>
|
||||
<li>Agents/failover: classify Z.ai vendor code <code>1311</code> as billing and <code>1113</code> as auth, including long wrapped <code>1311</code> payloads, so these errors stop falling through to generic failover handling. (#49552) Thanks @1bcMax.</li>
|
||||
<li>QQBot/media-tags: support HTML entity-encoded angle brackets (<code><</code>/<code>></code>), URL slashes in attributes, and self-closing media tags so upstream <code><qqimg></code> payloads are correctly parsed and normalized. (#60493) Thanks @ylc0919.</li>
|
||||
<li>Memory/dreaming: harden grounded backfill inputs, diary writes, status payloads, and diary action classification by preserving source-day labels, rejecting missing or symlinked targets cleanly, normalizing diary headings in gateway backfills, and tightening claim splitting plus diary source metadata. Thanks @mbelinky.</li>
|
||||
<li>Memory/dreaming: accept embedded heartbeat trigger tokens so light and REM dreaming still run when runtime wrappers include extra heartbeat text.</li>
|
||||
<li>Android/manual connect: allow blank port input only for TLS manual gateway endpoints so standard HTTPS Tailscale hosts default to <code>443</code> without silently changing cleartext manual connects. (#63134) Thanks @Tyler-RNG.</li>
|
||||
<li>Windows/update: add heap headroom to Windows <code>pnpm build</code> steps during dev updates so update preflight builds stop failing on low default Node memory.</li>
|
||||
<li>Plugin SDK: export the channel plugin base and web-search config contract through the public package so plugins can use them without private imports.</li>
|
||||
<li>Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical <code>*.test.ts</code> files stay blocked. (#63311) Thanks @altaywtf.</li>
|
||||
<li>Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the <code>openrouter/</code> prefix. (#63416) Thanks @sallyom.</li>
|
||||
<li>Plugin SDK/command auth: split command status builders onto the lightweight <code>openclaw/plugin-sdk/command-status</code> subpath while preserving deprecated <code>command-auth</code> compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.9/OpenClaw-2026.4.9.zip" length="25336730" type="application/octet-stream" sparkle:edSignature="zFKTcKpejPyGEHj6Bdop3EBDfRrHyQMtJzrpVKsIkBq3I/jbTNvsxQveKEy9r7dqkZVsldFYv7eSunP3SUmaAw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026041401
|
||||
versionName = "2026.4.14"
|
||||
versionCode = 2026041501
|
||||
versionName = "2026.4.15-beta.1"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.4.15 - 2026-04-15
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
## 2026.4.14 - 2026-04-14
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.4.14
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.14
|
||||
OPENCLAW_IOS_VERSION = 2026.4.15
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.15
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.14"
|
||||
"version": "2026.4.15"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.14</string>
|
||||
<string>2026.4.15-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026041401</string>
|
||||
<string>2026041501</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
3583489dfebd88a53f1c66c984b16dc5eff752c887d4c582a86753990f1d5b18 config-baseline.json
|
||||
a490b20c47a45c3e26b6917eb3e102356698395128aec20b1f4aabb62ca7cad1 config-baseline.core.json
|
||||
3bb312dc9c39a374ca92613abf21606c25dc571287a3941dac71ff57b2b5c519 config-baseline.channel.json
|
||||
0471a5bffb213a3829555efe5961f5b5fd5080c1d38b1ac8dd87afaabdb8bdc1 config-baseline.plugin.json
|
||||
4fec95c9ce02dddb4d3021812cf68df8b4cc92c5ba4db35778bb1bfe6fa63021 config-baseline.json
|
||||
aafbb407e62908709e90f750ea0f8274016fcfcbd613394896ff984f967f236e config-baseline.core.json
|
||||
ef83a06633fc001b5b2535566939186ecb49d05cd1a90b40e54cc58d3e6e44e3 config-baseline.channel.json
|
||||
5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
7b121e2b694f80433fa91ce9037527ca58be546a7f18798470a4ade66593e5e1 plugin-sdk-api-baseline.json
|
||||
7b802cc04f0eac0b498b50711e39a7afe93bbb6b682a2013d2c303583fb73f40 plugin-sdk-api-baseline.jsonl
|
||||
c2c6319c35f152d2a2b36584981b92c22f7e9759a27d47ad66bfdbcef916eace plugin-sdk-api-baseline.json
|
||||
3ba23b54667c75caba3560cc66a399b7bdd9b316009bf5ad6a43aefd469f1552 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
"source": "Diffs",
|
||||
"target": "Diffs"
|
||||
},
|
||||
{
|
||||
"source": "Dreaming",
|
||||
"target": "Dreaming"
|
||||
},
|
||||
{
|
||||
"source": "Capability Cookbook",
|
||||
"target": "能力扩展手册"
|
||||
|
||||
@@ -613,7 +613,8 @@ if you want a shorter or longer retry window.
|
||||
Startup also performs a conservative crypto bootstrap pass automatically.
|
||||
That pass tries to reuse the current secret storage and cross-signing identity first, and avoids resetting cross-signing unless you run an explicit bootstrap repair flow.
|
||||
|
||||
If startup finds broken bootstrap state and `channels.matrix.password` is configured, OpenClaw can attempt a stricter repair path.
|
||||
If startup still finds broken bootstrap state, OpenClaw can attempt a guarded repair path even when `channels.matrix.password` is not configured.
|
||||
If the homeserver requires password-based UIA for that repair, OpenClaw logs a warning and keeps startup non-fatal instead of aborting the bot.
|
||||
If the current device is already owner-signed, OpenClaw preserves that identity instead of resetting it automatically.
|
||||
|
||||
See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages.
|
||||
@@ -919,6 +920,7 @@ Entries without `account` stay shared across all Matrix accounts, and entries wi
|
||||
Partial shared auth defaults do not create a separate implicit default account by themselves. OpenClaw only synthesizes the top-level `default` account when that default has fresh auth (`homeserver` plus `accessToken`, or `homeserver` plus `userId` and `password`); named accounts can still stay discoverable from `homeserver` plus `userId` when cached credentials satisfy auth later.
|
||||
If Matrix already has exactly one named account, or `defaultAccount` points at an existing named account key, single-account-to-multi-account repair/setup promotion preserves that account instead of creating a fresh `accounts.default` entry. Only Matrix auth/bootstrap keys move into that promoted account; shared delivery-policy keys stay at the top level.
|
||||
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
|
||||
If multiple Matrix accounts are configured and one account id is `default`, OpenClaw uses that account implicitly even when `defaultAccount` is unset.
|
||||
If you configure multiple named accounts, set `defaultAccount` or pass `--account <id>` for CLI commands that rely on implicit account selection.
|
||||
Pass `--account <id>` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command.
|
||||
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ openclaw memory rem-harness [--agent <id>] [--include-promoted] [--json]
|
||||
- `--include-promoted`: include already promoted deep candidates.
|
||||
- `--json`: print JSON output.
|
||||
|
||||
## Dreaming (experimental)
|
||||
## Dreaming
|
||||
|
||||
Dreaming is the background memory consolidation system with three cooperative
|
||||
phases: **light** (sort/stage short-term material), **deep** (promote durable
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Dreaming (experimental)"
|
||||
title: "Dreaming"
|
||||
summary: "Background memory consolidation with light, deep, and REM phases plus a Dream Diary"
|
||||
read_when:
|
||||
- You want memory promotion to run automatically
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
- You want to tune consolidation without polluting MEMORY.md
|
||||
---
|
||||
|
||||
# Dreaming (experimental)
|
||||
# Dreaming
|
||||
|
||||
Dreaming is the background memory consolidation system in `memory-core`.
|
||||
It helps OpenClaw move strong short-term signals into durable memory while
|
||||
@@ -80,6 +80,9 @@ After each phase has enough material, `memory-core` runs a best-effort backgroun
|
||||
subagent turn (using the default runtime model) and appends a short diary entry.
|
||||
|
||||
This diary is for human reading in the Dreams UI, not a promotion source.
|
||||
Dreaming-generated diary/report artifacts are excluded from short-term
|
||||
promotion. Only grounded memory snippets are eligible to promote into
|
||||
`MEMORY.md`.
|
||||
|
||||
There is also a grounded historical backfill lane for review and recovery work:
|
||||
|
||||
@@ -212,7 +215,7 @@ All settings live under `plugins.entries.memory-core.config.dreaming`.
|
||||
Phase policy, thresholds, and storage behavior are internal implementation
|
||||
details (not user-facing config).
|
||||
|
||||
See [Memory configuration reference](/reference/memory-config#dreaming-experimental)
|
||||
See [Memory configuration reference](/reference/memory-config#dreaming)
|
||||
for the full key list.
|
||||
|
||||
## Dreams UI
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Experimental Features"
|
||||
summary: "What experimental flags mean in OpenClaw and which ones are currently documented"
|
||||
read_when:
|
||||
- You see an `.experimental` config key and want to know whether it is stable
|
||||
- You want to try preview runtime features without confusing them with normal defaults
|
||||
- You want one place to find the currently documented experimental flags
|
||||
---
|
||||
|
||||
# Experimental features
|
||||
|
||||
Experimental features in OpenClaw are **opt-in preview surfaces**. They are
|
||||
behind explicit flags because they still need real-world mileage before they
|
||||
deserve a stable default or a long-lived public contract.
|
||||
|
||||
Treat them differently from normal config:
|
||||
|
||||
- Keep them **off by default** unless the related doc tells you to try one.
|
||||
- Expect **shape and behavior to change** faster than stable config.
|
||||
- Prefer the stable path first when one already exists.
|
||||
- If you are rolling OpenClaw out broadly, test experimental flags in a smaller
|
||||
environment before baking them into a shared baseline.
|
||||
|
||||
## Currently documented flags
|
||||
|
||||
| Surface | Key | Use it when | More |
|
||||
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Local model runtime | `agents.defaults.experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
|
||||
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
|
||||
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/configuration-reference#toolsexperimental) |
|
||||
|
||||
## Local model lean mode
|
||||
|
||||
`agents.defaults.experimental.localModelLean: true` is a pressure-release valve
|
||||
for weaker local-model setups. It trims heavyweight default tools like
|
||||
`browser`, `cron`, and `message` so the prompt shape is smaller and less brittle
|
||||
for small-context or stricter OpenAI-compatible backends.
|
||||
|
||||
That is intentionally **not** the normal path. If your backend handles the full
|
||||
runtime cleanly, leave this off.
|
||||
|
||||
## Experimental does not mean hidden
|
||||
|
||||
If a feature is experimental, OpenClaw should say so plainly in docs and in the
|
||||
config path itself. What it should **not** do is smuggle preview behavior into a
|
||||
stable-looking default knob and pretend that is normal. That's how config
|
||||
surfaces get messy.
|
||||
@@ -15,8 +15,9 @@ chunks and searching them using embeddings, keywords, or both.
|
||||
|
||||
## Quick start
|
||||
|
||||
If you have an OpenAI, Gemini, Voyage, or Mistral API key configured, memory
|
||||
search works automatically. To set a provider explicitly:
|
||||
If you have a GitHub Copilot subscription, OpenAI, Gemini, Voyage, or Mistral
|
||||
API key configured, memory search works automatically. To set a provider
|
||||
explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -35,15 +36,16 @@ node-llama-cpp).
|
||||
|
||||
## Supported providers
|
||||
|
||||
| Provider | ID | Needs API key | Notes |
|
||||
| -------- | --------- | ------------- | ---------------------------------------------------- |
|
||||
| OpenAI | `openai` | Yes | Auto-detected, fast |
|
||||
| Gemini | `gemini` | Yes | Supports image/audio indexing |
|
||||
| Voyage | `voyage` | Yes | Auto-detected |
|
||||
| Mistral | `mistral` | Yes | Auto-detected |
|
||||
| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves |
|
||||
| Ollama | `ollama` | No | Local, must set explicitly |
|
||||
| Local | `local` | No | GGUF model, ~0.6 GB download |
|
||||
| Provider | ID | Needs API key | Notes |
|
||||
| -------------- | ---------------- | ------------- | ---------------------------------------------------- |
|
||||
| Bedrock | `bedrock` | No | Auto-detected when the AWS credential chain resolves |
|
||||
| Gemini | `gemini` | Yes | Supports image/audio indexing |
|
||||
| GitHub Copilot | `github-copilot` | No | Auto-detected, uses Copilot subscription |
|
||||
| Local | `local` | No | GGUF model, ~0.6 GB download |
|
||||
| Mistral | `mistral` | Yes | Auto-detected |
|
||||
| Ollama | `ollama` | No | Local, must set explicitly |
|
||||
| OpenAI | `openai` | Yes | Auto-detected, fast |
|
||||
| Voyage | `voyage` | Yes | Auto-detected |
|
||||
|
||||
## How search works
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Your agent has three memory-related files:
|
||||
decisions. Loaded at the start of every DM session.
|
||||
- **`memory/YYYY-MM-DD.md`** -- daily notes. Running context and observations.
|
||||
Today and yesterday's notes are loaded automatically.
|
||||
- **`DREAMS.md`** (experimental, optional) -- Dream Diary and dreaming sweep
|
||||
- **`DREAMS.md`** (optional) -- Dream Diary and dreaming sweep
|
||||
summaries for human review, including grounded historical backfill entries.
|
||||
|
||||
These files live in the agent workspace (default `~/.openclaw/workspace`).
|
||||
@@ -114,7 +114,7 @@ important facts in the conversation that are not yet written to a file, they
|
||||
will be saved automatically before the summary happens.
|
||||
</Tip>
|
||||
|
||||
## Dreaming (experimental)
|
||||
## Dreaming
|
||||
|
||||
Dreaming is an optional background consolidation pass for memory. It collects
|
||||
short-term signals, scores candidates, and promotes only qualified items into
|
||||
@@ -131,7 +131,7 @@ It is designed to keep long-term memory high signal:
|
||||
for human review.
|
||||
|
||||
For phase behavior, scoring signals, and Dream Diary details, see
|
||||
[Dreaming (experimental)](/concepts/dreaming).
|
||||
[Dreaming](/concepts/dreaming).
|
||||
|
||||
## Grounded backfill and live promotion
|
||||
|
||||
@@ -184,7 +184,7 @@ openclaw memory index --force # Rebuild the index
|
||||
- [Memory Wiki](/plugins/memory-wiki) -- compiled knowledge vault and wiki-native tools
|
||||
- [Memory Search](/concepts/memory-search) -- search pipeline, providers, and
|
||||
tuning
|
||||
- [Dreaming (experimental)](/concepts/dreaming) -- background promotion
|
||||
- [Dreaming](/concepts/dreaming) -- background promotion
|
||||
from short-term recall to long-term memory
|
||||
- [Memory configuration reference](/reference/memory-config) -- all config knobs
|
||||
- [Compaction](/concepts/compaction) -- how compaction interacts with memory
|
||||
|
||||
@@ -177,6 +177,19 @@ and the effective agent skill allowlist when `agents.defaults.skills` or
|
||||
|
||||
This keeps the base prompt small while still enabling targeted skill usage.
|
||||
|
||||
The skills list budget is owned by the skills subsystem:
|
||||
|
||||
- Global default: `skills.limits.maxSkillsPromptChars`
|
||||
- Per-agent override: `agents.list[].skillsLimits.maxSkillsPromptChars`
|
||||
|
||||
Generic bounded runtime excerpts use a different surface:
|
||||
|
||||
- `agents.defaults.contextLimits.*`
|
||||
- `agents.list[].contextLimits.*`
|
||||
|
||||
That split keeps skills sizing separate from runtime read/injection sizing such
|
||||
as `memory_get`, live tool results, and post-compaction AGENTS.md refreshes.
|
||||
|
||||
## Documentation
|
||||
|
||||
When available, the system prompt includes a **Documentation** section that points to the
|
||||
|
||||
+2
-1
@@ -1062,7 +1062,8 @@
|
||||
"concepts/agent-workspace",
|
||||
"concepts/soul",
|
||||
"concepts/oauth",
|
||||
"start/bootstrapping"
|
||||
"start/bootstrapping",
|
||||
"concepts/experimental-features"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -988,6 +988,142 @@ Default: `"once"`.
|
||||
}
|
||||
```
|
||||
|
||||
### Context budget ownership map
|
||||
|
||||
OpenClaw has multiple high-volume prompt/context budgets, and they are
|
||||
intentionally split by subsystem instead of all flowing through one generic
|
||||
knob.
|
||||
|
||||
- `agents.defaults.bootstrapMaxChars` /
|
||||
`agents.defaults.bootstrapTotalMaxChars`:
|
||||
normal workspace bootstrap injection.
|
||||
- `agents.defaults.startupContext.*`:
|
||||
one-shot `/new` and `/reset` startup prelude, including recent daily
|
||||
`memory/*.md` files.
|
||||
- `skills.limits.*`:
|
||||
the compact skills list injected into the system prompt.
|
||||
- `agents.defaults.contextLimits.*`:
|
||||
bounded runtime excerpts and injected runtime-owned blocks.
|
||||
- `memory.qmd.limits.*`:
|
||||
indexed memory-search snippet and injection sizing.
|
||||
|
||||
Use the matching per-agent override only when one agent needs a different
|
||||
budget:
|
||||
|
||||
- `agents.list[].skillsLimits.maxSkillsPromptChars`
|
||||
- `agents.list[].contextLimits.*`
|
||||
|
||||
#### `agents.defaults.startupContext`
|
||||
|
||||
Controls the first-turn startup prelude injected on bare `/new` and `/reset`
|
||||
runs.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
startupContext: {
|
||||
enabled: true,
|
||||
applyOn: ["new", "reset"],
|
||||
dailyMemoryDays: 2,
|
||||
maxFileBytes: 16384,
|
||||
maxFileChars: 1200,
|
||||
maxTotalChars: 2800,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `agents.defaults.contextLimits`
|
||||
|
||||
Shared defaults for bounded runtime context surfaces.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
contextLimits: {
|
||||
memoryGetMaxChars: 12000,
|
||||
memoryGetDefaultLines: 120,
|
||||
toolResultMaxChars: 16000,
|
||||
postCompactionMaxChars: 1800,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `memoryGetMaxChars`: default `memory_get` excerpt cap before truncation
|
||||
metadata and continuation notice are added.
|
||||
- `memoryGetDefaultLines`: default `memory_get` line window when `lines` is
|
||||
omitted.
|
||||
- `toolResultMaxChars`: live tool-result cap used for persisted results and
|
||||
overflow recovery.
|
||||
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
|
||||
refresh injection.
|
||||
|
||||
#### `agents.list[].contextLimits`
|
||||
|
||||
Per-agent override for the shared `contextLimits` knobs. Omitted fields inherit
|
||||
from `agents.defaults.contextLimits`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
contextLimits: {
|
||||
memoryGetMaxChars: 12000,
|
||||
toolResultMaxChars: 16000,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "tiny-local",
|
||||
contextLimits: {
|
||||
memoryGetMaxChars: 6000,
|
||||
toolResultMaxChars: 8000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `skills.limits.maxSkillsPromptChars`
|
||||
|
||||
Global cap for the compact skills list injected into the system prompt. This
|
||||
does not affect reading `SKILL.md` files on demand.
|
||||
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
limits: {
|
||||
maxSkillsPromptChars: 18000,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `agents.list[].skillsLimits.maxSkillsPromptChars`
|
||||
|
||||
Per-agent override for the skills prompt budget.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "tiny-local",
|
||||
skillsLimits: {
|
||||
maxSkillsPromptChars: 6000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.imageMaxDimensionPx`
|
||||
|
||||
Max pixel size for the longest image side in transcript/tool image blocks before provider calls.
|
||||
@@ -2764,7 +2900,7 @@ See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM
|
||||
- `plugins.entries.xai.config.xSearch`: xAI X Search (Grok web search) settings.
|
||||
- `enabled`: enable the X Search provider.
|
||||
- `model`: Grok model to use for search (e.g. `"grok-4-1-fast"`).
|
||||
- `plugins.entries.memory-core.config.dreaming`: memory dreaming (experimental) settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
|
||||
- `plugins.entries.memory-core.config.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
|
||||
- `enabled`: master dreaming switch (default `false`).
|
||||
- `frequency`: cron cadence for each full dreaming sweep (`"0 3 * * *"` by default).
|
||||
- phase policy and thresholds are implementation details (not user-facing config keys).
|
||||
|
||||
@@ -164,8 +164,12 @@ Compatibility notes for stricter OpenAI-compatible backends:
|
||||
- Some smaller or stricter local backends are unstable with OpenClaw's full
|
||||
agent-runtime prompt shape, especially when tool schemas are included. If the
|
||||
backend works for tiny direct `/v1/chat/completions` calls but fails on normal
|
||||
OpenClaw agent turns, try
|
||||
`models.providers.<provider>.models[].compat.supportsTools: false` first.
|
||||
OpenClaw agent turns, first try
|
||||
`agents.defaults.experimental.localModelLean: true` to drop heavyweight
|
||||
default tools like `browser`, `cron`, and `message`; this is an experimental
|
||||
flag, not a stable default-mode setting. See
|
||||
[Experimental Features](/concepts/experimental-features). If that still fails, try
|
||||
`models.providers.<provider>.models[].compat.supportsTools: false`.
|
||||
- If the backend still fails only on larger OpenClaw runs, the remaining issue
|
||||
is usually upstream model/server capacity or a backend bug, not OpenClaw's
|
||||
transport layer.
|
||||
|
||||
+27
-13
@@ -67,9 +67,13 @@ These commands sit beside the main test suites when you need QA-lab realism:
|
||||
- Starts the Docker-backed QA site for operator-style QA work.
|
||||
- `pnpm openclaw qa matrix`
|
||||
- Runs the Matrix live QA lane against a disposable Docker-backed Tuwunel homeserver.
|
||||
- This QA host is repo/dev-only today. Packaged OpenClaw installs do not ship
|
||||
`qa-lab`, so they do not expose `openclaw qa`.
|
||||
- Repo checkouts load the bundled runner directly; no separate plugin install
|
||||
step is needed.
|
||||
- Provisions three temporary Matrix users (`driver`, `sut`, `observer`) plus one private room, then starts a QA gateway child with the real Matrix plugin as the SUT transport.
|
||||
- Uses the pinned stable Tuwunel image `ghcr.io/matrix-construct/tuwunel:v1.5.1` by default. Override with `OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE` when you need to test a different image.
|
||||
- Matrix currently supports only `--credential-source env` because the lane provisions disposable users locally.
|
||||
- Matrix does not expose shared credential-source flags because the lane provisions disposable users locally.
|
||||
- Writes a Matrix QA report, summary, and observed-events artifact under `.artifacts/qa-e2e/...`.
|
||||
- `pnpm openclaw qa telegram`
|
||||
- Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env.
|
||||
@@ -170,11 +174,12 @@ Adding a channel to the markdown QA system requires exactly two things:
|
||||
1. A transport adapter for the channel.
|
||||
2. A scenario pack that exercises the channel contract.
|
||||
|
||||
Do not add a channel-specific QA runner when the shared `qa-lab` runner can
|
||||
Do not add a new top-level QA command root when the shared `qa-lab` host can
|
||||
own the flow.
|
||||
|
||||
`qa-lab` owns the shared mechanics:
|
||||
`qa-lab` owns the shared host mechanics:
|
||||
|
||||
- the `openclaw qa` command root
|
||||
- suite startup and teardown
|
||||
- worker concurrency
|
||||
- artifact writing
|
||||
@@ -182,8 +187,9 @@ own the flow.
|
||||
- scenario execution
|
||||
- compatibility aliases for older `qa-channel` scenarios
|
||||
|
||||
The channel adapter owns the transport contract:
|
||||
Runner plugins own the transport contract:
|
||||
|
||||
- how `openclaw qa <runner>` is mounted beneath the shared `qa` root
|
||||
- how the gateway is configured for that transport
|
||||
- how readiness is checked
|
||||
- how inbound events are injected
|
||||
@@ -194,17 +200,20 @@ The channel adapter owns the transport contract:
|
||||
|
||||
The minimum adoption bar for a new channel is:
|
||||
|
||||
1. Implement the transport adapter on the shared `qa-lab` seam.
|
||||
2. Register the adapter in the transport registry.
|
||||
3. Keep transport-specific mechanics inside the adapter or the channel harness.
|
||||
4. Author or adapt markdown scenarios under `qa/scenarios/`.
|
||||
5. Use the generic scenario helpers for new scenarios.
|
||||
6. Keep existing compatibility aliases working unless the repo is doing an intentional migration.
|
||||
1. Keep `qa-lab` as the owner of the shared `qa` root.
|
||||
2. Implement the transport runner on the shared `qa-lab` host seam.
|
||||
3. Keep transport-specific mechanics inside the runner plugin or channel harness.
|
||||
4. Mount the runner as `openclaw qa <runner>` instead of registering a competing root command.
|
||||
Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`.
|
||||
Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints.
|
||||
5. Author or adapt markdown scenarios under `qa/scenarios/`.
|
||||
6. Use the generic scenario helpers for new scenarios.
|
||||
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.
|
||||
|
||||
The decision rule is strict:
|
||||
|
||||
- If behavior can be expressed once in `qa-lab`, put it in `qa-lab`.
|
||||
- If behavior depends on one channel transport, keep it in that adapter or plugin harness.
|
||||
- If behavior depends on one channel transport, keep it in that runner plugin or plugin harness.
|
||||
- If a scenario needs a new capability that more than one channel can use, add a generic helper instead of a channel-specific branch in `suite.ts`.
|
||||
- If a behavior is only meaningful for one transport, keep the scenario transport-specific and make that explicit in the scenario contract.
|
||||
|
||||
@@ -781,11 +790,13 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
- Harness: `pnpm test:live:media video`
|
||||
- Scope:
|
||||
- Exercises the shared bundled video-generation provider path
|
||||
- Defaults to the release-safe smoke path: non-FAL providers, one text-to-video request per provider, one-second lobster prompt, and a per-provider operation cap from `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS` (`180000` by default)
|
||||
- Skips FAL by default because provider-side queue latency can dominate release time; pass `--video-providers fal` or `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="fal"` to run it explicitly
|
||||
- Loads provider env vars from your login shell (`~/.profile`) before probing
|
||||
- Uses live/env API keys ahead of stored auth profiles by default, so stale test keys in `auth-profiles.json` do not mask real shell credentials
|
||||
- Skips providers with no usable auth/profile/model
|
||||
- Runs both declared runtime modes when available:
|
||||
- `generate` with prompt-only input
|
||||
- Runs only `generate` by default
|
||||
- Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` to also run declared transform modes when available:
|
||||
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled` and the selected provider/model accepts buffer-backed local image input in the shared sweep
|
||||
- `videoToVideo` when the provider declares `capabilities.videoToVideo.enabled` and the selected provider/model accepts buffer-backed local video input in the shared sweep
|
||||
- Current declared-but-skipped `imageToVideo` providers in the shared sweep:
|
||||
@@ -802,6 +813,8 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
|
||||
- Optional narrowing:
|
||||
- `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="google,openai,runway"`
|
||||
- `OPENCLAW_LIVE_VIDEO_GENERATION_MODELS="google/veo-3.1-fast-generate-preview,openai/sora-2,runway/gen4_aleph"`
|
||||
- `OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS=""` to include every provider in the default sweep, including FAL
|
||||
- `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS=60000` to reduce each provider operation cap for an aggressive smoke run
|
||||
- Optional auth behavior:
|
||||
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
|
||||
|
||||
@@ -889,6 +902,7 @@ Useful env vars:
|
||||
- `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw`
|
||||
- `OPENCLAW_WORKSPACE_DIR=...` (default: `~/.openclaw/workspace`) mounted to `/home/node/.openclaw/workspace`
|
||||
- `OPENCLAW_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests
|
||||
- `OPENCLAW_DOCKER_PROFILE_ENV_ONLY=1` to verify only env vars sourced from `OPENCLAW_PROFILE_FILE`, using temporary config/workspace dirs and no external CLI auth mounts
|
||||
- `OPENCLAW_DOCKER_CLI_TOOLS_DIR=...` (default: `~/.cache/openclaw/docker-cli-tools`) mounted to `/home/node/.npm-global` for cached CLI installs inside Docker
|
||||
- External CLI auth dirs/files under `$HOME` are mounted read-only under `/host-auth...`, then copied into `/home/node/...` before tests start
|
||||
- Default dirs: `.minimax`
|
||||
|
||||
@@ -173,6 +173,15 @@ For channel plugins, the SDK surface is
|
||||
call lets a plugin return its visible actions, capabilities, and schema
|
||||
contributions together so those pieces do not drift apart.
|
||||
|
||||
When a channel-specific message-tool param carries a media source such as a
|
||||
local path or remote media URL, the plugin should also return
|
||||
`mediaSourceParams` from `describeMessageTool(...)`. Core uses that explicit
|
||||
list to apply sandbox path normalization and outbound media-access hints
|
||||
without hardcoding plugin-owned param names.
|
||||
Prefer action-scoped maps there, not one channel-wide flat list, so a
|
||||
profile-only media param does not get normalized on unrelated actions like
|
||||
`send`.
|
||||
|
||||
Core passes runtime scope into that discovery step. Important fields include:
|
||||
|
||||
- `accountId`
|
||||
|
||||
@@ -56,6 +56,8 @@ Use it for:
|
||||
plugin before runtime loads
|
||||
- static capability ownership snapshots used for bundled compat wiring and
|
||||
contract coverage
|
||||
- cheap QA runner metadata that the shared `openclaw qa` host can inspect
|
||||
before plugin runtime loads
|
||||
- channel-specific config metadata that should merge into catalog and validation
|
||||
surfaces without loading runtime
|
||||
- config UI hints
|
||||
@@ -158,6 +160,7 @@ Those belong in your plugin code and `package.json`.
|
||||
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
|
||||
| `activation` | No | `object` | Cheap activation hints for provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. |
|
||||
| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. |
|
||||
| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. |
|
||||
| `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
|
||||
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
|
||||
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
|
||||
@@ -219,6 +222,29 @@ uses this metadata for diagnostics without importing plugin runtime code.
|
||||
Use `activation` when the plugin can cheaply declare which control-plane events
|
||||
should activate it later.
|
||||
|
||||
## qaRunners reference
|
||||
|
||||
Use `qaRunners` when a plugin contributes one or more transport runners beneath
|
||||
the shared `openclaw qa` root. Keep this metadata cheap and static; the plugin
|
||||
runtime still owns actual CLI registration through a lightweight
|
||||
`runtime-api.ts` surface that exports `qaRunnerCliRegistrations`.
|
||||
|
||||
```json
|
||||
{
|
||||
"qaRunners": [
|
||||
{
|
||||
"commandName": "matrix",
|
||||
"description": "Run the Docker-backed Matrix live QA lane against a disposable homeserver"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Type | What it means |
|
||||
| ------------- | -------- | -------- | ------------------------------------------------------------------ |
|
||||
| `commandName` | Yes | `string` | Subcommand mounted beneath `openclaw qa`, for example `matrix`. |
|
||||
| `description` | No | `string` | Fallback help text used when the shared host needs a stub command. |
|
||||
|
||||
This block is metadata only. It does not register runtime behavior, and it does
|
||||
not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints.
|
||||
Current consumers use it as a narrowing hint before broader plugin loading, so
|
||||
|
||||
@@ -35,6 +35,16 @@ shared `message` tool in core. Your plugin owns:
|
||||
Core owns the shared message tool, prompt wiring, the outer session-key shape,
|
||||
generic `:thread:` bookkeeping, and dispatch.
|
||||
|
||||
If your channel adds message-tool params that carry media sources, expose those
|
||||
param names through `describeMessageTool(...).mediaSourceParams`. Core uses
|
||||
that explicit list for sandbox path normalization and outbound media-access
|
||||
policy, so plugins do not need shared-core special cases for provider-specific
|
||||
avatar, attachment, or cover-image params.
|
||||
Prefer returning an action-keyed map such as
|
||||
`{ "set-profile": ["avatarUrl", "avatarPath"] }` so unrelated actions do not
|
||||
inherit another action's media args. A flat array still works for params that
|
||||
are intentionally shared across every exposed action.
|
||||
|
||||
If your platform stores extra scope inside conversation ids, keep that parsing
|
||||
in the plugin with `messaging.resolveSessionConversation(...)`. That is the
|
||||
canonical hook for mapping `rawId` to the base conversation id, optional thread
|
||||
@@ -483,6 +493,11 @@ should use `resolveInboundMentionDecision({ facts, policy })`.
|
||||
or unconfigured. It avoids pulling in heavy runtime code during setup flows.
|
||||
See [Setup and Config](/plugins/sdk-setup#setup-entry) for details.
|
||||
|
||||
Bundled workspace channels that split setup-safe exports into sidecar
|
||||
modules can use `defineBundledChannelSetupEntry(...)` from
|
||||
`openclaw/plugin-sdk/channel-entry-contract` when they also need an
|
||||
explicit setup-time runtime setter.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Handle inbound messages">
|
||||
|
||||
@@ -145,6 +145,31 @@ families:
|
||||
Keep heavy SDKs, CLI registration, and long-lived runtime services in the full
|
||||
entry.
|
||||
|
||||
Bundled workspace channels that split setup and runtime surfaces can use
|
||||
`defineBundledChannelSetupEntry(...)` from
|
||||
`openclaw/plugin-sdk/channel-entry-contract` instead. That contract lets the
|
||||
setup entry keep setup-safe plugin/secrets exports while still exposing a
|
||||
runtime setter:
|
||||
|
||||
```typescript
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "myChannelPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setMyChannelRuntime",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use that bundled contract only when setup flows truly need a lightweight runtime
|
||||
setter before the full channel entry loads.
|
||||
|
||||
## Registration mode
|
||||
|
||||
`api.registrationMode` tells your plugin how it was loaded:
|
||||
|
||||
@@ -385,7 +385,10 @@ the `register` callback:
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const store = createPluginRuntimeStore<PluginRuntime>("my-plugin runtime not initialized");
|
||||
const store = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "my-plugin",
|
||||
errorMessage: "my-plugin runtime not initialized",
|
||||
});
|
||||
|
||||
// In your entry point
|
||||
export default defineChannelPluginEntry({
|
||||
@@ -406,6 +409,10 @@ export function tryGetRuntime() {
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `pluginId` for the runtime-store identity. The lower-level `key` form is
|
||||
for uncommon cases where one plugin intentionally needs more than one runtime
|
||||
slot.
|
||||
|
||||
## Other top-level `api` fields
|
||||
|
||||
Beyond `api.runtime`, the API object also provides:
|
||||
|
||||
@@ -279,6 +279,12 @@ export default defineSetupPluginEntry(myChannelPlugin);
|
||||
This avoids loading heavy runtime code (crypto libraries, CLI registrations,
|
||||
background services) during setup flows.
|
||||
|
||||
Bundled workspace channels that keep setup-safe exports in sidecar modules can
|
||||
use `defineBundledChannelSetupEntry(...)` from
|
||||
`openclaw/plugin-sdk/channel-entry-contract` instead of
|
||||
`defineSetupPluginEntry(...)`. That bundled contract also supports an optional
|
||||
`runtime` export so setup-time runtime wiring can stay lightweight and explicit.
|
||||
|
||||
**When OpenClaw uses `setupEntry` instead of the full entry:**
|
||||
|
||||
- The channel is disabled but needs setup/onboarding surfaces
|
||||
|
||||
@@ -155,7 +155,10 @@ For code that uses `createPluginRuntimeStore`, mock the runtime in tests:
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const store = createPluginRuntimeStore<PluginRuntime>("test runtime not set");
|
||||
const store = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "test-plugin",
|
||||
errorMessage: "test runtime not set",
|
||||
});
|
||||
|
||||
// In test setup
|
||||
const mockRuntime = {
|
||||
|
||||
@@ -119,6 +119,46 @@ Requires an interactive TTY. Run the login command directly in a terminal, not
|
||||
inside a headless script or CI job.
|
||||
</Warning>
|
||||
|
||||
## Memory search embeddings
|
||||
|
||||
GitHub Copilot can also serve as an embedding provider for
|
||||
[memory search](/concepts/memory-search). If you have a Copilot subscription and
|
||||
have logged in, OpenClaw can use it for embeddings without a separate API key.
|
||||
|
||||
### Auto-detection
|
||||
|
||||
When `memorySearch.provider` is `"auto"` (the default), GitHub Copilot is tried
|
||||
at priority 15 -- after local embeddings but before OpenAI and other paid
|
||||
providers. If a GitHub token is available, OpenClaw discovers available
|
||||
embedding models from the Copilot API and picks the best one automatically.
|
||||
|
||||
### Explicit config
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "github-copilot",
|
||||
// Optional: override the auto-discovered model
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
1. OpenClaw resolves your GitHub token (from env vars or auth profile).
|
||||
2. Exchanges it for a short-lived Copilot API token.
|
||||
3. Queries the Copilot `/models` endpoint to discover available embedding models.
|
||||
4. Picks the best model (prefers `text-embedding-3-small`).
|
||||
5. Sends embedding requests to the Copilot `/embeddings` endpoint.
|
||||
|
||||
Model availability depends on your GitHub plan. If no embedding models are
|
||||
available, OpenClaw skips Copilot and tries the next provider.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
+37
-35
@@ -8,7 +8,7 @@ title: "Ollama"
|
||||
|
||||
# Ollama
|
||||
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supports streaming and tool calling, and can auto-discover local Ollama models when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
OpenClaw integrates with Ollama's native API (`/api/chat`) for hosted cloud models and local/self-hosted Ollama servers. You can use Ollama in three modes: `Cloud + Local` through a reachable Ollama host, `Cloud only` against `https://ollama.com`, or `Local only` against a reachable Ollama host.
|
||||
|
||||
<Warning>
|
||||
**Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`).
|
||||
@@ -20,7 +20,7 @@ Choose your preferred setup method and mode.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Onboarding (recommended)">
|
||||
**Best for:** fastest path to a working Ollama setup with automatic model discovery.
|
||||
**Best for:** fastest path to a working Ollama cloud or local setup.
|
||||
|
||||
<Steps>
|
||||
<Step title="Run onboarding">
|
||||
@@ -31,13 +31,12 @@ Choose your preferred setup method and mode.
|
||||
Select **Ollama** from the provider list.
|
||||
</Step>
|
||||
<Step title="Choose your mode">
|
||||
- **Cloud + Local** — cloud-hosted models and local models together
|
||||
- **Local** — local models only
|
||||
|
||||
If you choose **Cloud + Local** and are not signed in to ollama.com, onboarding opens a browser sign-in flow.
|
||||
- **Cloud + Local** — local Ollama host plus cloud models routed through that host
|
||||
- **Cloud only** — hosted Ollama models via `https://ollama.com`
|
||||
- **Local only** — local models only
|
||||
</Step>
|
||||
<Step title="Select a model">
|
||||
Onboarding discovers available models and suggests defaults. It auto-pulls the selected model if it is not available locally.
|
||||
`Cloud only` prompts for `OLLAMA_API_KEY` and suggests hosted cloud defaults. `Cloud + Local` and `Local only` ask for an Ollama base URL, discover available models, and auto-pull the selected local model if it is not available yet. `Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
|
||||
</Step>
|
||||
<Step title="Verify the model is available">
|
||||
```bash
|
||||
@@ -67,13 +66,15 @@ Choose your preferred setup method and mode.
|
||||
</Tab>
|
||||
|
||||
<Tab title="Manual setup">
|
||||
**Best for:** full control over installation, model pulls, and config.
|
||||
**Best for:** full control over cloud or local setup.
|
||||
|
||||
<Steps>
|
||||
<Step title="Install Ollama">
|
||||
Download from [ollama.com/download](https://ollama.com/download).
|
||||
<Step title="Choose cloud or local">
|
||||
- **Cloud + Local**: install Ollama, sign in with `ollama signin`, and route cloud requests through that host
|
||||
- **Cloud only**: use `https://ollama.com` with an `OLLAMA_API_KEY`
|
||||
- **Local only**: install Ollama from [ollama.com/download](https://ollama.com/download)
|
||||
</Step>
|
||||
<Step title="Pull a local model">
|
||||
<Step title="Pull a local model (local only)">
|
||||
```bash
|
||||
ollama pull gemma4
|
||||
# or
|
||||
@@ -82,22 +83,18 @@ Choose your preferred setup method and mode.
|
||||
ollama pull llama3.3
|
||||
```
|
||||
</Step>
|
||||
<Step title="Sign in for cloud models (optional)">
|
||||
If you want cloud models too:
|
||||
|
||||
```bash
|
||||
ollama signin
|
||||
```
|
||||
</Step>
|
||||
<Step title="Enable Ollama for OpenClaw">
|
||||
Set any value for the API key (Ollama does not require a real key):
|
||||
For `Cloud only`, use your real `OLLAMA_API_KEY`. For host-backed setups, any placeholder value works:
|
||||
|
||||
```bash
|
||||
# Set environment variable
|
||||
# Cloud
|
||||
export OLLAMA_API_KEY="your-ollama-api-key"
|
||||
|
||||
# Local-only
|
||||
export OLLAMA_API_KEY="ollama-local"
|
||||
|
||||
# Or configure in your config file
|
||||
openclaw config set models.providers.ollama.apiKey "ollama-local"
|
||||
openclaw config set models.providers.ollama.apiKey "OLLAMA_API_KEY"
|
||||
```
|
||||
</Step>
|
||||
<Step title="Inspect and set your model">
|
||||
@@ -127,18 +124,23 @@ Choose your preferred setup method and mode.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Cloud + Local">
|
||||
Cloud models let you run cloud-hosted models alongside your local models. Examples include `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` -- these do **not** require a local `ollama pull`.
|
||||
`Cloud + Local` uses a reachable Ollama host as the control point for both local and cloud models. This is Ollama's preferred hybrid flow.
|
||||
|
||||
Select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
|
||||
Use **Cloud + Local** during setup. OpenClaw prompts for the Ollama base URL, discovers local models from that host, and checks whether the host is signed in for cloud access with `ollama signin`. When the host is signed in, OpenClaw also suggests hosted cloud defaults such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud`.
|
||||
|
||||
You can also sign in directly at [ollama.com/signin](https://ollama.com/signin).
|
||||
If the host is not signed in yet, OpenClaw keeps the setup local-only until you run `ollama signin`.
|
||||
|
||||
OpenClaw currently suggests these cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`.
|
||||
</Tab>
|
||||
|
||||
<Tab title="Cloud only">
|
||||
`Cloud only` runs against Ollama's hosted API at `https://ollama.com`.
|
||||
|
||||
Use **Cloud only** during setup. OpenClaw prompts for `OLLAMA_API_KEY`, sets `baseUrl: "https://ollama.com"`, and seeds the hosted cloud model list. This path does **not** require a local Ollama server or `ollama signin`.
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Local only">
|
||||
In local-only mode, OpenClaw discovers models from the local Ollama instance. No cloud sign-in is needed.
|
||||
In local-only mode, OpenClaw discovers models from the configured Ollama instance. This path is for local or self-hosted Ollama servers.
|
||||
|
||||
OpenClaw currently suggests `gemma4` as the local default.
|
||||
|
||||
@@ -182,7 +184,7 @@ If you set `models.providers.ollama` explicitly, auto-discovery is skipped and y
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Basic (implicit discovery)">
|
||||
The simplest way to enable Ollama is via environment variable:
|
||||
The simplest local-only enablement path is via environment variable:
|
||||
|
||||
```bash
|
||||
export OLLAMA_API_KEY="ollama-local"
|
||||
@@ -195,25 +197,25 @@ If you set `models.providers.ollama` explicitly, auto-discovery is skipped and y
|
||||
</Tab>
|
||||
|
||||
<Tab title="Explicit (manual models)">
|
||||
Use explicit config when Ollama runs on another host/port, you want to force specific context windows or model lists, or you want fully manual model definitions.
|
||||
Use explicit config when you want hosted cloud setup, Ollama runs on another host/port, you want to force specific context windows or model lists, or you want fully manual model definitions.
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
apiKey: "ollama-local",
|
||||
baseUrl: "https://ollama.com",
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
api: "ollama",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
id: "kimi-k2.5:cloud",
|
||||
name: "kimi-k2.5:cloud",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 8192 * 10
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+18
-25
@@ -43,6 +43,11 @@ OpenClaw has three public release lanes:
|
||||
- Run `pnpm release:check` before every tagged release
|
||||
- Release checks now run in a separate manual workflow:
|
||||
`OpenClaw Release Checks`
|
||||
- Cross-OS install and upgrade runtime validation is dispatched from the
|
||||
private caller workflow
|
||||
`openclaw/releases-private/.github/workflows/openclaw-cross-os-release-checks.yml`,
|
||||
which invokes the reusable public workflow
|
||||
`.github/workflows/openclaw-cross-os-release-checks-reusable.yml`
|
||||
- This split is intentional: keep the real npm release path short,
|
||||
deterministic, and artifact-focused, while slower live checks stay in their
|
||||
own lane so they do not stall or block publish
|
||||
@@ -74,10 +79,10 @@ OpenClaw has three public release lanes:
|
||||
- real npm publish must pass a successful npm `preflight_run_id`
|
||||
- stable npm releases default to `beta`
|
||||
- stable npm publish can target `latest` explicitly via workflow input
|
||||
- stable npm promotion from `beta` to `latest` is still available as an explicit manual mode on the trusted `OpenClaw NPM Release` workflow
|
||||
- direct stable publishes can also run an explicit dist-tag sync mode that
|
||||
points both `latest` and `beta` at the already-published stable version
|
||||
- those dist-tag modes still need a valid `NPM_TOKEN` in the `npm-release` environment because npm `dist-tag` management is separate from trusted publishing
|
||||
- token-based npm dist-tag mutation now lives in
|
||||
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
|
||||
for security, because `npm dist-tag add` still needs `NPM_TOKEN` while the
|
||||
public repo keeps OIDC-only publish
|
||||
- public `macOS Release` is validation-only
|
||||
- real private mac publish must pass successful private mac
|
||||
`preflight_run_id` and `validate_run_id`
|
||||
@@ -116,10 +121,6 @@ OpenClaw has three public release lanes:
|
||||
- `preflight_run_id`: required on the real publish path so the workflow reuses
|
||||
the prepared tarball from the successful preflight run
|
||||
- `npm_dist_tag`: npm target tag for the publish path; defaults to `beta`
|
||||
- `promote_beta_to_latest`: `true` to skip publish and move an already-published
|
||||
stable `beta` build onto `latest`
|
||||
- `sync_stable_dist_tags`: `true` to skip publish and point both `latest` and
|
||||
`beta` at an already-published stable version
|
||||
|
||||
`OpenClaw Release Checks` accepts these operator-controlled inputs:
|
||||
|
||||
@@ -134,14 +135,6 @@ Rules:
|
||||
- Release checks commit-SHA mode also requires the current `origin/main` HEAD
|
||||
- The real publish path must use the same `npm_dist_tag` used during preflight;
|
||||
the workflow verifies that metadata before publish continues
|
||||
- Promotion mode must use a stable or correction tag, `preflight_only=false`,
|
||||
an empty `preflight_run_id`, and `npm_dist_tag=beta`
|
||||
- Dist-tag sync mode must use a stable or correction tag,
|
||||
`preflight_only=false`, an empty `preflight_run_id`, `npm_dist_tag=latest`,
|
||||
and `promote_beta_to_latest=false`
|
||||
- Promotion and dist-tag sync modes also require a valid `NPM_TOKEN` because
|
||||
`npm dist-tag add` still needs regular npm auth; trusted publishing covers
|
||||
the package publish path only
|
||||
|
||||
## Stable npm release sequence
|
||||
|
||||
@@ -159,17 +152,16 @@ When cutting a stable npm release:
|
||||
4. Save the successful `preflight_run_id`
|
||||
5. Run `OpenClaw NPM Release` again with `preflight_only=false`, the same
|
||||
`tag`, the same `npm_dist_tag`, and the saved `preflight_run_id`
|
||||
6. If the release landed on `beta`, run `OpenClaw NPM Release` later with the
|
||||
same stable `tag`, `promote_beta_to_latest=true`, `preflight_only=false`,
|
||||
`preflight_run_id` empty, and `npm_dist_tag=beta` when you want to move that
|
||||
published build to `latest`
|
||||
6. If the release landed on `beta`, use the private
|
||||
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
|
||||
workflow to promote that stable version from `beta` to `latest`
|
||||
7. If the release intentionally published directly to `latest` and `beta`
|
||||
should follow the same stable build, run `OpenClaw NPM Release` with the same
|
||||
stable `tag`, `sync_stable_dist_tags=true`, `promote_beta_to_latest=false`,
|
||||
`preflight_only=false`, `preflight_run_id` empty, and `npm_dist_tag=latest`
|
||||
should follow the same stable build immediately, use that same private
|
||||
workflow to point both dist-tags at the stable version, or let its scheduled
|
||||
self-healing sync move `beta` later
|
||||
|
||||
The promotion and dist-tag sync modes still require the `npm-release`
|
||||
environment approval and a valid `NPM_TOKEN` accessible to that workflow run.
|
||||
The dist-tag mutation lives in the private repo for security because it still
|
||||
requires `NPM_TOKEN`, while the public repo keeps OIDC-only publish.
|
||||
|
||||
That keeps the direct publish path and the beta-first promotion path both
|
||||
documented and operator-visible.
|
||||
@@ -178,6 +170,7 @@ documented and operator-visible.
|
||||
|
||||
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
|
||||
- [`.github/workflows/openclaw-release-checks.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-release-checks.yml)
|
||||
- [`.github/workflows/openclaw-cross-os-release-checks-reusable.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-cross-os-release-checks-reusable.yml)
|
||||
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
|
||||
- [`scripts/package-mac-dist.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/package-mac-dist.sh)
|
||||
- [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)
|
||||
|
||||
@@ -37,23 +37,24 @@ plugin-owned config, transcript persistence, and safe rollout pattern.
|
||||
|
||||
## Provider selection
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ---------- | --------- | ---------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `provider` | `string` | auto-detected | Embedding adapter ID: `openai`, `gemini`, `voyage`, `mistral`, `bedrock`, `ollama`, `local` |
|
||||
| `model` | `string` | provider default | Embedding model name |
|
||||
| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails |
|
||||
| `enabled` | `boolean` | `true` | Enable or disable memory search |
|
||||
| Key | Type | Default | Description |
|
||||
| ---------- | --------- | ---------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | `string` | auto-detected | Embedding adapter ID: `bedrock`, `gemini`, `github-copilot`, `local`, `mistral`, `ollama`, `openai`, `voyage` |
|
||||
| `model` | `string` | provider default | Embedding model name |
|
||||
| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails |
|
||||
| `enabled` | `boolean` | `true` | Enable or disable memory search |
|
||||
|
||||
### Auto-detection order
|
||||
|
||||
When `provider` is not set, OpenClaw selects the first available:
|
||||
|
||||
1. `local` -- if `memorySearch.local.modelPath` is configured and the file exists.
|
||||
2. `openai` -- if an OpenAI key can be resolved.
|
||||
3. `gemini` -- if a Gemini key can be resolved.
|
||||
4. `voyage` -- if a Voyage key can be resolved.
|
||||
5. `mistral` -- if a Mistral key can be resolved.
|
||||
6. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
|
||||
2. `github-copilot` -- if a GitHub Copilot token can be resolved (env var or auth profile).
|
||||
3. `openai` -- if an OpenAI key can be resolved.
|
||||
4. `gemini` -- if a Gemini key can be resolved.
|
||||
5. `voyage` -- if a Voyage key can be resolved.
|
||||
6. `mistral` -- if a Mistral key can be resolved.
|
||||
7. `bedrock` -- if the AWS SDK credential chain resolves (instance role, access keys, profile, SSO, web identity, or shared config).
|
||||
|
||||
`ollama` is supported but not auto-detected (set it explicitly).
|
||||
|
||||
@@ -62,14 +63,15 @@ When `provider` is not set, OpenClaw selects the first available:
|
||||
Remote embeddings require an API key. Bedrock uses the AWS SDK default
|
||||
credential chain instead (instance roles, SSO, access keys).
|
||||
|
||||
| Provider | Env var | Config key |
|
||||
| -------- | ------------------------------ | --------------------------------- |
|
||||
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
|
||||
| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` |
|
||||
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
|
||||
| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` |
|
||||
| Bedrock | AWS credential chain | No API key needed |
|
||||
| Ollama | `OLLAMA_API_KEY` (placeholder) | -- |
|
||||
| Provider | Env var | Config key |
|
||||
| -------------- | -------------------------------------------------- | --------------------------------- |
|
||||
| Bedrock | AWS credential chain | No API key needed |
|
||||
| Gemini | `GEMINI_API_KEY` | `models.providers.google.apiKey` |
|
||||
| GitHub Copilot | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | Auth profile via device login |
|
||||
| Mistral | `MISTRAL_API_KEY` | `models.providers.mistral.apiKey` |
|
||||
| Ollama | `OLLAMA_API_KEY` (placeholder) | -- |
|
||||
| OpenAI | `OPENAI_API_KEY` | `models.providers.openai.apiKey` |
|
||||
| Voyage | `VOYAGE_API_KEY` | `models.providers.voyage.apiKey` |
|
||||
|
||||
Codex OAuth covers chat/completions only and does not satisfy embedding
|
||||
requests.
|
||||
@@ -477,7 +479,7 @@ Default is DM-only. `match.keyPrefix` matches the normalized session key;
|
||||
|
||||
---
|
||||
|
||||
## Dreaming (experimental)
|
||||
## Dreaming
|
||||
|
||||
Dreaming is configured under `plugins.entries.memory-core.config.dreaming`,
|
||||
not under `agents.defaults.memorySearch`.
|
||||
|
||||
@@ -42,6 +42,7 @@ Scope intent:
|
||||
- `messages.tts.providers.*.apiKey`
|
||||
- `tools.web.fetch.firecrawl.apiKey`
|
||||
- `plugins.entries.brave.config.webSearch.apiKey`
|
||||
- `plugins.entries.exa.config.webSearch.apiKey`
|
||||
- `plugins.entries.google.config.webSearch.apiKey`
|
||||
- `plugins.entries.xai.config.webSearch.apiKey`
|
||||
- `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
|
||||
@@ -526,6 +526,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.exa.config.webSearch.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.exa.config.webSearch.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -16,9 +16,12 @@ OpenAI-style models average ~4 characters per token for English text.
|
||||
OpenClaw assembles its own system prompt on every run. It includes:
|
||||
|
||||
- Tool list + short descriptions
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`)
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`).
|
||||
The compact skills block is bounded by `skills.limits.maxSkillsPromptChars`,
|
||||
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 or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `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 or `memory.md` as a lowercase fallback). 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`.
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
@@ -36,6 +39,18 @@ Everything the model receives counts toward the context limit:
|
||||
- Compaction summaries and pruning artifacts
|
||||
- Provider wrappers or safety headers (not visible, but still counted)
|
||||
|
||||
Some runtime-heavy surfaces have their own explicit caps:
|
||||
|
||||
- `agents.defaults.contextLimits.memoryGetMaxChars`
|
||||
- `agents.defaults.contextLimits.memoryGetDefaultLines`
|
||||
- `agents.defaults.contextLimits.toolResultMaxChars`
|
||||
- `agents.defaults.contextLimits.postCompactionMaxChars`
|
||||
|
||||
Per-agent overrides live under `agents.list[].contextLimits`. These knobs are
|
||||
for bounded runtime excerpts and injected runtime-owned blocks. They are
|
||||
separate from bootstrap limits, startup-context limits, and skills prompt
|
||||
limits.
|
||||
|
||||
For images, OpenClaw downscales transcript/tool image payloads before provider calls.
|
||||
Use `agents.defaults.imageMaxDimensionPx` (default: `1200`) to tune this:
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
|
||||
- Sets `agents.defaults.model` to `openai/gpt-5.4` when model is unset, `openai/*`, or `openai-codex/*`.
|
||||
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
|
||||
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
|
||||
- **Ollama**: prompts for the Ollama base URL, offers **Cloud + Local** or **Local** mode, discovers available models, and auto-pulls the selected local model when needed.
|
||||
- **Ollama**: offers **Cloud + Local**, **Cloud only**, or **Local only** first. `Cloud only` prompts for `OLLAMA_API_KEY` and uses `https://ollama.com`; the host-backed modes prompt for the Ollama base URL, discover available models, and auto-pull the selected local model when needed; `Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
|
||||
- More detail: [Ollama](/providers/ollama)
|
||||
- **API key**: stores the key for you.
|
||||
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
|
||||
|
||||
+131
-76
@@ -1,90 +1,117 @@
|
||||
---
|
||||
title: "Showcase"
|
||||
description: "Real-world OpenClaw projects from the community"
|
||||
summary: "Community-built projects and integrations powered by OpenClaw"
|
||||
read_when:
|
||||
- Looking for real OpenClaw usage examples
|
||||
- Updating community project highlights
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
|
||||
# Showcase
|
||||
|
||||
Real projects from the community. See what people are building with OpenClaw.
|
||||
<div className="showcase-hero">
|
||||
<p className="showcase-kicker">Built in chats, terminals, browsers, and living rooms</p>
|
||||
<p className="showcase-lead">
|
||||
OpenClaw projects are not toy demos. People are shipping PR review loops, mobile apps, home automation,
|
||||
voice systems, devtools, and memory-heavy workflows from the channels they already use.
|
||||
</p>
|
||||
<div className="showcase-actions">
|
||||
<a href="#videos">Watch demos</a>
|
||||
<a href="#fresh-from-discord">Browse projects</a>
|
||||
<a href="https://discord.gg/clawd">Share yours</a>
|
||||
</div>
|
||||
<div className="showcase-highlights">
|
||||
<div className="showcase-highlight">
|
||||
<strong>Chat-native builds</strong>
|
||||
<span>Telegram, WhatsApp, Discord, Beeper, web chat, and terminal-first workflows.</span>
|
||||
</div>
|
||||
<div className="showcase-highlight">
|
||||
<strong>Real automation</strong>
|
||||
<span>Booking, shopping, support, reporting, and browser control without waiting for an API.</span>
|
||||
</div>
|
||||
<div className="showcase-highlight">
|
||||
<strong>Local + physical world</strong>
|
||||
<span>Printers, vacuums, cameras, health data, home systems, and personal knowledge bases.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Info>
|
||||
**Want to be featured?** Share your project in [#self-promotion on Discord](https://discord.gg/clawd) or [tag @openclaw on X](https://x.com/openclaw).
|
||||
</Info>
|
||||
|
||||
## 🎥 OpenClaw in Action
|
||||
|
||||
Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
paddingBottom: "56.25%",
|
||||
height: 0,
|
||||
overflow: "hidden",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/SaWSPZoPX34"
|
||||
title="OpenClaw: The self-hosted AI that Siri should have been (Full setup)"
|
||||
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div className="showcase-jump-links">
|
||||
<a href="#videos">Videos</a>
|
||||
<a href="#fresh-from-discord">Fresh from Discord</a>
|
||||
<a href="#automation-workflows">Automation</a>
|
||||
<a href="#knowledge-memory">Memory</a>
|
||||
<a href="#voice-phone">Voice & Phone</a>
|
||||
<a href="#infrastructure-deployment">Infrastructure</a>
|
||||
<a href="#home-hardware">Home & Hardware</a>
|
||||
<a href="#community-projects">Community</a>
|
||||
<a href="#submit-your-project">Submit a project</a>
|
||||
</div>
|
||||
|
||||
[Watch on YouTube](https://www.youtube.com/watch?v=SaWSPZoPX34)
|
||||
<h2 id="videos">Videos</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
paddingBottom: "56.25%",
|
||||
height: 0,
|
||||
overflow: "hidden",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/mMSKQvlmFuQ"
|
||||
title="OpenClaw showcase video"
|
||||
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
<p className="showcase-section-intro">
|
||||
Start here if you want the shortest path from “what is this?” to “okay, I get it.”
|
||||
</p>
|
||||
|
||||
<div className="showcase-video-grid">
|
||||
<div className="showcase-video-card">
|
||||
<div className="showcase-video-shell">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/SaWSPZoPX34"
|
||||
title="OpenClaw: The self-hosted AI that Siri should have been (Full setup)"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<h3>Full setup walkthrough</h3>
|
||||
<p>VelvetShark, 28 minutes. Install, onboard, and get to a first working assistant end to end.</p>
|
||||
<a href="https://www.youtube.com/watch?v=SaWSPZoPX34">Watch on YouTube</a>
|
||||
</div>
|
||||
|
||||
<div className="showcase-video-card">
|
||||
<div className="showcase-video-shell">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/mMSKQvlmFuQ"
|
||||
title="OpenClaw showcase video"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<h3>Community showcase reel</h3>
|
||||
<p>A faster pass across real projects, surfaces, and workflows built around OpenClaw.</p>
|
||||
<a href="https://www.youtube.com/watch?v=mMSKQvlmFuQ">Watch on YouTube</a>
|
||||
</div>
|
||||
|
||||
<div className="showcase-video-card">
|
||||
<div className="showcase-video-shell">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/5kkIJNUGFho"
|
||||
title="OpenClaw community showcase"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<h3>Projects in the wild</h3>
|
||||
<p>Examples from the community, from chat-native coding loops to hardware and personal automation.</p>
|
||||
<a href="https://www.youtube.com/watch?v=5kkIJNUGFho">Watch on YouTube</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
[Watch on YouTube](https://www.youtube.com/watch?v=mMSKQvlmFuQ)
|
||||
<h2 id="fresh-from-discord">Fresh from Discord</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
paddingBottom: "56.25%",
|
||||
height: 0,
|
||||
overflow: "hidden",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/5kkIJNUGFho"
|
||||
title="OpenClaw community showcase"
|
||||
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
[Watch on YouTube](https://www.youtube.com/watch?v=5kkIJNUGFho)
|
||||
|
||||
## 🆕 Fresh from Discord
|
||||
<p className="showcase-section-intro">
|
||||
Recent standouts across coding, devtools, mobile, and chat-native product building.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -160,7 +187,7 @@ Real-time departures, disruptions, elevator status, and routing for Vienna's pub
|
||||
<img src="/assets/showcase/wienerlinien.png" alt="Wiener Linien skill on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="ParentPay School Meals" icon="utensils" href="#">
|
||||
<Card title="ParentPay School Meals" icon="utensils">
|
||||
**@George5562** • `automation` `browser` `parenting`
|
||||
|
||||
Automated UK school meal booking via ParentPay. Uses mouse coordinates for reliable table cell clicking.
|
||||
@@ -172,7 +199,7 @@ Automated UK school meal booking via ParentPay. Uses mouse coordinates for relia
|
||||
Upload to Cloudflare R2/S3 and generate secure presigned download links. Perfect for remote OpenClaw instances.
|
||||
</Card>
|
||||
|
||||
<Card title="iOS App via Telegram" icon="mobile" href="#">
|
||||
<Card title="iOS App via Telegram" icon="mobile">
|
||||
**@coard** • `ios` `xcode` `testflight`
|
||||
|
||||
Built a complete iOS app with maps and voice recording, deployed to TestFlight entirely via Telegram chat.
|
||||
@@ -180,7 +207,7 @@ Built a complete iOS app with maps and voice recording, deployed to TestFlight e
|
||||
<img src="/assets/showcase/ios-testflight.jpg" alt="iOS app on TestFlight" />
|
||||
</Card>
|
||||
|
||||
<Card title="Oura Ring Health Assistant" icon="heart-pulse" href="#">
|
||||
<Card title="Oura Ring Health Assistant" icon="heart-pulse">
|
||||
**@AS** • `health` `oura` `calendar`
|
||||
|
||||
Personal AI health assistant integrating Oura ring data with calendar, appointments, and gym schedule.
|
||||
@@ -207,7 +234,11 @@ Read, send, and archive messages via Beeper Desktop. Uses Beeper local MCP API s
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🤖 Automation & Workflows
|
||||
<h2 id="automation-workflows">Automation & Workflows</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
Scheduling, browser control, support loops, and the “just do the task for me” side of the product.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -285,7 +316,11 @@ Watches company Slack channel, responds helpfully, and forwards notifications to
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🧠 Knowledge & Memory
|
||||
<h2 id="knowledge-memory">Knowledge & Memory</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
Systems that index, search, remember, and reason over personal or team knowledge.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -317,7 +352,11 @@ Watches company Slack channel, responds helpfully, and forwards notifications to
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🎙️ Voice & Phone
|
||||
<h2 id="voice-phone">Voice & Phone</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
Speech-first entry points, phone bridges, and transcription-heavy workflows.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -335,7 +374,11 @@ Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on Cla
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🏗️ Infrastructure & Deployment
|
||||
<h2 id="infrastructure-deployment">Infrastructure & Deployment</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
Packaging, deployment, and integrations that make OpenClaw easier to run and extend.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -365,7 +408,11 @@ Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on Cla
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🏠 Home & Hardware
|
||||
<h2 id="home-hardware">Home & Hardware</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
The physical-world side of OpenClaw: homes, sensors, cameras, vacuums, and other devices.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -387,7 +434,11 @@ Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on Cla
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🌟 Community Projects
|
||||
<h2 id="community-projects">Community Projects</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
Things that grew beyond a single workflow into broader products or ecosystems.
|
||||
</p>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -401,7 +452,11 @@ Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on Cla
|
||||
|
||||
---
|
||||
|
||||
## Submit Your Project
|
||||
<h2 id="submit-your-project">Submit Your Project</h2>
|
||||
|
||||
<p className="showcase-section-intro">
|
||||
If you are building something interesting with OpenClaw, send it over. Strong screenshots and concrete outcomes help.
|
||||
</p>
|
||||
|
||||
Have something to share? We'd love to feature it!
|
||||
|
||||
|
||||
@@ -181,8 +181,10 @@ What you set:
|
||||
More detail: [Synthetic](/providers/synthetic).
|
||||
</Accordion>
|
||||
<Accordion title="Ollama (Cloud and local open models)">
|
||||
Prompts for base URL (default `http://127.0.0.1:11434`), then offers Cloud + Local or Local mode.
|
||||
Discovers available models and suggests defaults.
|
||||
Prompts for `Cloud + Local`, `Cloud only`, or `Local only` first.
|
||||
`Cloud only` uses `OLLAMA_API_KEY` with `https://ollama.com`.
|
||||
The host-backed modes prompt for base URL (default `http://127.0.0.1:11434`), discover available models, and suggest defaults.
|
||||
`Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
|
||||
More detail: [Ollama](/providers/ollama).
|
||||
</Accordion>
|
||||
<Accordion title="Moonshot and Kimi Coding">
|
||||
|
||||
+144
@@ -35,3 +35,147 @@ html.dark .nav-tabs-underline {
|
||||
.nav-tabs-underline-ready .nav-tabs-underline {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showcase-hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin: 8px 0 22px;
|
||||
padding: clamp(18px, 3vw, 30px);
|
||||
border: 1px solid color-mix(in oklab, rgb(var(--primary)) 24%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, rgb(var(--primary)) 5%, transparent);
|
||||
box-shadow: 0 18px 48px -34px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.showcase-kicker {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.showcase-lead {
|
||||
margin: 0;
|
||||
max-width: 48rem;
|
||||
font-size: clamp(18px, 2vw, 23px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.showcase-actions,
|
||||
.showcase-jump-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.showcase-actions a,
|
||||
.showcase-jump-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid color-mix(in oklab, rgb(var(--primary)) 24%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 24%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, rgb(var(--primary)) 4%, transparent);
|
||||
text-decoration: none;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.showcase-actions a:first-child {
|
||||
background: color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
border-color: color-mix(in oklab, rgb(var(--primary)) 36%, transparent);
|
||||
}
|
||||
|
||||
.showcase-actions a:hover,
|
||||
.showcase-jump-links a:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklab, rgb(var(--primary)) 46%, transparent);
|
||||
}
|
||||
|
||||
.showcase-highlights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.showcase-highlight,
|
||||
.showcase-video-card {
|
||||
border: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, rgb(var(--primary)) 3%, transparent);
|
||||
}
|
||||
|
||||
.showcase-highlight {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.showcase-highlight strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.showcase-highlight span,
|
||||
.showcase-section-intro,
|
||||
.showcase-video-card p {
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.showcase-jump-links {
|
||||
margin: 18px 0 28px;
|
||||
}
|
||||
|
||||
.showcase-section-intro {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.showcase-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.showcase-video-card {
|
||||
padding: 14px;
|
||||
box-shadow: 0 18px 44px -32px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.showcase-video-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.showcase-video-card p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.showcase-video-card a {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.showcase-video-shell {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 56.25%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.showcase-video-shell iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.showcase-highlights,
|
||||
.showcase-video-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,10 +316,23 @@ pnpm test:live:media video
|
||||
```
|
||||
|
||||
This live file loads missing provider env vars from `~/.profile`, prefers
|
||||
live/env API keys ahead of stored auth profiles by default, and runs the
|
||||
declared modes it can exercise safely with local media:
|
||||
live/env API keys ahead of stored auth profiles by default, and runs a
|
||||
release-safe smoke by default:
|
||||
|
||||
- `generate` for every non-FAL provider in the sweep
|
||||
- one-second lobster prompt
|
||||
- per-provider operation cap from `OPENCLAW_LIVE_VIDEO_GENERATION_TIMEOUT_MS`
|
||||
(`180000` by default)
|
||||
|
||||
FAL is opt-in because provider-side queue latency can dominate release time:
|
||||
|
||||
```bash
|
||||
pnpm test:live:media video --video-providers fal
|
||||
```
|
||||
|
||||
Set `OPENCLAW_LIVE_VIDEO_GENERATION_FULL_MODES=1` to also run declared transform
|
||||
modes the shared sweep can exercise safely with local media:
|
||||
|
||||
- `generate` for every provider in the sweep
|
||||
- `imageToVideo` when `capabilities.imageToVideo.enabled`
|
||||
- `videoToVideo` when `capabilities.videoToVideo.enabled` and the provider/model
|
||||
accepts buffer-backed local video input in the shared sweep
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
|
||||
normalizeProviderId,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
@@ -74,9 +74,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
|
||||
/ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i,
|
||||
/ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i,
|
||||
] as const;
|
||||
const anthropicByModelReplayHooks = buildProviderReplayFamilyHooks({
|
||||
family: "anthropic-by-model",
|
||||
});
|
||||
const anthropicByModelReplayHooks = ANTHROPIC_BY_MODEL_REPLAY_HOOKS;
|
||||
const pluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
|
||||
const guardrail = pluginConfig.guardrail;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildNativeAnthropicReplayPolicyForModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { NATIVE_ANTHROPIC_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
mergeImplicitAnthropicVertexProvider,
|
||||
resolveAnthropicVertexConfigApiKey,
|
||||
@@ -36,7 +36,7 @@ export default definePluginEntry({
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
|
||||
buildReplayPolicy: ({ modelId }) => buildNativeAnthropicReplayPolicyForModel(modelId),
|
||||
...NATIVE_ANTHROPIC_REPLAY_HOOKS,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -52,13 +52,6 @@ const ANTHROPIC_MODERN_MODEL_PREFIXES = [
|
||||
"claude-sonnet-4-5",
|
||||
"claude-haiku-4-5",
|
||||
] as const;
|
||||
const _ANTHROPIC_OAUTH_ALLOWLIST = [
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5",
|
||||
] as const;
|
||||
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
|
||||
"Anthropic setup-token auth is supported in OpenClaw.",
|
||||
"OpenClaw prefers Claude CLI reuse when it is available on the host.",
|
||||
@@ -380,13 +373,6 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = "anthropic/claude-sonnet-4-6";
|
||||
const _anthropicOauthAllowlist = [
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5",
|
||||
] as const;
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type {
|
||||
ProviderReplayPolicy,
|
||||
ProviderReplayPolicyContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildNativeAnthropicReplayPolicyForModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { NATIVE_ANTHROPIC_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
/**
|
||||
* Returns the provider-owned replay policy for Anthropic transports.
|
||||
*/
|
||||
export function buildAnthropicReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
|
||||
return buildNativeAnthropicReplayPolicyForModel(ctx.modelId);
|
||||
const { buildReplayPolicy } = NATIVE_ANTHROPIC_REPLAY_HOOKS;
|
||||
|
||||
if (!buildReplayPolicy) {
|
||||
throw new Error("Expected native Anthropic replay hooks to expose buildReplayPolicy.");
|
||||
}
|
||||
|
||||
export { buildReplayPolicy as buildAnthropicReplayPolicy };
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
readConfiguredProviderCatalogEntries,
|
||||
type ProviderCatalogContext,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
applyArceeConfig,
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
} from "./provider-catalog.js";
|
||||
|
||||
const PROVIDER_ID = "arcee";
|
||||
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
});
|
||||
const ARCEE_WIZARD_GROUP = {
|
||||
groupId: "arcee",
|
||||
groupLabel: "Arcee AI",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.12"
|
||||
"openclaw": ">=2026.4.15-beta.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -43,10 +43,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.12"
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.12"
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -48,7 +48,7 @@ function mergeBlueBubblesAccountConfig(
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
normalizeAccountId,
|
||||
nestedObjectKeys: ["network"],
|
||||
nestedObjectKeys: ["network", "catchup"],
|
||||
});
|
||||
return {
|
||||
...merged,
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchBlueBubblesMessagesSince,
|
||||
loadBlueBubblesCatchupCursor,
|
||||
runBlueBubblesCatchup,
|
||||
saveBlueBubblesCatchupCursor,
|
||||
} from "./catchup.js";
|
||||
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
||||
import type { WebhookTarget } from "./monitor-shared.js";
|
||||
|
||||
function makeStateDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catchup-test-"));
|
||||
process.env.OPENCLAW_STATE_DIR = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
function clearStateDir(dir: string): void {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeTarget(overrides: Partial<WebhookTarget & { accountId: string }> = {}): WebhookTarget {
|
||||
const accountId = overrides.accountId ?? "test-account";
|
||||
return {
|
||||
account: {
|
||||
accountId,
|
||||
enabled: true,
|
||||
name: accountId,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "test-password",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
config: {} as unknown as WebhookTarget["config"],
|
||||
runtime: { log: () => {}, error: () => {} },
|
||||
core: {} as unknown as WebhookTarget["core"],
|
||||
path: "/bluebubbles-webhook",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBbMessage(over: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
guid: `guid-${Math.random().toString(36).slice(2, 10)}`,
|
||||
text: "hello",
|
||||
dateCreated: 2_000,
|
||||
handle: { address: "+15555550123" },
|
||||
chats: [{ guid: "iMessage;-;+15555550123" }],
|
||||
isFromMe: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("catchup cursor persistence", () => {
|
||||
let stateDir: string;
|
||||
beforeEach(() => {
|
||||
stateDir = makeStateDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStateDir(stateDir);
|
||||
});
|
||||
|
||||
it("returns null before the first save", async () => {
|
||||
expect(await loadBlueBubblesCatchupCursor("acct")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips a saved cursor", async () => {
|
||||
await saveBlueBubblesCatchupCursor("acct", 1_234_567);
|
||||
const loaded = await loadBlueBubblesCatchupCursor("acct");
|
||||
expect(loaded?.lastSeenMs).toBe(1_234_567);
|
||||
expect(typeof loaded?.updatedAt).toBe("number");
|
||||
});
|
||||
|
||||
it("scopes cursor files per account", async () => {
|
||||
await saveBlueBubblesCatchupCursor("a", 100);
|
||||
await saveBlueBubblesCatchupCursor("b", 200);
|
||||
expect((await loadBlueBubblesCatchupCursor("a"))?.lastSeenMs).toBe(100);
|
||||
expect((await loadBlueBubblesCatchupCursor("b"))?.lastSeenMs).toBe(200);
|
||||
});
|
||||
|
||||
it("treats filesystem-unsafe account IDs as distinct", async () => {
|
||||
// Different account IDs that happen to map to the same safePrefix must
|
||||
// not collide on disk.
|
||||
await saveBlueBubblesCatchupCursor("acct/a", 111);
|
||||
await saveBlueBubblesCatchupCursor("acct:a", 222);
|
||||
expect((await loadBlueBubblesCatchupCursor("acct/a"))?.lastSeenMs).toBe(111);
|
||||
expect((await loadBlueBubblesCatchupCursor("acct:a"))?.lastSeenMs).toBe(222);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBlueBubblesCatchup", () => {
|
||||
let stateDir: string;
|
||||
beforeEach(() => {
|
||||
stateDir = makeStateDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStateDir(stateDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("coalesces concurrent runs for the same accountId via in-process singleflight", async () => {
|
||||
// Two calls firing simultaneously must share one run, one fetch, one
|
||||
// set of processMessage calls, one cursor write. Without singleflight,
|
||||
// both calls would read the same cursor, both would process the same
|
||||
// messages twice (caught by #66816 dedupe, but wasteful), and the
|
||||
// second writer could regress the cursor if its nowMs is stale.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
|
||||
let fetchCount = 0;
|
||||
let processCount = 0;
|
||||
let resolveFetch: (() => void) | null = null;
|
||||
|
||||
const call1 = runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => {
|
||||
fetchCount++;
|
||||
// Block until we fire the second call, so we can verify it
|
||||
// coalesces rather than starting a new run.
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
return {
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "g1", dateCreated: 6 * 60 * 1000 })],
|
||||
};
|
||||
},
|
||||
processMessageFn: async () => {
|
||||
processCount++;
|
||||
},
|
||||
});
|
||||
|
||||
// Wait a tick for call1 to enter fetchMessages, then fire call2.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||
const call2 = runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => {
|
||||
fetchCount++;
|
||||
return { resolved: true, messages: [makeBbMessage({ guid: "g2" })] };
|
||||
},
|
||||
processMessageFn: async () => {
|
||||
processCount++;
|
||||
},
|
||||
});
|
||||
|
||||
resolveFetch!();
|
||||
const [r1, r2] = await Promise.all([call1, call2]);
|
||||
|
||||
expect(fetchCount).toBe(1); // second call coalesced, didn't re-fetch
|
||||
expect(processCount).toBe(1);
|
||||
expect(r1).toBe(r2); // same summary object returned to both callers
|
||||
});
|
||||
|
||||
it("replays messages and advances the cursor on success", async () => {
|
||||
const now = 10_000;
|
||||
const processed: NormalizedWebhookMessage[] = [];
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "g1", text: "one", dateCreated: 9_000 }),
|
||||
makeBbMessage({ guid: "g2", text: "two", dateCreated: 9_500 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async (message) => {
|
||||
processed.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.querySucceeded).toBe(true);
|
||||
expect(summary?.replayed).toBe(2);
|
||||
expect(summary?.failed).toBe(0);
|
||||
expect(processed.map((m) => m.messageId)).toEqual(["g1", "g2"]);
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursor?.lastSeenMs).toBe(now);
|
||||
});
|
||||
|
||||
it("clamps first-run lookback to maxAgeMinutes when smaller", async () => {
|
||||
const now = 1_000_000;
|
||||
let seenSince = -1;
|
||||
await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
// maxAge tighter than firstRunLookback — must clamp on first run.
|
||||
catchup: { maxAgeMinutes: 5, firstRunLookbackMinutes: 30 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async (sinceMs) => {
|
||||
seenSince = sinceMs;
|
||||
return { resolved: true, messages: [] };
|
||||
},
|
||||
processMessageFn: async () => {},
|
||||
},
|
||||
);
|
||||
expect(seenSince).toBe(now - 5 * 60_000);
|
||||
});
|
||||
|
||||
it("uses firstRunLookback when no cursor exists", async () => {
|
||||
const now = 1_000_000;
|
||||
let seenSince = 0;
|
||||
await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { firstRunLookbackMinutes: 5 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async (sinceMs) => {
|
||||
seenSince = sinceMs;
|
||||
return { resolved: true, messages: [] };
|
||||
},
|
||||
processMessageFn: async () => {},
|
||||
},
|
||||
);
|
||||
expect(seenSince).toBe(now - 5 * 60_000);
|
||||
});
|
||||
|
||||
it("clamps window to maxAgeMinutes when cursor is older", async () => {
|
||||
const now = 100 * 60_000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 0);
|
||||
let seenSince = -1;
|
||||
await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxAgeMinutes: 10 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async (sinceMs) => {
|
||||
seenSince = sinceMs;
|
||||
return { resolved: true, messages: [] };
|
||||
},
|
||||
processMessageFn: async () => {},
|
||||
},
|
||||
);
|
||||
expect(seenSince).toBe(now - 10 * 60_000);
|
||||
});
|
||||
|
||||
it("skips when enabled: false", async () => {
|
||||
const called = { fetch: 0, proc: 0 };
|
||||
const summary = await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { enabled: false },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => 1_000,
|
||||
fetchMessages: async () => {
|
||||
called.fetch++;
|
||||
return { resolved: true, messages: [] };
|
||||
},
|
||||
processMessageFn: async () => {
|
||||
called.proc++;
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(summary).toBeNull();
|
||||
expect(called.fetch).toBe(0);
|
||||
expect(called.proc).toBe(0);
|
||||
});
|
||||
|
||||
it("runs catchup even on rapid restarts (no min-interval gate)", async () => {
|
||||
// Catchup runs once per gateway startup, so a quick restart MUST run
|
||||
// it again — otherwise messages dropped between the two startups
|
||||
// (gateway down → BB ECONNREFUSED → gateway up <30s later) are lost
|
||||
// permanently. Bounded by perRunLimit/maxAge + dedupe-protected.
|
||||
const now = 10_000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", now - 5_000);
|
||||
let fetched = false;
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => {
|
||||
fetched = true;
|
||||
return { resolved: true, messages: [] };
|
||||
},
|
||||
processMessageFn: async () => {},
|
||||
});
|
||||
expect(fetched).toBe(true);
|
||||
expect(summary).not.toBeNull();
|
||||
});
|
||||
|
||||
it("advances cursor only to last fetched ts when result is truncated (perRunLimit hit)", async () => {
|
||||
// Long-outage scenario: 4 messages arrived during downtime but
|
||||
// perRunLimit=2. Sort:ASC means we get the 2 oldest. Cursor must
|
||||
// advance to the 2nd's timestamp (not nowMs) so the next startup
|
||||
// picks up the remaining 2.
|
||||
const now = 100 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 50 * 60 * 1000);
|
||||
const summary = await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { perRunLimit: 2 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
// Only the 2 the cap allows BB to return (oldest first via ASC).
|
||||
messages: [
|
||||
makeBbMessage({ guid: "p1", dateCreated: 60 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "p2", dateCreated: 70 * 60 * 1000 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async () => {},
|
||||
},
|
||||
);
|
||||
expect(summary?.replayed).toBe(2);
|
||||
expect(summary?.fetchedCount).toBe(2);
|
||||
expect(summary?.cursorAfter).toBe(70 * 60 * 1000); // page boundary, not nowMs
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursor?.lastSeenMs).toBe(70 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("filters isFromMe before dispatch and still advances cursor", async () => {
|
||||
const now = 10_000;
|
||||
const processed: NormalizedWebhookMessage[] = [];
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "g-me", text: "self", dateCreated: 9_500, isFromMe: true }),
|
||||
makeBbMessage({ guid: "g-them", text: "them", dateCreated: 9_500 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async (m) => {
|
||||
processed.push(m);
|
||||
},
|
||||
});
|
||||
expect(summary?.replayed).toBe(1);
|
||||
expect(summary?.skippedFromMe).toBe(1);
|
||||
expect(processed.map((m) => m.messageId)).toEqual(["g-them"]);
|
||||
});
|
||||
|
||||
it("leaves cursor unchanged when the query fails", async () => {
|
||||
// Use timestamps well past MIN_INTERVAL_MS (30s) so the rate-limit skip
|
||||
// doesn't short-circuit the run before the fetch path fires.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({ resolved: false, messages: [] }),
|
||||
processMessageFn: async () => {},
|
||||
});
|
||||
expect(summary?.querySucceeded).toBe(false);
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursor?.lastSeenMs).toBe(5 * 60 * 1000); // unchanged
|
||||
});
|
||||
|
||||
it("does NOT advance cursor past a processMessage failure (retryable)", async () => {
|
||||
const cursorBefore = 5 * 60 * 1000;
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", cursorBefore);
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "ok1", dateCreated: 6 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "bad", dateCreated: 7 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "ok2", dateCreated: 8 * 60 * 1000 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async (m) => {
|
||||
if (m.messageId === "bad") {
|
||||
throw new Error("transient");
|
||||
}
|
||||
},
|
||||
});
|
||||
// Cursor is held just before the bad message's timestamp so the next
|
||||
// sweep retries it (and re-queries ok1 which dedupe will drop).
|
||||
expect(summary?.failed).toBe(1);
|
||||
expect(summary?.cursorAfter).toBe(7 * 60 * 1000 - 1);
|
||||
const cursorAfter = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursorAfter?.lastSeenMs).toBe(7 * 60 * 1000 - 1);
|
||||
});
|
||||
|
||||
it("clamps held cursor to previous cursor when failure ts is below it", async () => {
|
||||
// Pathological: failure timestamp is at or below the previous cursor
|
||||
// (shouldn't happen with server-side `after:` but defense in depth).
|
||||
// We must never regress the cursor.
|
||||
const cursorBefore = 9 * 60 * 1000;
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", cursorBefore);
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "bad", dateCreated: 1_000 })],
|
||||
}),
|
||||
processMessageFn: async () => {
|
||||
throw new Error("transient");
|
||||
},
|
||||
});
|
||||
// skippedPreCursor catches the bad record before processMessage runs,
|
||||
// so no failure is recorded and cursor advances to nowMs normally.
|
||||
expect(summary?.failed).toBe(0);
|
||||
expect(summary?.skippedPreCursor).toBe(1);
|
||||
expect(summary?.cursorAfter).toBe(now);
|
||||
});
|
||||
|
||||
it("recovers from a future-dated cursor by falling through to firstRunLookback", async () => {
|
||||
// Clock-skew scenario: cursor was written with a wall time that is now
|
||||
// ahead of the corrected clock. Catchup must NOT pass `after=future`
|
||||
// to BB (which would return zero), and must NOT save cursor=nowMs
|
||||
// without first replaying the [earliestAllowed, nowMs] window.
|
||||
const now = 1_000_000;
|
||||
const futureCursor = now + 60_000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", futureCursor);
|
||||
let seenSince = -1;
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async (sinceMs) => {
|
||||
seenSince = sinceMs;
|
||||
return { resolved: true, messages: [] };
|
||||
},
|
||||
processMessageFn: async () => {},
|
||||
});
|
||||
// Should fall through to firstRunLookback (default 30 min), clamped
|
||||
// to maxAge (default 120 min) — i.e. nowMs - 30min, NOT nowMs.
|
||||
expect(seenSince).toBe(now - 30 * 60_000);
|
||||
expect(summary).not.toBeNull();
|
||||
// Cursor should be repaired to nowMs so subsequent runs are normal.
|
||||
const repaired = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(repaired?.lastSeenMs).toBe(now);
|
||||
});
|
||||
|
||||
it("isolates one failing message and keeps processing the rest", async () => {
|
||||
const now = 10_000;
|
||||
const processed: string[] = [];
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "ok1", text: "ok1" }),
|
||||
makeBbMessage({ guid: "bad", text: "bad" }),
|
||||
makeBbMessage({ guid: "ok2", text: "ok2" }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async (m) => {
|
||||
if (m.messageId === "bad") {
|
||||
throw new Error("boom");
|
||||
}
|
||||
processed.push(m.messageId ?? "?");
|
||||
},
|
||||
});
|
||||
expect(summary?.replayed).toBe(2);
|
||||
expect(summary?.failed).toBe(1);
|
||||
expect(processed).toEqual(["ok1", "ok2"]);
|
||||
});
|
||||
|
||||
it("warns when fetched count hits perRunLimit so silent truncation is visible", async () => {
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
const warnings: string[] = [];
|
||||
const summary = await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { perRunLimit: 3 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "a", dateCreated: 6 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "b", dateCreated: 7 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "c", dateCreated: 8 * 60 * 1000 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async () => {},
|
||||
error: (msg) => warnings.push(msg),
|
||||
},
|
||||
);
|
||||
expect(summary?.replayed).toBe(3);
|
||||
expect(summary?.fetchedCount).toBe(3);
|
||||
const truncationWarnings = warnings.filter((w) => w.includes("perRunLimit"));
|
||||
expect(truncationWarnings).toHaveLength(1);
|
||||
expect(truncationWarnings[0]).toContain("WARNING");
|
||||
expect(truncationWarnings[0]).toContain("perRunLimit=3");
|
||||
});
|
||||
|
||||
it("does not warn when fetched count is below perRunLimit", async () => {
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
const warnings: string[] = [];
|
||||
await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { perRunLimit: 50 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "a" }), makeBbMessage({ guid: "b" })],
|
||||
}),
|
||||
processMessageFn: async () => {},
|
||||
error: (msg) => warnings.push(msg),
|
||||
},
|
||||
);
|
||||
expect(warnings.filter((w) => w.includes("perRunLimit"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips pre-cursor timestamps as defense in depth against server-inclusive bounds", async () => {
|
||||
const cursor = 5 * 60 * 1000;
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", cursor);
|
||||
const processed: string[] = [];
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "before", text: "before", dateCreated: cursor - 1_000 }),
|
||||
makeBbMessage({ guid: "at-boundary", text: "boundary", dateCreated: cursor }),
|
||||
makeBbMessage({ guid: "after", text: "after", dateCreated: cursor + 1_000 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async (m) => {
|
||||
processed.push(m.messageId ?? "?");
|
||||
},
|
||||
});
|
||||
expect(summary?.replayed).toBe(1);
|
||||
expect(summary?.skippedPreCursor).toBe(2);
|
||||
expect(processed).toEqual(["after"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBlueBubblesMessagesSince", () => {
|
||||
it("returns resolved:false when the network call throws", async () => {
|
||||
// Point at a port nothing is listening on so fetch fails fast.
|
||||
const result = await fetchBlueBubblesMessagesSince(0, 10, {
|
||||
baseUrl: "http://127.0.0.1:1",
|
||||
password: "x",
|
||||
allowPrivateNetwork: true,
|
||||
timeoutMs: 200,
|
||||
});
|
||||
expect(result.resolved).toBe(false);
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { asRecord, normalizeWebhookMessage } from "./monitor-normalize.js";
|
||||
import { processMessage } from "./monitor-processing.js";
|
||||
import type { WebhookTarget } from "./monitor-shared.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
// When the gateway is down, restarting, or wedged, inbound webhook POSTs from
|
||||
// BB Server fail with ECONNRESET/ECONNREFUSED. BB's WebhookService does not
|
||||
// retry, and its MessagePoller only re-fires webhooks on BB-side reconnect
|
||||
// events (Messages.app / APNs), not on webhook-receiver recovery. Without a
|
||||
// recovery pass, messages delivered during outage windows are permanently
|
||||
// lost. See #66721 for design discussion and experimental validation.
|
||||
|
||||
const DEFAULT_MAX_AGE_MINUTES = 120;
|
||||
const MAX_MAX_AGE_MINUTES = 12 * 60;
|
||||
const DEFAULT_PER_RUN_LIMIT = 50;
|
||||
const MAX_PER_RUN_LIMIT = 500;
|
||||
const DEFAULT_FIRST_RUN_LOOKBACK_MINUTES = 30;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type BlueBubblesCatchupConfig = {
|
||||
enabled?: boolean;
|
||||
maxAgeMinutes?: number;
|
||||
perRunLimit?: number;
|
||||
firstRunLookbackMinutes?: number;
|
||||
};
|
||||
|
||||
export type BlueBubblesCatchupSummary = {
|
||||
querySucceeded: boolean;
|
||||
replayed: number;
|
||||
skippedFromMe: number;
|
||||
skippedPreCursor: number;
|
||||
failed: number;
|
||||
cursorBefore: number | null;
|
||||
cursorAfter: number;
|
||||
windowStartMs: number;
|
||||
windowEndMs: number;
|
||||
fetchedCount: number;
|
||||
};
|
||||
|
||||
export type BlueBubblesCatchupCursor = { lastSeenMs: number; updatedAt: number };
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
||||
// Explicit OPENCLAW_STATE_DIR overrides take precedence (including
|
||||
// per-test mkdtemp dirs in this module's test suite).
|
||||
if (env.OPENCLAW_STATE_DIR?.trim()) {
|
||||
return resolveStateDir(env);
|
||||
}
|
||||
// Default test isolation: per-pid tmpdir, no bleed into real ~/.openclaw.
|
||||
// Use resolvePreferredOpenClawTmpDir + string concat (mirrors
|
||||
// inbound-dedupe) so this doesn't trip the tmpdir-path-guard test that
|
||||
// flags dynamic template-literal suffixes on os.tmpdir() paths.
|
||||
if (env.VITEST || env.NODE_ENV === "test") {
|
||||
const name = "openclaw-vitest-" + process.pid;
|
||||
return path.join(resolvePreferredOpenClawTmpDir(), name);
|
||||
}
|
||||
// Canonical OpenClaw state dir: honors `~` expansion + legacy/new
|
||||
// fallback. Sharing this resolver with inbound-dedupe is what guarantees
|
||||
// the catchup cursor and the dedupe state always live under the same
|
||||
// root, so a replayed GUID is recognized by the dedupe after catchup
|
||||
// re-feeds the message through processMessage.
|
||||
return resolveStateDir(env);
|
||||
}
|
||||
|
||||
function resolveCursorFilePath(accountId: string): string {
|
||||
// Match inbound-dedupe's file layout: readable prefix + short hash so
|
||||
// account IDs that only differ by filesystem-unsafe characters do not
|
||||
// collapse onto the same file.
|
||||
const safePrefix = accountId.replace(/[^a-zA-Z0-9_-]/g, "_") || "account";
|
||||
const hash = createHash("sha256").update(accountId, "utf8").digest("hex").slice(0, 12);
|
||||
return path.join(
|
||||
resolveStateDirFromEnv(),
|
||||
"bluebubbles",
|
||||
"catchup",
|
||||
`${safePrefix}__${hash}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadBlueBubblesCatchupCursor(
|
||||
accountId: string,
|
||||
): Promise<BlueBubblesCatchupCursor | null> {
|
||||
const filePath = resolveCursorFilePath(accountId);
|
||||
const { value } = await readJsonFileWithFallback<BlueBubblesCatchupCursor | null>(filePath, null);
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (typeof value.lastSeenMs !== "number" || !Number.isFinite(value.lastSeenMs)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function saveBlueBubblesCatchupCursor(
|
||||
accountId: string,
|
||||
lastSeenMs: number,
|
||||
): Promise<void> {
|
||||
const filePath = resolveCursorFilePath(accountId);
|
||||
const cursor: BlueBubblesCatchupCursor = { lastSeenMs, updatedAt: Date.now() };
|
||||
await writeJsonFileAtomically(filePath, cursor);
|
||||
}
|
||||
|
||||
type FetchOpts = {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type BlueBubblesCatchupFetchResult = {
|
||||
resolved: boolean;
|
||||
messages: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export async function fetchBlueBubblesMessagesSince(
|
||||
sinceMs: number,
|
||||
limit: number,
|
||||
opts: FetchOpts,
|
||||
): Promise<BlueBubblesCatchupFetchResult> {
|
||||
const ssrfPolicy = opts.allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl: opts.baseUrl,
|
||||
path: "/api/v1/message/query",
|
||||
password: opts.password,
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
limit,
|
||||
sort: "ASC",
|
||||
after: sinceMs,
|
||||
// `with` mirrors what bb-catchup.sh uses and what the normal webhook
|
||||
// payload carries, so normalizeWebhookMessage has the same fields to
|
||||
// read during replay as it does on live dispatch.
|
||||
with: ["chat", "chat.participants", "attachment"],
|
||||
});
|
||||
try {
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? FETCH_TIMEOUT_MS,
|
||||
ssrfPolicy,
|
||||
);
|
||||
if (!res.ok) {
|
||||
return { resolved: false, messages: [] };
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { data?: unknown } | null;
|
||||
if (!json || !Array.isArray(json.data)) {
|
||||
return { resolved: false, messages: [] };
|
||||
}
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
for (const entry of json.data) {
|
||||
const rec = asRecord(entry);
|
||||
if (rec) {
|
||||
messages.push(rec);
|
||||
}
|
||||
}
|
||||
return { resolved: true, messages };
|
||||
} catch {
|
||||
return { resolved: false, messages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function clampCatchupConfig(raw?: BlueBubblesCatchupConfig) {
|
||||
const maxAgeMinutes = Math.min(
|
||||
Math.max(raw?.maxAgeMinutes ?? DEFAULT_MAX_AGE_MINUTES, 1),
|
||||
MAX_MAX_AGE_MINUTES,
|
||||
);
|
||||
const perRunLimit = Math.min(
|
||||
Math.max(raw?.perRunLimit ?? DEFAULT_PER_RUN_LIMIT, 1),
|
||||
MAX_PER_RUN_LIMIT,
|
||||
);
|
||||
const firstRunLookbackMinutes = Math.min(
|
||||
Math.max(raw?.firstRunLookbackMinutes ?? DEFAULT_FIRST_RUN_LOOKBACK_MINUTES, 1),
|
||||
MAX_MAX_AGE_MINUTES,
|
||||
);
|
||||
return {
|
||||
maxAgeMs: maxAgeMinutes * 60_000,
|
||||
perRunLimit,
|
||||
firstRunLookbackMs: firstRunLookbackMinutes * 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
export type RunBlueBubblesCatchupDeps = {
|
||||
fetchMessages?: typeof fetchBlueBubblesMessagesSince;
|
||||
processMessageFn?: typeof processMessage;
|
||||
now?: () => number;
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch and replay BlueBubbles messages delivered since the persisted
|
||||
* catchup cursor, feeding each through the same `processMessage` pipeline
|
||||
* live webhooks use. Safe to call on every gateway startup: replays that
|
||||
* collide with #66230's inbound dedupe cache are dropped there, so a
|
||||
* message already processed via live webhook will not be processed twice.
|
||||
*
|
||||
* Returns the run summary, or `null` when disabled or aborted before the
|
||||
* first query.
|
||||
*
|
||||
* Concurrent calls for the same accountId are coalesced into a single
|
||||
* in-flight run via a module-level singleflight map. Without this, a
|
||||
* fire-and-forget trigger (monitor.ts) combined with an overlapping
|
||||
* webhook-target re-registration could race: two runs would read the
|
||||
* same cursor, compute divergent `nextCursorMs` values, and the last
|
||||
* writer could regress the cursor — causing repeated replay of the same
|
||||
* backlog on every subsequent startup.
|
||||
*/
|
||||
const inFlightCatchups = new Map<string, Promise<BlueBubblesCatchupSummary | null>>();
|
||||
|
||||
export function runBlueBubblesCatchup(
|
||||
target: WebhookTarget,
|
||||
deps: RunBlueBubblesCatchupDeps = {},
|
||||
): Promise<BlueBubblesCatchupSummary | null> {
|
||||
const accountId = target.account.accountId;
|
||||
const existing = inFlightCatchups.get(accountId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const runPromise = runBlueBubblesCatchupInner(target, deps).finally(() => {
|
||||
inFlightCatchups.delete(accountId);
|
||||
});
|
||||
inFlightCatchups.set(accountId, runPromise);
|
||||
return runPromise;
|
||||
}
|
||||
|
||||
async function runBlueBubblesCatchupInner(
|
||||
target: WebhookTarget,
|
||||
deps: RunBlueBubblesCatchupDeps,
|
||||
): Promise<BlueBubblesCatchupSummary | null> {
|
||||
const raw = (target.account.config as { catchup?: BlueBubblesCatchupConfig }).catchup;
|
||||
if (raw?.enabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const log = deps.log ?? target.runtime.log;
|
||||
const error = deps.error ?? target.runtime.error;
|
||||
const fetchFn = deps.fetchMessages ?? fetchBlueBubblesMessagesSince;
|
||||
const procFn = deps.processMessageFn ?? processMessage;
|
||||
const accountId = target.account.accountId;
|
||||
|
||||
const { maxAgeMs, perRunLimit, firstRunLookbackMs } = clampCatchupConfig(raw);
|
||||
const nowMs = now();
|
||||
const existing = await loadBlueBubblesCatchupCursor(accountId).catch(() => null);
|
||||
const cursorBefore = existing?.lastSeenMs ?? null;
|
||||
|
||||
// Catchup runs once per gateway startup (called from monitor.ts after
|
||||
// webhook target registration). We deliberately do NOT short-circuit on
|
||||
// a "ran recently" gate, because catchup is the only mechanism that
|
||||
// recovers messages dropped during the gateway-down window. A short
|
||||
// gap (e.g. <30s) between two startups can still have lost messages in
|
||||
// the middle, and skipping the second startup's catchup would lose
|
||||
// them permanently. The bounded query (perRunLimit, maxAge) and the
|
||||
// inbound-dedupe cache from #66230 cap the cost of running the query
|
||||
// every startup.
|
||||
|
||||
const earliestAllowed = nowMs - maxAgeMs;
|
||||
// A future-dated cursor (clock rollback via NTP correction or manual
|
||||
// adjust) is unusable: querying with `after` set to a future timestamp
|
||||
// would return zero records, and saving `nowMs` as the new cursor would
|
||||
// permanently skip any real messages missed in the
|
||||
// [earliestAllowed, nowMs] window. Treat it as if no cursor exists and
|
||||
// fall through to the firstRun lookback path; the inbound-dedupe cache
|
||||
// from #66230 handles any overlap with already-processed messages, and
|
||||
// saving cursor = nowMs at the end of the run repairs the cursor.
|
||||
const cursorIsUsable = existing !== null && existing.lastSeenMs <= nowMs;
|
||||
// First-run (and recovered-future-cursor) lookback is also clamped to
|
||||
// the maxAge ceiling so a config with `maxAgeMinutes: 5,
|
||||
// firstRunLookbackMinutes: 30` doesn't silently exceed the operator's
|
||||
// stated lookback cap on first startup.
|
||||
const windowStartMs = cursorIsUsable
|
||||
? Math.max(existing.lastSeenMs, earliestAllowed)
|
||||
: Math.max(nowMs - firstRunLookbackMs, earliestAllowed);
|
||||
|
||||
let baseUrl: string;
|
||||
let password: string;
|
||||
let allowPrivateNetwork = false;
|
||||
try {
|
||||
({ baseUrl, password, allowPrivateNetwork } = resolveBlueBubblesServerAccount({
|
||||
serverUrl: target.account.baseUrl,
|
||||
password: target.account.config.password,
|
||||
accountId,
|
||||
cfg: target.config,
|
||||
}));
|
||||
} catch (err) {
|
||||
error?.(`[${accountId}] BlueBubbles catchup: cannot resolve server account: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { resolved, messages } = await fetchFn(windowStartMs, perRunLimit, {
|
||||
baseUrl,
|
||||
password,
|
||||
allowPrivateNetwork,
|
||||
});
|
||||
|
||||
const summary: BlueBubblesCatchupSummary = {
|
||||
querySucceeded: resolved,
|
||||
replayed: 0,
|
||||
skippedFromMe: 0,
|
||||
skippedPreCursor: 0,
|
||||
failed: 0,
|
||||
cursorBefore,
|
||||
cursorAfter: nowMs,
|
||||
windowStartMs,
|
||||
windowEndMs: nowMs,
|
||||
fetchedCount: messages.length,
|
||||
};
|
||||
|
||||
if (!resolved) {
|
||||
// Leave cursor unchanged so the next run retries the same window.
|
||||
error?.(`[${accountId}] BlueBubbles catchup: message-query failed; cursor unchanged`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Track the earliest timestamp where `processMessage` threw so we never
|
||||
// advance the cursor past a retryable failure. Normalize failures (the
|
||||
// record didn't yield a usable NormalizedWebhookMessage) are treated as
|
||||
// permanent skips and do NOT block cursor advance — those payloads are
|
||||
// unlikely to ever normalize on retry, and blocking on them would wedge
|
||||
// catchup forever.
|
||||
let earliestProcessFailureTs: number | null = null;
|
||||
// Track the latest fetched message timestamp regardless of fate, so a
|
||||
// truncated query (fetchedCount === perRunLimit) can advance the cursor
|
||||
// exactly to the page boundary. Without this, the unfetched tail past
|
||||
// the cap is permanently unreachable.
|
||||
let latestFetchedTs = windowStartMs;
|
||||
|
||||
for (const rec of messages) {
|
||||
// Defense in depth: the server-side `after:` filter should already
|
||||
// exclude pre-cursor messages, but guard here against BB API variants
|
||||
// that return inclusive-of-boundary data.
|
||||
const ts = typeof rec.dateCreated === "number" ? rec.dateCreated : 0;
|
||||
if (ts > 0 && ts > latestFetchedTs) {
|
||||
latestFetchedTs = ts;
|
||||
}
|
||||
if (ts > 0 && ts <= windowStartMs) {
|
||||
summary.skippedPreCursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter fromMe early so BB's record of our own outbound sends cannot
|
||||
// enter the inbound pipeline even if normalization would accept them.
|
||||
if (rec.isFromMe === true || rec.is_from_me === true) {
|
||||
summary.skippedFromMe++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeWebhookMessage({ type: "new-message", data: rec });
|
||||
if (!normalized) {
|
||||
summary.failed++;
|
||||
continue;
|
||||
}
|
||||
if (normalized.fromMe) {
|
||||
summary.skippedFromMe++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await procFn(normalized, target);
|
||||
summary.replayed++;
|
||||
} catch (err) {
|
||||
summary.failed++;
|
||||
if (ts > 0 && (earliestProcessFailureTs === null || ts < earliestProcessFailureTs)) {
|
||||
earliestProcessFailureTs = ts;
|
||||
}
|
||||
error?.(`[${accountId}] BlueBubbles catchup: processMessage failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the new cursor.
|
||||
//
|
||||
// - Default: advance to `nowMs` so subsequent runs start from the moment
|
||||
// this sweep finished (avoiding stuck rescans of a message with
|
||||
// `dateCreated > nowMs` from minor clock skew between BB host and
|
||||
// gateway host).
|
||||
// - On retryable failure (any `processMessage` throw): hold the cursor
|
||||
// just before the earliest failed timestamp so the next run retries
|
||||
// from there. The inbound-dedupe cache from #66230 keeps successfully
|
||||
// replayed messages from being re-processed.
|
||||
// - On truncation (fetched === perRunLimit): advance only to the latest
|
||||
// fetched timestamp so the next run picks up from the page boundary.
|
||||
// Otherwise the unfetched tail past the cap (which can be substantial
|
||||
// during long outages) would be permanently unreachable.
|
||||
const isTruncated = summary.fetchedCount >= perRunLimit;
|
||||
let nextCursorMs = nowMs;
|
||||
if (earliestProcessFailureTs !== null) {
|
||||
const heldCursor = Math.max(earliestProcessFailureTs - 1, cursorBefore ?? windowStartMs);
|
||||
nextCursorMs = Math.min(heldCursor, nowMs);
|
||||
} else if (isTruncated) {
|
||||
// Use latestFetchedTs (clamped to >= prior cursor and <= nowMs) so the
|
||||
// next run starts where this page ended.
|
||||
nextCursorMs = Math.min(Math.max(latestFetchedTs, cursorBefore ?? windowStartMs), nowMs);
|
||||
}
|
||||
summary.cursorAfter = nextCursorMs;
|
||||
await saveBlueBubblesCatchupCursor(accountId, nextCursorMs).catch((err) => {
|
||||
error?.(`[${accountId}] BlueBubbles catchup: cursor save failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
log?.(
|
||||
`[${accountId}] BlueBubbles catchup: replayed=${summary.replayed} ` +
|
||||
`skipped_fromMe=${summary.skippedFromMe} skipped_preCursor=${summary.skippedPreCursor} ` +
|
||||
`failed=${summary.failed} fetched=${summary.fetchedCount} ` +
|
||||
`window_ms=${nowMs - windowStartMs}`,
|
||||
);
|
||||
|
||||
// Distinct WARNING when the BB result hits perRunLimit so operators
|
||||
// know a single startup didn't drain the full backlog. The cursor was
|
||||
// advanced only to the page boundary above, so the unfetched tail will
|
||||
// be picked up on the next gateway startup — but if startups are
|
||||
// infrequent, raising perRunLimit drains larger backlogs in one pass.
|
||||
if (isTruncated) {
|
||||
error?.(
|
||||
`[${accountId}] BlueBubbles catchup: WARNING fetched=${summary.fetchedCount} ` +
|
||||
`hit perRunLimit=${perRunLimit}; cursor advanced only to page boundary, ` +
|
||||
`remaining messages will be picked up on next startup. Raise ` +
|
||||
`channels.bluebubbles...catchup.perRunLimit to drain larger backlogs ` +
|
||||
`in a single pass.`,
|
||||
);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -40,6 +40,20 @@ const bluebubblesNetworkSchema = z
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const bluebubblesCatchupSchema = z
|
||||
.object({
|
||||
/** Replay messages delivered while the gateway was unreachable. Defaults to on. */
|
||||
enabled: z.boolean().optional(),
|
||||
/** Hard ceiling on lookback window. Clamped to [1, 720] minutes. */
|
||||
maxAgeMinutes: z.number().int().positive().optional(),
|
||||
/** Upper bound on messages replayed in a single startup pass. Clamped to [1, 500]. */
|
||||
perRunLimit: z.number().int().positive().optional(),
|
||||
/** First-run lookback used when no cursor has been persisted yet. Clamped to [1, 720]. */
|
||||
firstRunLookbackMinutes: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const bluebubblesAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -62,6 +76,7 @@ const bluebubblesAccountSchema = z
|
||||
mediaLocalRoots: z.array(z.string()).optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
network: bluebubblesNetworkSchema,
|
||||
catchup: bluebubblesCatchupSchema,
|
||||
blockStreaming: z.boolean().optional(),
|
||||
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
_resetBlueBubblesInboundDedupForTest,
|
||||
claimBlueBubblesInboundMessage,
|
||||
} from "./inbound-dedupe.js";
|
||||
|
||||
async function claimAndFinalize(guid: string | undefined, accountId: string): Promise<string> {
|
||||
const claim = await claimBlueBubblesInboundMessage({ guid, accountId });
|
||||
if (claim.kind === "claimed") {
|
||||
await claim.finalize();
|
||||
}
|
||||
return claim.kind;
|
||||
}
|
||||
|
||||
describe("claimBlueBubblesInboundMessage", () => {
|
||||
beforeEach(() => {
|
||||
_resetBlueBubblesInboundDedupForTest();
|
||||
});
|
||||
|
||||
it("claims a new guid and rejects committed duplicates", async () => {
|
||||
expect(await claimAndFinalize("g1", "acc")).toBe("claimed");
|
||||
expect(await claimAndFinalize("g1", "acc")).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("scopes dedupe per account", async () => {
|
||||
expect(await claimAndFinalize("g1", "a")).toBe("claimed");
|
||||
expect(await claimAndFinalize("g1", "b")).toBe("claimed");
|
||||
});
|
||||
|
||||
it("reports skip when guid is missing or blank", async () => {
|
||||
expect((await claimBlueBubblesInboundMessage({ guid: undefined, accountId: "acc" })).kind).toBe(
|
||||
"skip",
|
||||
);
|
||||
expect((await claimBlueBubblesInboundMessage({ guid: "", accountId: "acc" })).kind).toBe(
|
||||
"skip",
|
||||
);
|
||||
expect((await claimBlueBubblesInboundMessage({ guid: " ", accountId: "acc" })).kind).toBe(
|
||||
"skip",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects overlong guids to cap on-disk size", async () => {
|
||||
const huge = "x".repeat(10_000);
|
||||
expect((await claimBlueBubblesInboundMessage({ guid: huge, accountId: "acc" })).kind).toBe(
|
||||
"skip",
|
||||
);
|
||||
});
|
||||
|
||||
it("releases the claim so a later replay can retry after a transient failure", async () => {
|
||||
const first = await claimBlueBubblesInboundMessage({ guid: "g1", accountId: "acc" });
|
||||
expect(first.kind).toBe("claimed");
|
||||
if (first.kind === "claimed") {
|
||||
first.release();
|
||||
}
|
||||
// Released claims should be re-claimable on the next delivery.
|
||||
expect(await claimAndFinalize("g1", "acc")).toBe("claimed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { type ClaimableDedupe, createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
||||
|
||||
// BlueBubbles has no sequence/ack in its webhook protocol, and its
|
||||
// MessagePoller replays its ~1-week lookback window as `new-message` events
|
||||
// after BB Server restarts or reconnects. Without persistent dedup, the
|
||||
// gateway can reply to messages that were already handled before a restart
|
||||
// (see issues #19176, #12053).
|
||||
//
|
||||
// TTL matches BB's lookback window so any replay is guaranteed to land on
|
||||
// a remembered GUID, and the file-backed store survives gateway restarts.
|
||||
const DEDUP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
||||
const MEMORY_MAX_SIZE = 5_000;
|
||||
const FILE_MAX_ENTRIES = 50_000;
|
||||
// Cap GUID length so a malformed or hostile payload can't bloat the on-disk
|
||||
// dedupe file. Real BB GUIDs are short (<64 chars); 512 is generous.
|
||||
const MAX_GUID_CHARS = 512;
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env.VITEST || env.NODE_ENV === "test") {
|
||||
// Isolate tests from real ~/.openclaw state without sharing across tests.
|
||||
// Stable-per-pid so the scoped dedupe test can observe persistence.
|
||||
const name = "openclaw-vitest-" + process.pid;
|
||||
return path.join(resolvePreferredOpenClawTmpDir(), name);
|
||||
}
|
||||
// Canonical OpenClaw state dir: honors OPENCLAW_STATE_DIR (with `~` expansion
|
||||
// via resolveUserPath), plus legacy/new fallback. Using the shared helper
|
||||
// keeps this plugin's persistence aligned with the rest of OpenClaw state.
|
||||
return resolveStateDir(env);
|
||||
}
|
||||
|
||||
function resolveNamespaceFilePath(namespace: string): string {
|
||||
// Keep a readable prefix for operator debugging, but suffix with a short
|
||||
// hash of the raw namespace so account IDs that only differ by
|
||||
// filesystem-unsafe characters (e.g. "acct/a" vs "acct:a") don't collapse
|
||||
// onto the same file.
|
||||
const safePrefix = namespace.replace(/[^a-zA-Z0-9_-]/g, "_") || "ns";
|
||||
const hash = createHash("sha256").update(namespace, "utf8").digest("hex").slice(0, 12);
|
||||
return path.join(
|
||||
resolveStateDirFromEnv(),
|
||||
"bluebubbles",
|
||||
"inbound-dedupe",
|
||||
`${safePrefix}__${hash}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildPersistentImpl(): ClaimableDedupe {
|
||||
return createClaimableDedupe({
|
||||
ttlMs: DEDUP_TTL_MS,
|
||||
memoryMaxSize: MEMORY_MAX_SIZE,
|
||||
fileMaxEntries: FILE_MAX_ENTRIES,
|
||||
resolveFilePath: resolveNamespaceFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
function buildMemoryOnlyImpl(): ClaimableDedupe {
|
||||
return createClaimableDedupe({
|
||||
ttlMs: DEDUP_TTL_MS,
|
||||
memoryMaxSize: MEMORY_MAX_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let impl: ClaimableDedupe = buildPersistentImpl();
|
||||
|
||||
function sanitizeGuid(guid: string | undefined | null): string | null {
|
||||
const trimmed = guid?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.length > MAX_GUID_CHARS) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical dedupe key for a BlueBubbles inbound message.
|
||||
*
|
||||
* Mirrors `monitor-debounce.ts`'s `buildKey`: BlueBubbles sends URL-preview
|
||||
* / sticker "balloon" events with a different `messageId` than the text
|
||||
* message they belong to, and the debouncer coalesces the two only when
|
||||
* both `balloonBundleId` AND `associatedMessageGuid` are present. We gate
|
||||
* on the same pair so that regular replies — which also set
|
||||
* `associatedMessageGuid` (pointing at the parent message) but have no
|
||||
* `balloonBundleId` — are NOT collapsed onto their parent's dedupe key.
|
||||
*
|
||||
* Known tradeoff: `combineDebounceEntries` clears `balloonBundleId` on
|
||||
* merged entries while keeping `associatedMessageGuid`, so a post-merge
|
||||
* balloon+text message here will fall back to its `messageId`. A later
|
||||
* MessagePoller replay that arrives in a different text-first/balloon-first
|
||||
* order could therefore produce a different `messageId` at merge time and
|
||||
* bypass this dedupe for that one message. That edge case is strictly
|
||||
* narrower than the alternative — which would dedupe every distinct user
|
||||
* reply against the same parent GUID and silently drop real messages.
|
||||
*/
|
||||
export function resolveBlueBubblesInboundDedupeKey(
|
||||
message: Pick<
|
||||
NormalizedWebhookMessage,
|
||||
"messageId" | "balloonBundleId" | "associatedMessageGuid"
|
||||
>,
|
||||
): string | undefined {
|
||||
const balloonBundleId = message.balloonBundleId?.trim();
|
||||
const associatedMessageGuid = message.associatedMessageGuid?.trim();
|
||||
if (balloonBundleId && associatedMessageGuid) {
|
||||
return associatedMessageGuid;
|
||||
}
|
||||
return message.messageId?.trim() || undefined;
|
||||
}
|
||||
|
||||
export type InboundDedupeClaim =
|
||||
| { kind: "claimed"; finalize: () => Promise<void>; release: () => void }
|
||||
| { kind: "duplicate" }
|
||||
| { kind: "inflight" }
|
||||
| { kind: "skip" };
|
||||
|
||||
/**
|
||||
* Attempt to claim an inbound BlueBubbles message GUID.
|
||||
*
|
||||
* - `claimed`: caller should process the message, then call `finalize()` on
|
||||
* success (persists the GUID) or `release()` on failure (lets a later
|
||||
* replay try again).
|
||||
* - `duplicate`: we've already committed this GUID; caller should drop.
|
||||
* - `inflight`: another claim is currently in progress; caller should drop
|
||||
* rather than race.
|
||||
* - `skip`: GUID was missing or invalid — caller should continue processing
|
||||
* without dedup (no finalize/release needed).
|
||||
*/
|
||||
export async function claimBlueBubblesInboundMessage(params: {
|
||||
guid: string | undefined | null;
|
||||
accountId: string;
|
||||
onDiskError?: (error: unknown) => void;
|
||||
}): Promise<InboundDedupeClaim> {
|
||||
const normalized = sanitizeGuid(params.guid);
|
||||
if (!normalized) {
|
||||
return { kind: "skip" };
|
||||
}
|
||||
const claim = await impl.claim(normalized, {
|
||||
namespace: params.accountId,
|
||||
onDiskError: params.onDiskError,
|
||||
});
|
||||
if (claim.kind === "duplicate") {
|
||||
return { kind: "duplicate" };
|
||||
}
|
||||
if (claim.kind === "inflight") {
|
||||
return { kind: "inflight" };
|
||||
}
|
||||
return {
|
||||
kind: "claimed",
|
||||
finalize: async () => {
|
||||
await impl.commit(normalized, {
|
||||
namespace: params.accountId,
|
||||
onDiskError: params.onDiskError,
|
||||
});
|
||||
},
|
||||
release: () => {
|
||||
impl.release(normalized, { namespace: params.accountId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset inbound dedupe state between tests. Installs an in-memory-only
|
||||
* implementation so tests do not hit disk, avoiding file-lock timing issues
|
||||
* in the webhook flush path.
|
||||
*/
|
||||
export function _resetBlueBubblesInboundDedupForTest(): void {
|
||||
impl = buildMemoryOnlyImpl();
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveBlueBubblesConversationRoute } from "./conversation-route.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import {
|
||||
claimBlueBubblesInboundMessage,
|
||||
resolveBlueBubblesInboundDedupeKey,
|
||||
} from "./inbound-dedupe.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import {
|
||||
buildMessagePlaceholder,
|
||||
@@ -581,11 +585,102 @@ function buildInboundHistorySnapshot(params: {
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: unknown, maxLen = 200): string {
|
||||
const cleaned = String(value).replace(/[\r\n\t\p{C}]/gu, " ");
|
||||
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal object threaded through `processMessageAfterDedupe` so the outer
|
||||
* wrapper can distinguish "reply delivery failed silently" from "returned
|
||||
* normally after an intentional drop" (fromMe cache, pairing flow, allowlist
|
||||
* block, empty text, etc.).
|
||||
*
|
||||
* Reply delivery errors in the BlueBubbles path surface through the
|
||||
* dispatcher's `onError` callback rather than as thrown exceptions, so a
|
||||
* plain try/catch cannot detect them — see review thread `rwF8` on #66230.
|
||||
*/
|
||||
type InboundDedupeDeliverySignal = { deliveryFailed: boolean };
|
||||
|
||||
/**
|
||||
* Claim → process → finalize/release wrapper around the real inbound flow.
|
||||
*
|
||||
* Claim before doing any work so restart replays and in-flight concurrent
|
||||
* redeliveries both drop cleanly. Finalize (persist the GUID) only when
|
||||
* processing completed cleanly AND any reply dispatch reported success;
|
||||
* release (let a later replay try again) when processing threw OR the reply
|
||||
* pipeline reported a delivery failure via its onError callback.
|
||||
*
|
||||
* The dedupe key follows the same canonicalization rules as the debouncer
|
||||
* (`monitor-debounce.ts`): balloon events (URL previews, stickers) share
|
||||
* a logical identity with their originating text message via
|
||||
* `associatedMessageGuid`, so balloon-first vs text-first event ordering
|
||||
* cannot produce two distinct dedupe keys for the same logical message.
|
||||
*/
|
||||
export async function processMessage(
|
||||
message: NormalizedWebhookMessage,
|
||||
target: WebhookTarget,
|
||||
): Promise<void> {
|
||||
const { account, core, runtime } = target;
|
||||
|
||||
const dedupeKey = resolveBlueBubblesInboundDedupeKey(message);
|
||||
|
||||
// Drop BlueBubbles MessagePoller replays after server restart (#19176, #12053).
|
||||
const claim = await claimBlueBubblesInboundMessage({
|
||||
guid: dedupeKey,
|
||||
accountId: account.accountId,
|
||||
onDiskError: (error) =>
|
||||
logVerbose(core, runtime, `inbound-dedupe disk error: ${sanitizeForLog(error)}`),
|
||||
});
|
||||
if (claim.kind === "duplicate" || claim.kind === "inflight") {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop: ${claim.kind} inbound key=${sanitizeForLog(dedupeKey ?? "")} sender=${sanitizeForLog(message.senderId)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const signal: InboundDedupeDeliverySignal = { deliveryFailed: false };
|
||||
try {
|
||||
await processMessageAfterDedupe(message, target, signal);
|
||||
} catch (error) {
|
||||
if (claim.kind === "claimed") {
|
||||
claim.release();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (claim.kind === "claimed") {
|
||||
if (signal.deliveryFailed) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`inbound-dedupe: releasing claim for key=${sanitizeForLog(dedupeKey ?? "")} after reply delivery failure (will retry on replay)`,
|
||||
);
|
||||
claim.release();
|
||||
} else {
|
||||
try {
|
||||
await claim.finalize();
|
||||
} catch (finalizeError) {
|
||||
// commit() already clears inflight state in its finally block, so
|
||||
// no explicit release() needed here — just log the persistence error.
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`inbound-dedupe: finalize failed for key=${sanitizeForLog(dedupeKey ?? "")}: ${sanitizeForLog(finalizeError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processMessageAfterDedupe(
|
||||
message: NormalizedWebhookMessage,
|
||||
target: WebhookTarget,
|
||||
dedupeSignal: InboundDedupeDeliverySignal,
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, core, statusSink } = target;
|
||||
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
channel: "bluebubbles",
|
||||
@@ -1597,6 +1692,19 @@ export async function processMessage(
|
||||
onReplyStart: typingCallbacks?.onReplyStart,
|
||||
onIdle: typingCallbacks?.onIdle,
|
||||
onError: (err, info) => {
|
||||
// Flag the outer dedupe wrapper so it releases the claim instead
|
||||
// of committing. Without this, a transient BlueBubbles send failure
|
||||
// would permanently block replay-retry for 7 days and the user
|
||||
// would never receive a reply to that message.
|
||||
//
|
||||
// Only the terminal `final` delivery represents the user-visible
|
||||
// answer. The dispatcher continues past `tool` / `block` failures
|
||||
// and may still deliver `final` successfully — releasing the
|
||||
// dedupe claim for those would invite a replay that re-runs tool
|
||||
// side effects and resends partially-delivered content.
|
||||
if (info.kind === "final") {
|
||||
dedupeSignal.deliveryFailed = true;
|
||||
}
|
||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesEffectiveAllowPrivateNetwork } from "./accounts.js";
|
||||
import { runBlueBubblesCatchup } from "./catchup.js";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import {
|
||||
asRecord,
|
||||
@@ -343,14 +344,15 @@ export async function monitorBlueBubblesProvider(
|
||||
);
|
||||
}
|
||||
|
||||
const unregister = registerBlueBubblesWebhookTarget({
|
||||
const target: WebhookTarget = {
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
path,
|
||||
statusSink,
|
||||
});
|
||||
};
|
||||
const unregister = registerBlueBubblesWebhookTarget(target);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
@@ -367,6 +369,17 @@ export async function monitorBlueBubblesProvider(
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
||||
);
|
||||
|
||||
// Kick off a catchup pass for messages delivered while the webhook
|
||||
// target wasn't reachable. Fire-and-forget; the catchup runs through the
|
||||
// same processMessage path webhooks use, and #66230's inbound dedupe
|
||||
// drops any GUID that was already handled, so this is safe even if a
|
||||
// live webhook raced the startup replay. See #66721.
|
||||
runBlueBubblesCatchup(target).catch((err) => {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] BlueBubbles catchup: unexpected failure: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "bluebubbles",
|
||||
errorMessage: "BlueBubbles runtime not initialized",
|
||||
});
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HistoryEntry, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../../test/helpers/plugins/plugin-runtime-mock.js";
|
||||
import { _resetBlueBubblesInboundDedupForTest } from "../inbound-dedupe.js";
|
||||
import {
|
||||
_resetBlueBubblesShortIdState,
|
||||
clearBlueBubblesWebhookSecurityStateForTest,
|
||||
@@ -118,6 +119,7 @@ export function resetBlueBubblesMonitorTestState(params: {
|
||||
}) {
|
||||
vi.clearAllMocks();
|
||||
_resetBlueBubblesShortIdState();
|
||||
_resetBlueBubblesInboundDedupForTest();
|
||||
clearBlueBubblesWebhookSecurityStateForTest();
|
||||
params.extraReset?.();
|
||||
params.fetchHistoryMock.mockResolvedValue({ entries: [], resolved: true });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,6 +3,6 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 40_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 8_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -2,9 +2,12 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
createProviderOperationDeadline,
|
||||
fetchWithTimeout,
|
||||
postJsonRequest,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
resolveProviderHttpRequestConfig,
|
||||
waitProviderOperationPollInterval,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type {
|
||||
@@ -73,6 +76,10 @@ async function pollBytePlusTask(params: {
|
||||
baseUrl: string;
|
||||
fetchFn: typeof fetch;
|
||||
}): Promise<BytePlusTaskResponse> {
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: params.timeoutMs,
|
||||
label: `BytePlus video generation task ${params.taskId}`,
|
||||
});
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
const response = await fetchWithTimeout(
|
||||
`${params.baseUrl}/contents/generations/tasks/${params.taskId}`,
|
||||
@@ -80,7 +87,7 @@ async function pollBytePlusTask(params: {
|
||||
method: "GET",
|
||||
headers: params.headers,
|
||||
},
|
||||
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS }),
|
||||
params.fetchFn,
|
||||
);
|
||||
await assertOkOrThrowHttpError(response, "BytePlus video status request failed");
|
||||
@@ -96,7 +103,7 @@ async function pollBytePlusTask(params: {
|
||||
case "queued":
|
||||
case "running":
|
||||
default:
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
await waitProviderOperationPollInterval({ deadline, pollIntervalMs: POLL_INTERVAL_MS });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -183,6 +190,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
}
|
||||
|
||||
const fetchFn = fetch;
|
||||
const deadline = createProviderOperationDeadline({
|
||||
timeoutMs: req.timeoutMs,
|
||||
label: "BytePlus video generation",
|
||||
});
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: resolveBytePlusVideoBaseUrl(req),
|
||||
@@ -261,7 +272,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
url: `${baseUrl}/contents/generations/tasks`,
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: req.timeoutMs,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
@@ -276,7 +290,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
const completed = await pollBytePlusTask({
|
||||
taskId,
|
||||
headers,
|
||||
timeoutMs: req.timeoutMs,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
baseUrl,
|
||||
fetchFn,
|
||||
});
|
||||
@@ -286,7 +303,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
}
|
||||
const video = await downloadBytePlusVideo({
|
||||
url: videoUrl,
|
||||
timeoutMs: req.timeoutMs,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
}),
|
||||
fetchFn,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"description": "OpenClaw Codex harness and model provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -24,10 +24,10 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.12"
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.12"
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.12"
|
||||
"openclaw": ">=2026.4.15-beta.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -52,10 +52,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.12"
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.12"
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -475,8 +475,8 @@ describe("Discord model picker interactions", () => {
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
|
||||
const mismatchLog = verboseSpy.mock.calls.find((call) =>
|
||||
String(call[0] ?? "").includes("model picker override mismatch"),
|
||||
const mismatchLog = verboseSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("model picker override mismatch"),
|
||||
)?.[0];
|
||||
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ type CreateClientFn = (
|
||||
handlers: ConstructorParameters<typeof Client>[1],
|
||||
plugins: ConstructorParameters<typeof Client>[2],
|
||||
) => Client;
|
||||
type CarbonEventQueueOptions = NonNullable<ConstructorParameters<typeof Client>[0]["eventQueue"]>;
|
||||
|
||||
type ListenerCompatClient = Client & {
|
||||
plugins?: Array<{ id: string; plugin: Plugin }>;
|
||||
@@ -116,7 +117,10 @@ export function createDiscordMonitorClient(params: {
|
||||
modals: Modal[];
|
||||
voiceEnabled: boolean;
|
||||
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0] & {
|
||||
eventQueue?: { listenerTimeout?: number };
|
||||
eventQueue?: Pick<
|
||||
CarbonEventQueueOptions,
|
||||
"listenerTimeout" | "maxQueueSize" | "maxConcurrency"
|
||||
>;
|
||||
};
|
||||
runtime: RuntimeEnv;
|
||||
createClient: CreateClientFn;
|
||||
@@ -145,8 +149,9 @@ export function createDiscordMonitorClient(params: {
|
||||
// Discord normalization/enqueue work).
|
||||
const eventQueueOpts = {
|
||||
listenerTimeout: 120_000,
|
||||
slowListenerThreshold: 30_000,
|
||||
...params.discordConfig.eventQueue,
|
||||
};
|
||||
} satisfies CarbonEventQueueOptions;
|
||||
const readyListener = createDiscordStatusReadyListener({
|
||||
discordConfig: params.discordConfig,
|
||||
getAutoPresenceController: () => autoPresenceController,
|
||||
|
||||
@@ -97,21 +97,23 @@ describe("monitorDiscordProvider", () => {
|
||||
) => Promise<{ status: string; reason?: string }>;
|
||||
};
|
||||
|
||||
const getConstructedEventQueue = (): { listenerTimeout?: number } | undefined => {
|
||||
const getConstructedEventQueue = ():
|
||||
| { listenerTimeout?: number; slowListenerThreshold?: number }
|
||||
| undefined => {
|
||||
expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1);
|
||||
const opts = clientConstructorOptionsMock.mock.calls[0]?.[0] as {
|
||||
eventQueue?: { listenerTimeout?: number };
|
||||
eventQueue?: { listenerTimeout?: number; slowListenerThreshold?: number };
|
||||
};
|
||||
return opts.eventQueue;
|
||||
};
|
||||
|
||||
const getConstructedClientOptions = (): {
|
||||
eventQueue?: { listenerTimeout?: number };
|
||||
eventQueue?: { listenerTimeout?: number; slowListenerThreshold?: number };
|
||||
} => {
|
||||
expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1);
|
||||
return (
|
||||
(clientConstructorOptionsMock.mock.calls[0]?.[0] as {
|
||||
eventQueue?: { listenerTimeout?: number };
|
||||
eventQueue?: { listenerTimeout?: number; slowListenerThreshold?: number };
|
||||
}) ?? {}
|
||||
);
|
||||
};
|
||||
@@ -573,14 +575,17 @@ describe("monitorDiscordProvider", () => {
|
||||
expect(drained[0]?.message).toContain("4014");
|
||||
});
|
||||
|
||||
it("passes default eventQueue.listenerTimeout of 120s to Carbon Client", async () => {
|
||||
it("passes OpenClaw EventQueue defaults to Carbon Client", async () => {
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime: baseRuntime(),
|
||||
});
|
||||
|
||||
const eventQueue = getConstructedEventQueue();
|
||||
expect(eventQueue).toEqual({ listenerTimeout: 120_000 });
|
||||
expect(eventQueue).toEqual({
|
||||
listenerTimeout: 120_000,
|
||||
slowListenerThreshold: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards custom eventQueue config from discord config to Carbon Client", async () => {
|
||||
|
||||
@@ -16,5 +16,8 @@ const {
|
||||
setRuntime: setDiscordRuntime,
|
||||
tryGetRuntime: getOptionalDiscordRuntime,
|
||||
getRuntime: getDiscordRuntime,
|
||||
} = createPluginRuntimeStore<DiscordRuntime>("Discord runtime not initialized");
|
||||
} = createPluginRuntimeStore<DiscordRuntime>({
|
||||
pluginId: "discord",
|
||||
errorMessage: "Discord runtime not initialized",
|
||||
});
|
||||
export { getDiscordRuntime, getOptionalDiscordRuntime, setDiscordRuntime };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/duckduckgo-plugin",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw DuckDuckGo plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/elevenlabs-speech",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw ElevenLabs speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Exa plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fal-provider",
|
||||
"version": "2026.4.12",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw fal provider plugin",
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user