mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 00:11:53 +08:00
Compare commits
1 Commits
fix/plugin
...
codex/exte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab7fdfd03 |
@@ -1,87 +0,0 @@
|
||||
---
|
||||
name: openclaw-ghsa-maintainer
|
||||
description: Maintainer workflow for OpenClaw GitHub Security Advisories (GHSA). Use when Codex needs to inspect, patch, validate, or publish a repo advisory, verify private-fork state, prepare advisory Markdown or JSON payloads safely, handle GHSA API-specific publish constraints, or confirm advisory publish success.
|
||||
---
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
|
||||
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
|
||||
|
||||
## Respect advisory guardrails
|
||||
|
||||
- Before reviewing or publishing a repo advisory, read `SECURITY.md`.
|
||||
- Ask permission before any publish action.
|
||||
- Treat this skill as GHSA-only. Do not use it for stable or beta release work.
|
||||
|
||||
## Fetch and inspect advisory state
|
||||
|
||||
Fetch the current advisory and the latest published npm version:
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
npm view openclaw version --userconfig "$(mktemp)"
|
||||
```
|
||||
|
||||
Use the fetch output to confirm the advisory state, linked private fork, and vulnerability payload shape before patching.
|
||||
|
||||
## Verify private fork PRs are closed
|
||||
|
||||
Before publishing, verify that the advisory's private fork has no open PRs:
|
||||
|
||||
```bash
|
||||
fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)
|
||||
gh pr list -R "$fork" --state open
|
||||
```
|
||||
|
||||
The PR list must be empty before publish.
|
||||
|
||||
## Prepare advisory Markdown and JSON safely
|
||||
|
||||
- Write advisory Markdown via heredoc to a temp file. Do not use escaped `\n` strings.
|
||||
- Build PATCH payload JSON with `jq`, not hand-escaped shell JSON.
|
||||
|
||||
Example pattern:
|
||||
|
||||
```bash
|
||||
cat > /tmp/ghsa.desc.md <<'EOF'
|
||||
<markdown description>
|
||||
EOF
|
||||
|
||||
jq -n --rawfile desc /tmp/ghsa.desc.md \
|
||||
'{summary,severity,description:$desc,vulnerabilities:[...]}' \
|
||||
> /tmp/ghsa.patch.json
|
||||
```
|
||||
|
||||
## Apply PATCH calls in the correct sequence
|
||||
|
||||
- Do not set `severity` and `cvss_vector_string` in the same PATCH call.
|
||||
- Use separate calls when the advisory requires both fields.
|
||||
- Publish by PATCHing the advisory and setting `"state":"published"`. There is no separate `/publish` endpoint.
|
||||
|
||||
Example shape:
|
||||
|
||||
```bash
|
||||
gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> \
|
||||
--input /tmp/ghsa.patch.json
|
||||
```
|
||||
|
||||
## Publish and verify success
|
||||
|
||||
After publish, re-fetch the advisory and confirm:
|
||||
|
||||
- `state=published`
|
||||
- `published_at` is set
|
||||
- the description does not contain literal escaped `\\n`
|
||||
|
||||
Verification pattern:
|
||||
|
||||
```bash
|
||||
gh api /repos/openclaw/openclaw/security-advisories/<GHSA>
|
||||
jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||
```
|
||||
|
||||
## Common GHSA footguns
|
||||
|
||||
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
|
||||
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
|
||||
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
name: openclaw-parallels-smoke
|
||||
description: End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.
|
||||
---
|
||||
|
||||
# OpenClaw Parallels Smoke
|
||||
|
||||
Use this skill for Parallels guest workflows and smoke interpretation. Do not load it for normal repo work.
|
||||
|
||||
## Global rules
|
||||
|
||||
- Use the snapshot most closely matching the requested fresh baseline.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet.
|
||||
- Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback.
|
||||
- Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression.
|
||||
- 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.
|
||||
|
||||
## macOS flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||
- Target the snapshot closest to `macOS 26.3.1 fresh`.
|
||||
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
|
||||
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
|
||||
|
||||
## Windows flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:windows`
|
||||
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
|
||||
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
|
||||
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
|
||||
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
|
||||
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
|
||||
|
||||
## Linux flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:linux`
|
||||
- Use the snapshot closest to fresh `Ubuntu 24.04.3 ARM64`.
|
||||
- Use plain `prlctl exec`; `--current-user` is not the right transport on this snapshot.
|
||||
- Fresh snapshots may be missing `curl`, and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates`.
|
||||
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
|
||||
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
|
||||
- `prlctl exec` reaps detached Linux child processes on this snapshot, so detached background gateway runs are not trustworthy smoke signals.
|
||||
|
||||
## Discord roundtrip
|
||||
|
||||
- Discord roundtrip is optional and should be enabled with:
|
||||
- `--discord-token-env`
|
||||
- `--discord-guild-id`
|
||||
- `--discord-channel-id`
|
||||
- Keep the Discord token only in a host env var.
|
||||
- Use installed `openclaw message send/read`, not `node openclaw.mjs message ...`.
|
||||
- Set `channels.discord.guilds` as one JSON object, not dotted config paths with snowflakes.
|
||||
- Avoid long `prlctl enter` or expect-driven Discord config scripts; prefer `prlctl exec --current-user /bin/sh -lc ...` with short commands.
|
||||
- For a narrower macOS-only Discord proof run, the existing `parallels-discord-roundtrip` skill is the deep-dive companion.
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: openclaw-pr-maintainer
|
||||
description: Maintainer workflow for reviewing, triaging, preparing, closing, or landing OpenClaw pull requests and related issues. Use when Codex needs to validate bug-fix claims, search for related issues or PRs, apply or recommend close/reason labels, prepare GitHub comments safely, check review-thread follow-up, or perform maintainer-style PR decision making before merge or closure.
|
||||
---
|
||||
|
||||
# OpenClaw PR Maintainer
|
||||
|
||||
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
|
||||
|
||||
## Apply close and triage labels correctly
|
||||
|
||||
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
|
||||
- Do not manually close plus manually comment for these reasons.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
- Current reasons:
|
||||
- `r: skill`
|
||||
- `r: support`
|
||||
- `r: no-ci-pr`
|
||||
- `r: too-many-prs`
|
||||
- `r: testflight`
|
||||
- `r: third-party-extension`
|
||||
- `r: moltbook`
|
||||
- `r: spam`
|
||||
- `invalid`
|
||||
- `dirty` for PRs only
|
||||
|
||||
## Enforce the bug-fix evidence bar
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before landing, require:
|
||||
1. symptom evidence such as a repro, logs, or a failing test
|
||||
2. a verified root cause in code with file/line
|
||||
3. a fix that touches the implicated code path
|
||||
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
|
||||
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
|
||||
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
|
||||
|
||||
## Handle GitHub text safely
|
||||
|
||||
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
|
||||
- Do not use `gh issue/pr comment -b "..."` when the body contains backticks or shell characters. Prefer a single-quoted heredoc.
|
||||
- Do not wrap issue or PR refs like `#24643` in backticks when you want auto-linking.
|
||||
- PR landing comments should include clickable full commit links for landed and source SHAs when present.
|
||||
|
||||
## Search broadly before deciding
|
||||
|
||||
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
|
||||
- Use `--repo openclaw/openclaw` with `--match title,body` first.
|
||||
- Add `--match comments` when triaging follow-up discussion.
|
||||
- Do not stop at the first 500 results when the task requires a full search.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||
gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"
|
||||
gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
--json number,title,state,url,updatedAt -- "auto update" \
|
||||
--jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'
|
||||
```
|
||||
|
||||
## Follow PR review and landing hygiene
|
||||
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
|
||||
|
||||
## Extra safety
|
||||
|
||||
- If a close or reopen action would affect more than 5 PRs, ask for explicit confirmation with the exact count and target query first.
|
||||
- `sync` means: if the tree is dirty, commit all changes with a sensible Conventional Commit message, then `git pull --rebase`, then `git push`. Stop if rebase conflicts cannot be resolved safely.
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: openclaw-release-maintainer
|
||||
description: Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
|
||||
---
|
||||
|
||||
# OpenClaw Release Maintainer
|
||||
|
||||
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
|
||||
|
||||
## Respect release guardrails
|
||||
|
||||
- Do not change version numbers without explicit operator approval.
|
||||
- Ask permission before any npm publish or release step.
|
||||
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
|
||||
|
||||
## Keep release channel naming aligned
|
||||
|
||||
- `stable`: tagged releases only, with npm dist-tag `latest`
|
||||
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
|
||||
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
|
||||
- `dev`: moving head on `main`
|
||||
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||
|
||||
## Handle versions and release files consistently
|
||||
|
||||
- Version locations include:
|
||||
- `package.json`
|
||||
- `apps/android/app/build.gradle.kts`
|
||||
- `apps/ios/Sources/Info.plist`
|
||||
- `apps/ios/Tests/Info.plist`
|
||||
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
|
||||
- `docs/install/updating.md`
|
||||
- Peekaboo Xcode project and plist version fields
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- When cutting a mac release with a beta GitHub prerelease:
|
||||
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||
- use release notes from the matching `CHANGELOG.md` version section
|
||||
- attach at least the zip and dSYM zip, plus dmg if available
|
||||
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first
|
||||
- `### Fixes` deduped with user-facing fixes first
|
||||
|
||||
## Run publish-time validation
|
||||
|
||||
Before tagging or publishing, run:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/release-check.ts
|
||||
pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
For a non-root smoke path:
|
||||
|
||||
```bash
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
```
|
||||
|
||||
## Use the right auth flow
|
||||
|
||||
- Core `openclaw` publish uses GitHub trusted publishing.
|
||||
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||
|
||||
## GHSA advisory work
|
||||
|
||||
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
name: parallels-discord-roundtrip
|
||||
description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback.
|
||||
---
|
||||
|
||||
# Parallels Discord Roundtrip
|
||||
|
||||
Use when macOS Parallels smoke must prove Discord two-way delivery end to end.
|
||||
|
||||
## Goal
|
||||
|
||||
Cover:
|
||||
|
||||
- install on fresh macOS snapshot
|
||||
- onboard + gateway health
|
||||
- guest `message send` to Discord
|
||||
- host sees that message on Discord
|
||||
- host posts a new Discord message
|
||||
- guest `message read` sees that new message
|
||||
|
||||
## Inputs
|
||||
|
||||
- host env var with Discord bot token
|
||||
- Discord guild ID
|
||||
- Discord channel ID
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
## Preferred run
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PARALLELS_DISCORD_TOKEN="$(
|
||||
ssh peters-mac-studio-1 'jq -r ".channels.discord.token" ~/.openclaw/openclaw.json' | tr -d '\n'
|
||||
)"
|
||||
|
||||
pnpm test:parallels:macos \
|
||||
--discord-token-env OPENCLAW_PARALLELS_DISCORD_TOKEN \
|
||||
--discord-guild-id 1456350064065904867 \
|
||||
--discord-channel-id 1456744319972282449 \
|
||||
--json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Snapshot target: closest to `macOS 26.3.1 fresh`.
|
||||
- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint.
|
||||
- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots.
|
||||
- Harness configures Discord inside the guest; no checked-in token/config.
|
||||
- Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way.
|
||||
- Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated like array indexes.
|
||||
- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase.
|
||||
- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load.
|
||||
- Harness cleanup deletes the temporary Discord smoke messages at exit.
|
||||
- Per-phase logs: `/tmp/openclaw-parallels-smoke.*`
|
||||
- Machine summary: pass `--json`
|
||||
- If roundtrip flakes, inspect `fresh.discord-roundtrip.log` and `discord-last-readback.json` in the run dir first.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- fresh lane or upgrade lane requested passes
|
||||
- summary reports `discord=pass` for that lane
|
||||
- guest outbound nonce appears in channel history
|
||||
- host inbound nonce appears in `openclaw message read` output
|
||||
46
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
46
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,8 +7,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence.
|
||||
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
- type: dropdown
|
||||
id: bug_type
|
||||
attributes:
|
||||
@@ -24,35 +23,35 @@ body:
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is broken, based only on observed evidence. If the evidence is insufficient, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: After upgrading from 2026.2.10 to 2026.2.17, Telegram thread replies stopped posting; reproduced twice and confirmed by gateway logs.
|
||||
description: One-sentence statement of what is broken.
|
||||
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the shortest deterministic repro path supported by direct observation. If the repro path cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
description: Provide the shortest deterministic repro path.
|
||||
placeholder: |
|
||||
1. Start OpenClaw 2026.2.17 with the attached config.
|
||||
2. Send a Telegram thread reply in the affected chat.
|
||||
3. Observe no reply and confirm the attached `reply target not found` log line.
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: State the expected result using a concrete reference such as prior observed behavior, attached docs, or a known-good version. If no grounded reference exists, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: In 2026.2.10, the agent posted replies in the same Telegram thread under the same workflow.
|
||||
description: What should happen if the bug does not exist.
|
||||
placeholder: Agent posts a reply in the same thread.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Describe only the observed result, including user-visible errors and cited evidence. If the observed result cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: No reply is posted in the thread; the attached gateway log shows `reply target not found` at 14:23:08 UTC.
|
||||
description: What happened instead, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -93,6 +92,12 @@ body:
|
||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: config_location
|
||||
attributes:
|
||||
label: Config file / key location
|
||||
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
|
||||
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
|
||||
- type: textarea
|
||||
id: provider_setup_details
|
||||
attributes:
|
||||
@@ -106,28 +111,27 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
|
||||
description: Include redacted logs/screenshots/recordings that prove the behavior.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact and severity
|
||||
description: |
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence using only observed evidence.
|
||||
If any part cannot be grounded from the evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||
placeholder: |
|
||||
Affected: Telegram group users on 2026.2.17
|
||||
Severity: High (blocks thread replies)
|
||||
Frequency: 4/4 observed attempts
|
||||
Consequence: Agents do not respond in the affected threads
|
||||
Affected: Telegram group users on <version>
|
||||
Severity: High (blocks replies)
|
||||
Frequency: 100% repro
|
||||
Consequence: Agents cannot respond in threads
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any remaining grounded context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions when observed. If there is not enough evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
placeholder: Last known good version 2026.2.10, first known bad version 2026.2.17, temporary workaround is sending a top-level message instead of a thread reply.
|
||||
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
|
||||
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
|
||||
|
||||
20
.github/labeler.yml
vendored
20
.github/labeler.yml
vendored
@@ -198,6 +198,14 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/diagnostics-otel/**"
|
||||
"extensions: google-antigravity-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-antigravity-auth/**"
|
||||
"extensions: google-gemini-cli-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-gemini-cli-auth/**"
|
||||
"extensions: llm-task":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -234,10 +242,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/byteplus/**"
|
||||
"extensions: anthropic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/anthropic/**"
|
||||
"extensions: cloudflare-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -254,10 +258,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kilocode/**"
|
||||
"extensions: openai":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/openai/**"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -314,7 +314,3 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
"extensions: fal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fal/**"
|
||||
|
||||
231
.github/workflows/ci.yml
vendored
231
.github/workflows/ci.yml
vendored
@@ -78,50 +78,6 @@ jobs:
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
changed-extensions:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure changed-extensions base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
install-deps: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Detect changed extensions
|
||||
id: changed
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
|
||||
|
||||
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
|
||||
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||
EOF
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope]
|
||||
@@ -206,9 +162,6 @@ jobs:
|
||||
- runtime: node
|
||||
task: channels
|
||||
command: pnpm test:channels
|
||||
- runtime: node
|
||||
task: contracts
|
||||
command: pnpm test:contracts
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
@@ -252,31 +205,6 @@ jobs:
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
extension-fast:
|
||||
name: "extension-fast (${{ matrix.extension }})"
|
||||
needs: [docs-scope, changed-scope, changed-extensions]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run changed extension tests
|
||||
env:
|
||||
OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }}
|
||||
run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION"
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
@@ -304,146 +232,6 @@ jobs:
|
||||
- name: Enforce safe external URL opening policy
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
plugin-extension-boundary:
|
||||
name: "plugin-extension-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run plugin extension boundary guard
|
||||
run: pnpm run lint:plugins:no-extension-imports
|
||||
|
||||
web-search-provider-boundary:
|
||||
name: "web-search-provider-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run web search provider boundary guard
|
||||
run: pnpm run lint:web-search-provider-boundaries
|
||||
|
||||
extension-src-outside-plugin-sdk-boundary:
|
||||
name: "extension-src-outside-plugin-sdk-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run extension src boundary guard
|
||||
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
|
||||
|
||||
extension-plugin-sdk-internal-boundary:
|
||||
name: "extension-plugin-sdk-internal-boundary"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run extension plugin-sdk-internal guard
|
||||
run: pnpm run lint:extensions:no-plugin-sdk-internal
|
||||
|
||||
build-smoke:
|
||||
name: "build-smoke"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Smoke test CLI launcher help
|
||||
run: node openclaw.mjs --help
|
||||
|
||||
- name: Smoke test CLI launcher status json
|
||||
run: node openclaw.mjs status --json --timeout 1
|
||||
|
||||
- name: Smoke test built bundled plugin singleton
|
||||
run: pnpm test:build:singleton
|
||||
|
||||
- name: Check CLI startup memory
|
||||
run: pnpm test:startup:memory
|
||||
|
||||
gateway-watch-regression:
|
||||
name: "gateway-watch-regression"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Run gateway watch regression harness
|
||||
run: pnpm test:gateway:watch-regression
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gateway-watch-regression
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
@@ -571,30 +359,21 @@ jobs:
|
||||
run: pre-commit run --all-files detect-private-key
|
||||
|
||||
- name: Audit changed GitHub workflows with zizmor
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "No usable base SHA detected; skipping zizmor."
|
||||
exit 0
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mapfile -t workflow_files < <(
|
||||
git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml'
|
||||
)
|
||||
mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml')
|
||||
if [ "${#workflow_files[@]}" -eq 0 ]; then
|
||||
echo "No workflow changes detected; skipping zizmor."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
||||
pre-commit run zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Audit production dependencies
|
||||
|
||||
45
.github/workflows/install-smoke.yml
vendored
45
.github/workflows/install-smoke.yml
vendored
@@ -62,57 +62,24 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
# This smoke validates that the build-arg path preinstalls selected
|
||||
# extension deps and that matrix plugin discovery stays healthy in the
|
||||
# final runtime image.
|
||||
# This smoke only validates that the build-arg path preinstalls selected
|
||||
# extension deps without breaking image build or basic CLI startup. It
|
||||
# does not exercise runtime loading/registration of diagnostics-otel.
|
||||
- name: Build extension Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
tags: openclaw-ext-smoke:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Smoke test Dockerfile with matrix extension build arg
|
||||
- name: Smoke test Dockerfile with extension build arg
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
const Module = require(\"node:module\");
|
||||
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
|
||||
requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\");
|
||||
requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\");
|
||||
const { spawnSync } = require(\"node:child_process\");
|
||||
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
|
||||
if (run.status !== 0) {
|
||||
process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
|
||||
process.exit(run.status ?? 1);
|
||||
}
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
|
||||
if (!matrix) {
|
||||
throw new Error(\"matrix plugin missing from bundled plugin list\");
|
||||
}
|
||||
const matrixDiag = (parsed.diagnostics || []).filter(
|
||||
(diag) =>
|
||||
typeof diag.source === \"string\" &&
|
||||
diag.source.includes(\"/extensions/matrix\") &&
|
||||
typeof diag.message === \"string\" &&
|
||||
diag.message.includes(\"extension entry escapes package directory\"),
|
||||
);
|
||||
if (matrixDiag.length > 0) {
|
||||
throw new Error(
|
||||
\"unexpected matrix diagnostics: \" +
|
||||
matrixDiag.map((diag) => diag.message).join(\"; \"),
|
||||
);
|
||||
}
|
||||
"
|
||||
'
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Build installer smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
|
||||
214
.github/workflows/plugin-npm-release.yml
vendored
214
.github/workflows/plugin-npm-release.yml
vendored
@@ -1,214 +0,0 @@
|
||||
name: Plugin NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/workflows/plugin-npm-release.yml"
|
||||
- "extensions/**"
|
||||
- "package.json"
|
||||
- "scripts/lib/plugin-npm-release.ts"
|
||||
- "scripts/plugin-npm-publish.sh"
|
||||
- "scripts/plugin-npm-release-check.ts"
|
||||
- "scripts/plugin-npm-release-plan.ts"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_scope:
|
||||
description: Publish the selected plugins or all publishable plugins from the ref
|
||||
required: true
|
||||
default: selected
|
||||
type: choice
|
||||
options:
|
||||
- selected
|
||||
- all-publishable
|
||||
ref:
|
||||
description: Commit SHA on main to publish from (copy from the preview run)
|
||||
required: true
|
||||
type: string
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preview_plugins_npm:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_sha: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- 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"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor HEAD origin/main
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
release_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
release_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
pnpm release:plugins:npm:check -- "${release_args[@]}"
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
|
||||
else
|
||||
pnpm release:plugins:npm:check
|
||||
fi
|
||||
|
||||
- name: Resolve plugin release plan
|
||||
id: plan
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
|
||||
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
|
||||
HEAD_REF: ${{ steps.ref.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
if [[ -n "${PUBLISH_SCOPE}" ]]; then
|
||||
plan_args=(--selection-mode "${PUBLISH_SCOPE}")
|
||||
if [[ -n "${RELEASE_PLUGINS}" ]]; then
|
||||
plan_args+=(--plugins "${RELEASE_PLUGINS}")
|
||||
fi
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json
|
||||
elif [[ -n "${BASE_REF}" ]]; then
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json
|
||||
else
|
||||
node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json
|
||||
fi
|
||||
|
||||
cat .local/plugin-npm-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- 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: Preview publish command
|
||||
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview npm pack contents
|
||||
working-directory: ${{ matrix.plugin.packageDir }}
|
||||
run: npm pack --dry-run --json --ignore-scripts
|
||||
|
||||
publish_plugins_npm:
|
||||
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- 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: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,12 +4,10 @@ node_modules
|
||||
docker-compose.override.yml
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
dist-runtime
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
__openclaw_vitest__/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
@@ -31,7 +29,6 @@ apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
apps/android/.kotlin/
|
||||
apps/android/benchmark/results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
@@ -101,6 +98,8 @@ USER.md
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
skills-lock.json
|
||||
|
||||
@@ -134,6 +133,3 @@ ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
||||
# Deprecated changelog fragment workflow
|
||||
changelog/fragments/
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
**/node_modules/
|
||||
docs/.generated/
|
||||
|
||||
3
.npmrc
3
.npmrc
@@ -1,4 +1 @@
|
||||
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
|
||||
# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
|
||||
# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
|
||||
node-linker=hoisted
|
||||
|
||||
188
AGENTS.md
188
AGENTS.md
@@ -2,8 +2,45 @@
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
|
||||
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
|
||||
|
||||
## Auto-close labels (issues and PRs)
|
||||
|
||||
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
|
||||
- Do not manually close + manually comment for these reasons.
|
||||
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
|
||||
- `r: skill`: close with guidance to publish skills on Clawhub.
|
||||
- `r: support`: close with redirect to Discord support + stuck FAQ.
|
||||
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
|
||||
- `r: too-many-prs`: close when author exceeds active PR limit.
|
||||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
||||
- `r: spam`: close + lock as spam (`lock_reason: spam`).
|
||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
||||
|
||||
## PR truthfulness and bug-fix validation
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
|
||||
- Minimum merge gate for bug-fix PRs:
|
||||
1. symptom evidence (repro/log/failing test),
|
||||
2. verified root cause in code with file/line,
|
||||
3. fix touches the implicated code path,
|
||||
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
|
||||
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
|
||||
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
@@ -11,7 +48,6 @@
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
|
||||
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
|
||||
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
@@ -70,10 +106,6 @@
|
||||
- Format check: `pnpm format` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
- Hard gate: before any commit, `pnpm check` MUST be run and MUST pass for the change being committed.
|
||||
- Hard gate: before any push to `main`, `pnpm check` MUST be run and MUST pass, and `pnpm test` MUST be run and MUST pass.
|
||||
- Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`.
|
||||
- Hard gate: do not commit or push with failing format, lint, type, build, or required test checks.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
@@ -82,9 +114,6 @@
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
||||
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
||||
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
|
||||
- Extension package boundary guardrail: inside `extensions/<id>/**`, do not use relative imports/exports that resolve outside that same `extensions/<id>` package root. If shared code belongs in the plugin SDK, import `openclaw/plugin-sdk/<subpath>` instead of reaching into `src/plugin-sdk/**` or other repo paths via `../`.
|
||||
- Extension API surface rule: `openclaw/plugin-sdk/<subpath>` is the only public cross-package contract for extension-facing SDK code. If an extension needs a new seam, add a public subpath first; do not reach into `src/plugin-sdk/**` by relative path.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
@@ -94,23 +123,23 @@
|
||||
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
||||
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
|
||||
|
||||
## Release / Advisory Workflows
|
||||
## Release Channels (Naming)
|
||||
|
||||
- Use `$openclaw-release-maintainer` at `.agents/skills/openclaw-release-maintainer/SKILL.md` for release naming, version coordination, release auth, and changelog-backed release-note workflows.
|
||||
- Use `$openclaw-ghsa-maintainer` at `.agents/skills/openclaw-ghsa-maintainer/SKILL.md` for GHSA advisory inspection, patch/publish flow, private-fork checks, and GHSA API validation.
|
||||
- Release and publish remain explicit-approval actions even when using the skill.
|
||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
||||
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||
- Full kit + what’s covered: `docs/testing.md`.
|
||||
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
|
||||
- Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section.
|
||||
- Changelog attribution: use at most one contributor mention per line; prefer `Thanks @author` and do not also add `by @author` on the same entry.
|
||||
@@ -119,9 +148,7 @@
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md` for maintainer PR triage, review, close, search, and landing workflows.
|
||||
- This includes auto-close labels, bug-fix evidence gates, GitHub comment/search footguns, and maintainer PR decision flow.
|
||||
- For the repo's end-to-end maintainer PR workflow, use `$openclaw-pr-maintainer` at `.agents/skills/openclaw-pr-maintainer/SKILL.md`.
|
||||
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
|
||||
|
||||
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
@@ -130,30 +157,100 @@
|
||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
## Git Notes
|
||||
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
- Agents MUST NOT create or push merge commits on `main`. If `main` has advanced, rebase local commits onto the latest `origin/main` before pushing.
|
||||
- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query.
|
||||
|
||||
## GitHub Search (`gh`)
|
||||
|
||||
- Prefer targeted keyword search before proposing new work or duplicating fixes.
|
||||
- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads.
|
||||
- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
||||
- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"`
|
||||
- Structured output example:
|
||||
`gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'`
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
|
||||
|
||||
## Local Runtime / Platform Notes
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
- Before reviewing security advisories, read `SECURITY.md`.
|
||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- Private fork PRs must be closed:
|
||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
||||
`gh pr list -R "$fork" --state open` (must be empty)
|
||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
||||
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
|
||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
- Use `$openclaw-parallels-smoke` at `.agents/skills/openclaw-parallels-smoke/SKILL.md` for Parallels smoke, rerun, upgrade, debug, and result-interpretation workflows across macOS, Windows, and Linux guests.
|
||||
- For the macOS Discord roundtrip deep dive, use the narrower `.agents/skills/parallels-discord-roundtrip/SKILL.md` companion skill.
|
||||
- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested.
|
||||
- Parallels beta smoke: use `--target-package-spec openclaw@<beta-version>` for the beta artifact, and pin the stable side with both `--install-version <stable-version>` and `--latest-version <stable-version>` for upgrade runs. npm dist-tags can move mid-run.
|
||||
- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane.
|
||||
- Parallels macOS smoke playbook:
|
||||
- `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`.
|
||||
- Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed.
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`.
|
||||
- All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times.
|
||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
||||
- Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green.
|
||||
- Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially.
|
||||
- Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading.
|
||||
- Parallels Windows smoke playbook:
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero.
|
||||
- Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded.
|
||||
- Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path.
|
||||
- Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy.
|
||||
- Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`.
|
||||
- Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails.
|
||||
- Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path.
|
||||
- Parallels Linux smoke playbook:
|
||||
- Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes.
|
||||
- Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there.
|
||||
- Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths.
|
||||
- Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`.
|
||||
- This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`.
|
||||
- Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell.
|
||||
- `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed.
|
||||
- When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure.
|
||||
- Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part.
|
||||
- Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`.
|
||||
- Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself.
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- If you need local-only `.agents` ignores, use `.git/info/exclude` instead of repo `.gitignore`.
|
||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
@@ -168,20 +265,6 @@
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||
- Lobster palette: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
|
||||
## Collaboration / Safety Notes
|
||||
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
@@ -192,12 +275,41 @@
|
||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||
- Only ask when changes are semantic (logic/data/behavior).
|
||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||
|
||||
## Release Auth
|
||||
|
||||
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
|
||||
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
|
||||
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
- When cutting a mac release with beta GitHub prerelease:
|
||||
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
|
||||
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
|
||||
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
|
||||
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
|
||||
|
||||
- Keep top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first.
|
||||
- `### Fixes` deduped and ranked with user-facing fixes first.
|
||||
- Before tagging/publishing, run:
|
||||
- `node --import tsx scripts/release-check.ts`
|
||||
- `pnpm release:check`
|
||||
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.
|
||||
|
||||
467
CHANGELOG.md
467
CHANGELOG.md
@@ -6,173 +6,73 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.
|
||||
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
|
||||
- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode.
|
||||
- Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches.
|
||||
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
||||
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
|
||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. (#47630) Thanks @vincentkoc.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) Thanks @Takhoffman.
|
||||
- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. (#48058) Thanks @vincentkoc.
|
||||
- Commands/plugins: add owner-gated `/plugins` and `/plugin` chat commands for plugin list/show and enable/disable flows, alongside explicit `commands.plugins` config gating. Thanks @vincentkoc.
|
||||
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lixuankai.
|
||||
- Android/nodes: add `sms.search` plus shared SMS permission wiring so Android nodes can search device text messages through the gateway. (#48299) Thanks @lixuankai.
|
||||
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
||||
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
||||
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) Thanks @scoootscooob.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
||||
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
|
||||
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
|
||||
- Browser/existing-session: support `browser.profiles.<name>.userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark.
|
||||
- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese.
|
||||
- Models/OpenAI: add native forward-compat support for `gpt-5.4-mini` and `gpt-5.4-nano` in the OpenAI provider catalog, runtime resolution, and reasoning capability gates. Thanks @vincentkoc.
|
||||
- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import.
|
||||
- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant.
|
||||
- Plugins/testing: add a public `openclaw/plugin-sdk/testing` surface for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers.
|
||||
- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor.
|
||||
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
|
||||
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
|
||||
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
|
||||
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
|
||||
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
|
||||
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
|
||||
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
|
||||
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
|
||||
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
||||
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
|
||||
- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai.
|
||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
|
||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. (#46800) Thanks @vincentkoc.
|
||||
- Gateway/restart: defer externally signaled unmanaged restarts through the in-process idle drain, and preserve the restored subagent run as remap fallback during orphan recovery so resumed sessions do not duplicate work. (#47719) Thanks @joeykrug.
|
||||
- Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) Thanks @scoootscooob.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.
|
||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
|
||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
|
||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
|
||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
|
||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
|
||||
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
|
||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
||||
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
||||
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. (#46802) Thanks @vincentkoc.
|
||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. (#46816) Thanks @vincentkoc.
|
||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. (#46803) Thanks @vincentkoc.
|
||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. (#46799) Thanks @vincentkoc.
|
||||
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. (#46801) Thanks @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. (#47968) Thanks @Takhoffman.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||
- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. (#47968) Thanks @Takhoffman.
|
||||
- Feishu/webhooks: harden signed webhook verification to use constant-time signature comparison and keep malformed short signatures fail-closed in webhook E2E coverage.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) Thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus.
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) Thanks @obviyus.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman.
|
||||
- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire.
|
||||
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. (#47413) Thanks @vincentkoc.
|
||||
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) Thanks @gumadeiras.
|
||||
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) Thanks @luzhidong.
|
||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46580) Fixes #46532. Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#46596) Fixes #45777. Thanks @odysseus0.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
|
||||
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
|
||||
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk.
|
||||
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
|
||||
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
|
||||
- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman.
|
||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
|
||||
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
||||
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
||||
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk.
|
||||
- ACP/acpx: keep plugin-local backend installs under `extensions/acpx` in live repo checkouts so rebuilds no longer delete the runtime binary, and avoid package-lock churn during runtime repair.
|
||||
- Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman.
|
||||
- Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj.
|
||||
- Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx.
|
||||
- Agents/compaction: trigger overflow recovery from the tool-result guard once post-compaction context still exceeds the safe threshold, so long tool loops compact before the next model call hard-fails. (#29371) thanks @keshav55.
|
||||
- macOS/exec approvals: harden exec-host request HMAC verification to use a timing-safe compare and keep malformed or truncated signatures fail-closed in focused IPC auth coverage.
|
||||
- Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env.
|
||||
- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr.
|
||||
- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman.
|
||||
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
|
||||
- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007.
|
||||
- Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes.
|
||||
- Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev.
|
||||
- Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant.
|
||||
- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant.
|
||||
- Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
|
||||
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
|
||||
- macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67.
|
||||
- macOS/launch at login: stop emitting `KeepAlive` for the desktop app launch agent so OpenClaw no longer relaunches immediately after a manual quit while launch at login remains enabled. (#40213) Thanks @stablegenius49.
|
||||
- ACP/gateway startup: use direct Telegram and Discord startup/status helpers instead of routing probes through the plugin runtime, and prepend the selected daemon Node bin dir to service PATH so plugin-local installs can still find `npm` and `pnpm`.
|
||||
- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime.
|
||||
- Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing.
|
||||
- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus.
|
||||
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
|
||||
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
|
||||
- Plugins/runtime-api: pin extension runtime-api export surfaces with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
|
||||
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
|
||||
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
|
||||
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
|
||||
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
|
||||
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
|
||||
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
|
||||
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
|
||||
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
|
||||
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
|
||||
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
|
||||
|
||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
|
||||
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
|
||||
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
|
||||
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
|
||||
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
|
||||
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
|
||||
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
|
||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
|
||||
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
|
||||
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@@ -188,6 +88,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/sessions: add `sessionTarget: "current"` and `session:<id>` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF.
|
||||
- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
|
||||
@@ -223,7 +127,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
||||
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||
- CLI/startup: stop `openclaw devices list` and similar loopback gateway commands from failing during startup by isolating heavy import-time side effects from the normal CLI path. (#50212) Thanks @obviyus.
|
||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||
@@ -248,13 +151,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
|
||||
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
|
||||
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
|
||||
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
|
||||
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
|
||||
- Sessions/BlueBubbles/cron: persist outbound session routing and transcript mirroring for new targets, auto-create BlueBubbles chats before attachment sends, and only suppress isolated cron deliveries when the run started hours late instead of merely finishing late. (#50092)
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
@@ -346,16 +242,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte.
|
||||
- Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh.
|
||||
- Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu.
|
||||
- Agents/compaction safeguard: trim large kept `toolResult` payloads consistently for budgeting, pruning, and identifier seeding, then restore preserved payloads after prune so oversized safeguard summaries stay stable. (#44133) thanks @SayrWolfridge.
|
||||
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
|
||||
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
|
||||
- Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit.
|
||||
|
||||
- Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec.
|
||||
- Plugins/context engines: retry legacy lifecycle calls once without `sessionKey` when older plugins reject that field, memoize legacy mode after the first strict-schema fallback, and preserve non-compat runtime errors without retry. (#44779) thanks @hhhhao28.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
|
||||
@@ -377,6 +270,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
|
||||
- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
|
||||
@@ -487,15 +384,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
|
||||
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
|
||||
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
|
||||
- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025)
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
@@ -609,6 +497,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
|
||||
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`.
|
||||
@@ -679,7 +571,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
|
||||
- Web UI/config form: treat `additionalProperties: true` object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
|
||||
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
|
||||
- macOS/tray menu: keep injected sessions and device rows below the controls section so toggles and action buttons stay visible even when many sessions are active. (#38079) Thanks @bernesto.
|
||||
- Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so `requireMention` checks compare against current bot identity instead of stale config names, fixing missed `@bot` handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
|
||||
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
|
||||
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.
|
||||
@@ -934,10 +825,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||
- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
### Changes
|
||||
@@ -966,6 +853,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): keep canonical DM routing while preserving legacy DM session continuity on upgrade, and preserve provider-native `g-`/`u-` target ids in outbound send and directory flows so #33992 lands without breaking existing sessions or stored targets. (#33992) Thanks @darkamenosa.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077.
|
||||
@@ -1152,13 +1046,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
- Control UI/markdown recursion fallback: catch markdown parser failures and safely render escaped plain-text fallback instead of crashing the Control UI on pathological markdown history payloads. (#36445, fixes #36213) Thanks @BinHPdev.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
## 2026.3.1
|
||||
|
||||
### Changes
|
||||
@@ -1186,6 +1073,11 @@ Docs: https://docs.openclaw.ai
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
|
||||
@@ -1280,12 +1172,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
## 2026.2.27
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1560,6 +1447,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong.
|
||||
@@ -1623,10 +1514,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
- Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
@@ -1637,6 +1524,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
|
||||
- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
|
||||
@@ -1716,11 +1608,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
## 2026.2.23
|
||||
|
||||
### Changes
|
||||
@@ -1735,6 +1622,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed.
|
||||
- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305.
|
||||
@@ -1780,10 +1671,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc.
|
||||
- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
@@ -1808,6 +1695,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
|
||||
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
|
||||
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
|
||||
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc.
|
||||
@@ -2034,14 +1929,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Daemon: verify gateway health after daemon restart.
|
||||
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
|
||||
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
|
||||
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
|
||||
## 2026.2.21
|
||||
|
||||
### Changes
|
||||
@@ -2690,6 +2577,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
|
||||
- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||
@@ -2792,10 +2683,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
### Changes
|
||||
@@ -2807,6 +2694,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
|
||||
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
|
||||
@@ -2889,10 +2780,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
|
||||
- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
|
||||
|
||||
## 2026.2.9
|
||||
|
||||
### Added
|
||||
@@ -2982,12 +2869,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Changes
|
||||
|
||||
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
|
||||
@@ -3003,6 +2884,12 @@ Docs: https://docs.openclaw.ai
|
||||
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
|
||||
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Fixes
|
||||
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
@@ -3301,6 +3188,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
|
||||
- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald.
|
||||
@@ -3353,10 +3244,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
|
||||
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
## 2026.1.24-3
|
||||
|
||||
### Fixes
|
||||
@@ -3588,6 +3475,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
@@ -3610,11 +3502,6 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
- Embedded runner: forward sender identity into attempt execution so Feishu doc auto-grant receives requester context again. (#32915) Thanks @cszhouwei.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
@@ -3696,6 +3583,10 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs.
|
||||
@@ -3794,10 +3685,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
@@ -3816,6 +3703,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
|
||||
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
@@ -3907,15 +3803,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
|
||||
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
@@ -3925,6 +3812,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
|
||||
### Changes
|
||||
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
|
||||
@@ -3997,11 +3889,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
### Highlights
|
||||
@@ -4138,6 +4025,10 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||
|
||||
### Installer
|
||||
|
||||
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
||||
@@ -4163,10 +4054,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
||||
|
||||
### Installer
|
||||
|
||||
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
@@ -4218,6 +4105,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
|
||||
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
|
||||
|
||||
### Installer
|
||||
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
||||
@@ -4256,15 +4152,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
||||
|
||||
### Installer
|
||||
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
## 2026.1.10
|
||||
|
||||
### Highlights
|
||||
@@ -4373,6 +4260,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
|
||||
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
|
||||
|
||||
### Breaking
|
||||
|
||||
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### New Features and Changes
|
||||
|
||||
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
|
||||
@@ -4414,11 +4306,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
|
||||
|
||||
### Breaking
|
||||
|
||||
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Dependencies: bump pi-\* stack to 0.42.2.
|
||||
@@ -4438,18 +4325,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes.
|
||||
- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
|
||||
@@ -4465,6 +4340,18 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Skills additions (Himalaya email, CodexBar, 1Password).
|
||||
|
||||
@@ -47,7 +47,7 @@ Welcome to the lobster tank! 🦞
|
||||
- **Christoph Nakazawa** - JS Infra
|
||||
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
|
||||
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, Performance, Plugins, Matrix
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
|
||||
@@ -83,21 +83,13 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
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
|
||||
3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first.
|
||||
4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## Before You PR
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- For extension/plugin changes, run the fast local lane first:
|
||||
- `pnpm test:extension <extension-name>`
|
||||
- `pnpm test:extension --list` to see valid extension ids
|
||||
- If you changed shared plugin or channel surfaces, run `pnpm test:contracts`
|
||||
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
|
||||
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
|
||||
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
|
||||
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
|
||||
@@ -146,10 +146,6 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
|
||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
|
||||
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||
# bundled discovery so package metadata that points at source entries stays valid.
|
||||
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions
|
||||
|
||||
# Keep pnpm available in the runtime image for container-local workflows.
|
||||
# Use a shared Corepack home so the non-root `node` user does not need a
|
||||
# first-run network fetch when invoking pnpm.
|
||||
|
||||
18
README.md
18
README.md
@@ -23,10 +23,10 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[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)
|
||||
[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) · [Wizard](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)
|
||||
|
||||
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)**.
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
|
||||
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended 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)
|
||||
|
||||
@@ -58,7 +58,7 @@ npm install -g openclaw@latest
|
||||
openclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
@@ -132,7 +132,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[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.
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -143,7 +143,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
### Core platform
|
||||
|
||||
- [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).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](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).
|
||||
@@ -293,7 +293,7 @@ If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see [macOS Permissions](https://docs.openclaw.ai/platforms/mac/permissions)).
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
|
||||
### iOS node (optional)
|
||||
|
||||
@@ -364,7 +364,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
@@ -422,7 +422,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [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)
|
||||
- [Follow the onboarding wizard flow 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)
|
||||
|
||||
@@ -176,45 +176,6 @@ More details: `docs/platforms/android.md`.
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## Google Play Restricted Permissions
|
||||
|
||||
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
|
||||
|
||||
- `READ_SMS`
|
||||
- `SEND_SMS`
|
||||
- `READ_CALL_LOG`
|
||||
|
||||
Why these matter:
|
||||
|
||||
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||
|
||||
Current OpenClaw Android implication:
|
||||
|
||||
- APK / sideload build can keep SMS and Call Log features.
|
||||
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||
|
||||
Policy links:
|
||||
|
||||
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
|
||||
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
|
||||
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
|
||||
|
||||
Other Play-restricted surfaces to watch if added later:
|
||||
|
||||
- `ACCESS_BACKGROUND_LOCATION`
|
||||
- `MANAGE_EXTERNAL_STORAGE`
|
||||
- `QUERY_ALL_PACKAGES`
|
||||
- `REQUEST_INSTALL_PACKAGES`
|
||||
- `AccessibilityService`
|
||||
|
||||
Reference links:
|
||||
|
||||
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
|
||||
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
|
||||
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
|
||||
|
||||
## Integration Capability Test (Preconditioned)
|
||||
|
||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission
|
||||
|
||||
@@ -18,13 +18,14 @@ import kotlinx.coroutines.launch
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private var didAttachRuntimeUi = false
|
||||
private var didStartNodeService = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
@@ -38,20 +39,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.runtimeInitialized.collect { ready ->
|
||||
if (!ready || didAttachRuntimeUi) return@collect
|
||||
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
didAttachRuntimeUi = true
|
||||
if (!didStartNodeService) {
|
||||
NodeForegroundService.start(this@MainActivity)
|
||||
didStartNodeService = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
OpenClawTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
@@ -59,6 +46,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep startup path lean: start foreground service after first frame.
|
||||
window.decorView.post { NodeForegroundService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
||||
@@ -2,274 +2,209 @@ package ai.openclaw.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.node.SmsManager
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val nodeApp = app as NodeApp
|
||||
private val prefs = nodeApp.prefs
|
||||
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
|
||||
private var foreground = true
|
||||
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||
|
||||
private fun ensureRuntime(): NodeRuntime {
|
||||
runtimeRef.value?.let { return it }
|
||||
val runtime = nodeApp.ensureRuntime()
|
||||
runtime.setForeground(foreground)
|
||||
runtimeRef.value = runtime
|
||||
return runtime
|
||||
}
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
|
||||
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val sms: SmsManager = runtime.sms
|
||||
|
||||
private fun <T> runtimeState(
|
||||
initial: T,
|
||||
selector: (NodeRuntime) -> StateFlow<T>,
|
||||
): StateFlow<T> =
|
||||
runtimeRef
|
||||
.flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, initial)
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
val runtimeInitialized: StateFlow<Boolean> =
|
||||
runtimeRef
|
||||
.flatMapLatest { runtime -> flowOf(runtime != null) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
|
||||
val canvasCurrentUrl: StateFlow<String?> = runtimeState(initial = null) { it.canvas.currentUrl }
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasA2uiHydrated }
|
||||
val canvasRehydratePending: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasRehydratePending }
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = runtimeState(initial = null) { it.canvasRehydrateErrorText }
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
|
||||
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
|
||||
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
|
||||
val micStatusText: StateFlow<String> = runtime.micStatusText
|
||||
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
|
||||
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
|
||||
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
|
||||
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
|
||||
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
|
||||
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
||||
val gatewayToken: StateFlow<String> = runtime.gatewayToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
|
||||
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
|
||||
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtimeState(initial = null) { it.cameraHud }
|
||||
val cameraFlashToken: StateFlow<Long> = runtimeState(initial = 0L) { it.cameraFlashToken }
|
||||
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||
|
||||
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
|
||||
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
|
||||
val micLiveTranscript: StateFlow<String?> = runtimeState(initial = null) { it.micLiveTranscript }
|
||||
val micIsListening: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsListening }
|
||||
val micQueuedMessages: StateFlow<List<String>> = runtimeState(initial = emptyList()) { it.micQueuedMessages }
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
|
||||
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
|
||||
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = runtimeState(initial = emptyList()) { it.chatMessages }
|
||||
val chatError: StateFlow<String?> = runtimeState(initial = null) { it.chatError }
|
||||
val chatHealthOk: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHealthOk }
|
||||
val chatThinkingLevel: StateFlow<String> = runtimeState(initial = "off") { it.chatThinkingLevel }
|
||||
val chatStreamingAssistantText: StateFlow<String?> = runtimeState(initial = null) { it.chatStreamingAssistantText }
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||
|
||||
init {
|
||||
if (prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
val canvas: CanvasController
|
||||
get() = ensureRuntime().canvas
|
||||
|
||||
val camera: CameraCaptureManager
|
||||
get() = ensureRuntime().camera
|
||||
|
||||
val sms: SmsManager
|
||||
get() = ensureRuntime().sms
|
||||
|
||||
fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) {
|
||||
val runtime = runtimeRef.value ?: return
|
||||
runtime.camera.attachLifecycleOwner(owner)
|
||||
runtime.camera.attachPermissionRequester(permissionRequester)
|
||||
runtime.sms.attachPermissionRequester(permissionRequester)
|
||||
}
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||
val chatMessages = runtime.chatMessages
|
||||
val chatError: StateFlow<String?> = runtime.chatError
|
||||
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
||||
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
||||
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
||||
val chatSessions = runtime.chatSessions
|
||||
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
foreground = value
|
||||
val runtime =
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
} else {
|
||||
runtimeRef.value
|
||||
}
|
||||
runtime?.setForeground(value)
|
||||
runtime.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
runtime.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.setCameraEnabled(value)
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.setLocationMode(mode)
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.setLocationPreciseEnabled(value)
|
||||
runtime.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.setManualEnabled(value)
|
||||
runtime.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
prefs.setManualHost(value)
|
||||
runtime.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.setManualPort(value)
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.setManualTls(value)
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
prefs.setGatewayToken(value)
|
||||
runtime.setGatewayToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayBootstrapToken(value: String) {
|
||||
prefs.setGatewayBootstrapToken(value)
|
||||
runtime.setGatewayBootstrapToken(value)
|
||||
}
|
||||
|
||||
fun setGatewayPassword(value: String) {
|
||||
prefs.setGatewayPassword(value)
|
||||
runtime.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
if (value) {
|
||||
ensureRuntime()
|
||||
}
|
||||
prefs.setOnboardingCompleted(value)
|
||||
runtime.setOnboardingCompleted(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
ensureRuntime().setVoiceScreenActive(active)
|
||||
runtime.setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
runtime.setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setSpeakerEnabled(enabled)
|
||||
runtime.setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
runtime.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
ensureRuntime().connect(endpoint)
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
ensureRuntime().connectManual()
|
||||
runtime.connectManual()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
runtimeRef.value?.disconnect()
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
runtimeRef.value?.acceptGatewayTrustPrompt()
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
runtimeRef.value?.declineGatewayTrustPrompt()
|
||||
runtime.declineGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "screen_tab") {
|
||||
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
|
||||
runtime.requestCanvasRehydrate(source = source, force = true)
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
|
||||
runtime.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
ensureRuntime().loadChat(sessionKey)
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
ensureRuntime().refreshChat()
|
||||
runtime.refreshChat()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
ensureRuntime().refreshChatSessions(limit = limit)
|
||||
runtime.refreshChatSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
ensureRuntime().setChatThinkingLevel(level)
|
||||
runtime.setChatThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
ensureRuntime().switchChatSession(sessionKey)
|
||||
runtime.switchChatSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
ensureRuntime().abortChat()
|
||||
runtime.abortChat()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,7 @@ import android.app.Application
|
||||
import android.os.StrictMode
|
||||
|
||||
class NodeApp : Application() {
|
||||
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
|
||||
|
||||
@Volatile private var runtimeInstance: NodeRuntime? = null
|
||||
|
||||
fun ensureRuntime(): NodeRuntime {
|
||||
runtimeInstance?.let { return it }
|
||||
return synchronized(this) {
|
||||
runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun peekRuntime(): NodeRuntime? = runtimeInstance
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
@@ -28,11 +28,7 @@ class NodeForegroundService : Service() {
|
||||
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
|
||||
startForegroundWithTypes(notification = initial)
|
||||
|
||||
val runtime = (application as NodeApp).peekRuntime()
|
||||
if (runtime == null) {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
val runtime = (application as NodeApp).runtime
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
@@ -63,7 +59,7 @@ class NodeForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
(application as NodeApp).peekRuntime()?.disconnect()
|
||||
(application as NodeApp).runtime.disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
@@ -43,12 +43,11 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
) {
|
||||
class NodeRuntime(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
@@ -137,8 +136,7 @@ class NodeRuntime(
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
sendSmsAvailable = { sms.canSendSms() },
|
||||
readSmsAvailable = { sms.canReadSms() },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
@@ -161,8 +159,7 @@ class NodeRuntime(
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
sendSmsAvailable = { sms.canSendSms() },
|
||||
readSmsAvailable = { sms.canReadSms() },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
@@ -568,8 +565,43 @@ class NodeRuntime(
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
seedLastDiscoveredGateway(list)
|
||||
autoConnectIfNeeded()
|
||||
if (list.isNotEmpty()) {
|
||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
||||
// UX parity with iOS: only set once when unset.
|
||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
if (_isConnected.value) return@collect
|
||||
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
if (!manualTls.value) return@collect
|
||||
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,53 +626,11 @@ class NodeRuntime(
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
} else {
|
||||
if (!value) {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
|
||||
if (list.isEmpty()) return
|
||||
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
|
||||
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port !in 1..65535) return null
|
||||
return GatewayEndpoint.manual(host = host, port = port)
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return null
|
||||
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return null
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private fun autoConnectIfNeeded() {
|
||||
if (didAutoConnect) return
|
||||
if (_isConnected.value) return
|
||||
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||
didAutoConnect = true
|
||||
connect(endpoint)
|
||||
}
|
||||
|
||||
private fun reconnectPreferredGatewayOnForeground() {
|
||||
if (_isConnected.value) return
|
||||
if (_pendingGatewayTrust.value != null) return
|
||||
if (connectedEndpoint != null) {
|
||||
refreshGatewayConnection()
|
||||
return
|
||||
}
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ class ChatController(
|
||||
}
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
|
||||
val history = parseHistory(historyJson, sessionKey = key)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
@@ -336,7 +336,7 @@ class ChatController(
|
||||
try {
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
@@ -450,11 +450,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
previousMessages: List<ChatMessage>,
|
||||
): ChatHistory {
|
||||
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
@@ -474,12 +470,7 @@ class ChatController(
|
||||
)
|
||||
}
|
||||
|
||||
return ChatHistory(
|
||||
sessionKey = sessionKey,
|
||||
sessionId = sid,
|
||||
thinkingLevel = thinkingLevel,
|
||||
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||
)
|
||||
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
||||
}
|
||||
|
||||
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
@@ -528,47 +519,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||
if (previous.isEmpty() || incoming.isEmpty()) return incoming
|
||||
|
||||
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
|
||||
for (message in previous) {
|
||||
val key = messageIdentityKey(message) ?: continue
|
||||
idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id)
|
||||
}
|
||||
|
||||
return incoming.map { message ->
|
||||
val key = messageIdentityKey(message) ?: return@map message
|
||||
val ids = idsByKey[key] ?: return@map message
|
||||
val reusedId = ids.removeFirstOrNull() ?: return@map message
|
||||
if (ids.isEmpty()) {
|
||||
idsByKey.remove(key)
|
||||
}
|
||||
if (reusedId == message.id) return@map message
|
||||
message.copy(id = reusedId)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val role = message.role.trim().lowercase()
|
||||
if (role.isEmpty()) return null
|
||||
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
val contentFingerprint =
|
||||
message.content.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
part.type.trim().lowercase(),
|
||||
part.text?.trim().orEmpty(),
|
||||
part.mimeType?.trim()?.lowercase().orEmpty(),
|
||||
part.fileName?.trim().orEmpty(),
|
||||
part.base64?.hashCode()?.toString().orEmpty(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
@@ -17,8 +17,7 @@ class ConnectionManager(
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
private val sendSmsAvailable: () -> Boolean,
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
@@ -79,8 +78,7 @@ class ConnectionManager(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
sendSmsAvailable = sendSmsAvailable(),
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsAvailable = smsAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
|
||||
@@ -18,8 +18,7 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
|
||||
data class NodeRuntimeFlags(
|
||||
val cameraEnabled: Boolean,
|
||||
val locationEnabled: Boolean,
|
||||
val sendSmsAvailable: Boolean,
|
||||
val readSmsAvailable: Boolean,
|
||||
val smsAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
@@ -30,8 +29,7 @@ enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SendSmsAvailable,
|
||||
ReadSmsAvailable,
|
||||
SmsAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
@@ -189,11 +187,7 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SendSmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Search.rawValue,
|
||||
availability = InvokeCommandAvailability.ReadSmsAvailable,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCallLogCommand.Search.rawValue,
|
||||
@@ -219,7 +213,7 @@ object InvokeCommandRegistry {
|
||||
NodeCapabilityAvailability.Always -> true
|
||||
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
@@ -234,8 +228,7 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
||||
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
|
||||
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
|
||||
@@ -32,8 +32,7 @@ class InvokeDispatcher(
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val sendSmsAvailable: () -> Boolean,
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
@@ -163,7 +162,6 @@ class InvokeDispatcher(
|
||||
|
||||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
|
||||
|
||||
// CallLog command
|
||||
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
|
||||
@@ -258,17 +256,8 @@ class InvokeDispatcher(
|
||||
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SendSmsAvailable ->
|
||||
if (sendSmsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.ReadSmsAvailable ->
|
||||
if (readSmsAvailable()) {
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
|
||||
@@ -16,16 +16,4 @@ class SmsHandler(
|
||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val res = sms.search(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} else {
|
||||
val error = res.error ?: "SMS_SEARCH_FAILED"
|
||||
val idx = error.indexOf(':')
|
||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
|
||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,19 @@ package ai.openclaw.app.node
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.Telephony
|
||||
import android.telephony.SmsManager as AndroidSmsManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import ai.openclaw.app.PermissionRequester
|
||||
|
||||
/**
|
||||
* Sends SMS messages via the Android SMS API.
|
||||
* Requires SEND_SMS permission to be granted.
|
||||
*
|
||||
* Also provides SMS query functionality with READ_SMS permission.
|
||||
*/
|
||||
class SmsManager(private val context: Context) {
|
||||
|
||||
@@ -38,30 +30,6 @@ class SmsManager(private val context: Context) {
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a single SMS message
|
||||
*/
|
||||
@Serializable
|
||||
data class SmsMessage(
|
||||
val id: Long,
|
||||
val threadId: Long,
|
||||
val address: String?,
|
||||
val person: String?,
|
||||
val date: Long,
|
||||
val dateSent: Long,
|
||||
val read: Boolean,
|
||||
val type: Int,
|
||||
val body: String?,
|
||||
val status: Int,
|
||||
)
|
||||
|
||||
data class SearchResult(
|
||||
val ok: Boolean,
|
||||
val messages: List<SmsMessage>,
|
||||
val error: String? = null,
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
internal data class ParsedParams(
|
||||
val to: String,
|
||||
val message: String,
|
||||
@@ -76,30 +44,12 @@ class SmsManager(private val context: Context) {
|
||||
) : ParseResult()
|
||||
}
|
||||
|
||||
internal data class QueryParams(
|
||||
val startTime: Long? = null,
|
||||
val endTime: Long? = null,
|
||||
val contactName: String? = null,
|
||||
val phoneNumber: String? = null,
|
||||
val keyword: String? = null,
|
||||
val type: Int? = null,
|
||||
val isRead: Boolean? = null,
|
||||
val limit: Int = DEFAULT_SMS_LIMIT,
|
||||
val offset: Int = 0,
|
||||
)
|
||||
|
||||
internal sealed class QueryParseResult {
|
||||
data class Ok(val params: QueryParams) : QueryParseResult()
|
||||
data class Error(val error: String) : QueryParseResult()
|
||||
}
|
||||
|
||||
internal data class SendPlan(
|
||||
val parts: List<String>,
|
||||
val useMultipart: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_SMS_LIMIT = 25
|
||||
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
||||
@@ -138,52 +88,6 @@ class SmsManager(private val context: Context) {
|
||||
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
||||
}
|
||||
|
||||
internal fun parseQueryParams(paramsJson: String?, json: Json = JsonConfig): QueryParseResult {
|
||||
val params = paramsJson?.trim().orEmpty()
|
||||
if (params.isEmpty()) {
|
||||
return QueryParseResult.Ok(QueryParams())
|
||||
}
|
||||
|
||||
val obj = try {
|
||||
json.parseToJsonElement(params).jsonObject
|
||||
} catch (_: Throwable) {
|
||||
return QueryParseResult.Error("INVALID_REQUEST: expected JSON object")
|
||||
}
|
||||
|
||||
val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim()
|
||||
val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim()
|
||||
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
|
||||
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
|
||||
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
|
||||
.coerceIn(1, 200)
|
||||
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
|
||||
.coerceAtLeast(0)
|
||||
|
||||
// Validate time range
|
||||
if (startTime != null && endTime != null && startTime > endTime) {
|
||||
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
|
||||
}
|
||||
|
||||
return QueryParseResult.Ok(QueryParams(
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
contactName = contactName,
|
||||
phoneNumber = phoneNumber,
|
||||
keyword = keyword,
|
||||
type = type,
|
||||
isRead = isRead,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
))
|
||||
}
|
||||
|
||||
private fun normalizePhoneNumber(phone: String): String {
|
||||
return phone.replace(Regex("""[\s\-()]"""), "")
|
||||
}
|
||||
|
||||
internal fun buildSendPlan(
|
||||
message: String,
|
||||
divider: (String) -> List<String>,
|
||||
@@ -208,25 +112,6 @@ class SmsManager(private val context: Context) {
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||
}
|
||||
|
||||
internal fun buildQueryPayloadJson(
|
||||
json: Json = JsonConfig,
|
||||
ok: Boolean,
|
||||
messages: List<SmsMessage>,
|
||||
error: String? = null,
|
||||
): String {
|
||||
val messagesArray = json.encodeToString(messages)
|
||||
val messagesElement = json.parseToJsonElement(messagesArray)
|
||||
val payload = mutableMapOf<String, JsonElement>(
|
||||
"ok" to JsonPrimitive(ok),
|
||||
"count" to JsonPrimitive(messages.size),
|
||||
"messages" to messagesElement
|
||||
)
|
||||
if (!ok && error != null) {
|
||||
payload["error"] = JsonPrimitive(error)
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||
}
|
||||
}
|
||||
|
||||
fun hasSmsPermission(): Boolean {
|
||||
@@ -236,28 +121,10 @@ class SmsManager(private val context: Context) {
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun hasReadSmsPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_SMS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun hasReadContactsPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun canSendSms(): Boolean {
|
||||
return hasSmsPermission() && hasTelephonyFeature()
|
||||
}
|
||||
|
||||
fun canReadSms(): Boolean {
|
||||
return hasReadSmsPermission() && hasTelephonyFeature()
|
||||
}
|
||||
|
||||
fun hasTelephonyFeature(): Boolean {
|
||||
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
@@ -341,20 +208,6 @@ class SmsManager(private val context: Context) {
|
||||
return results[Manifest.permission.SEND_SMS] == true
|
||||
}
|
||||
|
||||
private suspend fun ensureReadSmsPermission(): Boolean {
|
||||
if (hasReadSmsPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS))
|
||||
return results[Manifest.permission.READ_SMS] == true
|
||||
}
|
||||
|
||||
private suspend fun ensureReadContactsPermission(): Boolean {
|
||||
if (hasReadContactsPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
|
||||
return results[Manifest.permission.READ_CONTACTS] == true
|
||||
}
|
||||
|
||||
private fun okResult(to: String, message: String): SendResult {
|
||||
return SendResult(
|
||||
ok = true,
|
||||
@@ -374,240 +227,4 @@ class SmsManager(private val context: Context) {
|
||||
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* search SMS messages with the specified parameters.
|
||||
*
|
||||
* @param paramsJson JSON with optional fields:
|
||||
* - startTime (Long): Start time in milliseconds
|
||||
* - endTime (Long): End time in milliseconds
|
||||
* - contactName (String): Contact name to search
|
||||
* - phoneNumber (String): Phone number to search (supports partial matching)
|
||||
* - keyword (String): Keyword to search in message body
|
||||
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
|
||||
* - isRead (Boolean): Read status
|
||||
* - limit (Int): Number of records to return (default: 25, range: 1-200)
|
||||
* - offset (Int): Number of records to skip (default: 0)
|
||||
* @return SearchResult containing the list of SMS messages or an error
|
||||
*/
|
||||
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
|
||||
if (!hasTelephonyFeature()) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_UNAVAILABLE: telephony not available",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
|
||||
)
|
||||
}
|
||||
|
||||
if (!ensureReadSmsPermission()) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
|
||||
)
|
||||
}
|
||||
|
||||
val parseResult = parseQueryParams(paramsJson, json)
|
||||
if (parseResult is QueryParseResult.Error) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = parseResult.error,
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
|
||||
)
|
||||
}
|
||||
val params = (parseResult as QueryParseResult.Ok).params
|
||||
|
||||
return@withContext try {
|
||||
// Get phone numbers from contact name if provided
|
||||
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
|
||||
if (!ensureReadContactsPermission()) {
|
||||
return@withContext SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
|
||||
)
|
||||
}
|
||||
getPhoneNumbersFromContactName(params.contactName)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val messages = querySmsMessages(params, phoneNumbers)
|
||||
SearchResult(
|
||||
ok = true,
|
||||
messages = messages,
|
||||
error = null,
|
||||
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
|
||||
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all phone numbers associated with a contact name
|
||||
*/
|
||||
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
|
||||
val phoneNumbers = mutableListOf<String>()
|
||||
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
|
||||
val selectionArgs = arrayOf("%$contactName%")
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
while (it.moveToNext()) {
|
||||
val number = it.getString(numberIndex)
|
||||
if (!number.isNullOrBlank()) {
|
||||
phoneNumbers.add(normalizePhoneNumber(number))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return phoneNumbers
|
||||
}
|
||||
|
||||
/**
|
||||
* Query SMS messages based on the provided parameters
|
||||
*/
|
||||
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
|
||||
val messages = mutableListOf<SmsMessage>()
|
||||
|
||||
// Build selection and selectionArgs
|
||||
val selections = mutableListOf<String>()
|
||||
val selectionArgs = mutableListOf<String>()
|
||||
|
||||
// Time range
|
||||
if (params.startTime != null) {
|
||||
selections.add("${Telephony.Sms.DATE} >= ?")
|
||||
selectionArgs.add(params.startTime.toString())
|
||||
}
|
||||
if (params.endTime != null) {
|
||||
selections.add("${Telephony.Sms.DATE} <= ?")
|
||||
selectionArgs.add(params.endTime.toString())
|
||||
}
|
||||
|
||||
// Phone numbers (from contact name or direct phone number)
|
||||
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
|
||||
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
|
||||
} else {
|
||||
phoneNumbers
|
||||
}
|
||||
|
||||
if (allPhoneNumbers.isNotEmpty()) {
|
||||
val addressSelection = allPhoneNumbers.joinToString(" OR ") {
|
||||
"${Telephony.Sms.ADDRESS} LIKE ?"
|
||||
}
|
||||
selections.add("($addressSelection)")
|
||||
allPhoneNumbers.forEach {
|
||||
selectionArgs.add("%$it%")
|
||||
}
|
||||
}
|
||||
|
||||
// Keyword in body
|
||||
if (!params.keyword.isNullOrEmpty()) {
|
||||
selections.add("${Telephony.Sms.BODY} LIKE ?")
|
||||
selectionArgs.add("%${params.keyword}%")
|
||||
}
|
||||
|
||||
// Type
|
||||
if (params.type != null) {
|
||||
selections.add("${Telephony.Sms.TYPE} = ?")
|
||||
selectionArgs.add(params.type.toString())
|
||||
}
|
||||
|
||||
// Read status
|
||||
if (params.isRead != null) {
|
||||
selections.add("${Telephony.Sms.READ} = ?")
|
||||
selectionArgs.add(if (params.isRead) "1" else "0")
|
||||
}
|
||||
|
||||
val selection = if (selections.isNotEmpty()) {
|
||||
selections.joinToString(" AND ")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val selectionArgsArray = if (selectionArgs.isNotEmpty()) {
|
||||
selectionArgs.toTypedArray()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
|
||||
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
|
||||
val cursor = context.contentResolver.query(
|
||||
Telephony.Sms.CONTENT_URI,
|
||||
arrayOf(
|
||||
Telephony.Sms._ID,
|
||||
Telephony.Sms.THREAD_ID,
|
||||
Telephony.Sms.ADDRESS,
|
||||
Telephony.Sms.PERSON,
|
||||
Telephony.Sms.DATE,
|
||||
Telephony.Sms.DATE_SENT,
|
||||
Telephony.Sms.READ,
|
||||
Telephony.Sms.TYPE,
|
||||
Telephony.Sms.BODY,
|
||||
Telephony.Sms.STATUS
|
||||
),
|
||||
selection,
|
||||
selectionArgsArray,
|
||||
sortOrder
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndex(Telephony.Sms._ID)
|
||||
val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID)
|
||||
val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS)
|
||||
val personIndex = it.getColumnIndex(Telephony.Sms.PERSON)
|
||||
val dateIndex = it.getColumnIndex(Telephony.Sms.DATE)
|
||||
val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT)
|
||||
val readIndex = it.getColumnIndex(Telephony.Sms.READ)
|
||||
val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE)
|
||||
val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY)
|
||||
val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS)
|
||||
|
||||
var count = 0
|
||||
while (it.moveToNext() && count < params.limit) {
|
||||
val message = SmsMessage(
|
||||
id = it.getLong(idIndex),
|
||||
threadId = it.getLong(threadIdIndex),
|
||||
address = it.getString(addressIndex),
|
||||
person = it.getString(personIndex),
|
||||
date = it.getLong(dateIndex),
|
||||
dateSent = it.getLong(dateSentIndex),
|
||||
read = it.getInt(readIndex) == 1,
|
||||
type = it.getInt(typeIndex),
|
||||
body = it.getString(bodyIndex),
|
||||
status = it.getInt(statusIndex)
|
||||
)
|
||||
messages.add(message)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
|
||||
enum class OpenClawSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
Search("sms.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@@ -50,7 +49,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
@@ -62,7 +60,6 @@ private enum class ConnectInputMode {
|
||||
|
||||
@Composable
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@@ -137,8 +134,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
@@ -283,46 +279,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (showDiagnostics) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileWarningSoft,
|
||||
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.25f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Last gateway error", style = mobileHeadline, color = mobileWarning)
|
||||
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
Text("OpenClaw Android ${openClawAndroidVersionLabel()}", style = mobileCaption1, color = mobileTextSecondary)
|
||||
Button(
|
||||
onClick = {
|
||||
copyGatewayDiagnosticsReport(
|
||||
context = context,
|
||||
screen = "connect tab",
|
||||
gatewayAddress = activeEndpoint,
|
||||
statusText = statusLabel,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileWarning,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileWarning.copy(alpha = 0.3f)),
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import ai.openclaw.app.BuildConfig
|
||||
|
||||
internal fun openClawAndroidVersionLabel(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayStatusForDisplay(statusText: String): String {
|
||||
return statusText.trim().ifEmpty { "Offline" }
|
||||
}
|
||||
|
||||
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower != "offline" && !lower.contains("connecting")
|
||||
}
|
||||
|
||||
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower.contains("pair") || lower.contains("approve")
|
||||
}
|
||||
|
||||
internal fun buildGatewayDiagnosticsReport(
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
statusText: String,
|
||||
): String {
|
||||
val device =
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { "Android" }
|
||||
val androidVersion = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { Build.VERSION.SDK_INT.toString() }
|
||||
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
|
||||
val status = gatewayStatusForDisplay(statusText)
|
||||
return """
|
||||
Help diagnose this OpenClaw Android gateway connection failure.
|
||||
|
||||
Please:
|
||||
- pick one route only: same machine, same LAN, Tailscale, or public URL
|
||||
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
|
||||
- quote the exact app status/error below
|
||||
- tell me whether `openclaw devices list` should show a pending pairing request
|
||||
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
|
||||
- give the next exact command or tap
|
||||
|
||||
Debug info:
|
||||
- screen: $screen
|
||||
- app version: ${openClawAndroidVersionLabel()}
|
||||
- device: $device
|
||||
- android: $androidVersion (SDK ${Build.VERSION.SDK_INT})
|
||||
- gateway address: $endpoint
|
||||
- status/error: $status
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
internal fun copyGatewayDiagnosticsReport(
|
||||
context: Context,
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
statusText: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
|
||||
val report = buildGatewayDiagnosticsReport(screen = screen, gatewayAddress = gatewayAddress, statusText = statusText)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw gateway diagnostics", report))
|
||||
Toast.makeText(context, "Copied gateway diagnostics", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -61,7 +60,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@@ -289,11 +287,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
var enableSms by
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
smsAvailable &&
|
||||
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS)
|
||||
)
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
var enableCallLog by
|
||||
rememberSaveable {
|
||||
@@ -342,9 +336,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
!motionPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable ||
|
||||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS))
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
|
||||
}
|
||||
|
||||
@@ -706,7 +698,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Sms,
|
||||
checked,
|
||||
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
|
||||
listOf(Manifest.permission.SEND_SMS),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -1445,11 +1437,9 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "SMS",
|
||||
subtitle = "Send and search text messages via the gateway",
|
||||
subtitle = "Send text messages via the gateway",
|
||||
checked = enableSms,
|
||||
granted =
|
||||
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
|
||||
isPermissionGranted(context, Manifest.permission.READ_SMS),
|
||||
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
|
||||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
@@ -1521,12 +1511,6 @@ private fun FinalStep(
|
||||
enabledPermissions: String,
|
||||
methodLabel: String,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val gatewayAddress = parsedGateway?.displayUrl ?: "Invalid gateway URL"
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
val showDiagnostics = gatewayStatusHasDiagnostics(statusText)
|
||||
val pairingRequired = gatewayStatusLooksLikePairing(statusText)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Review", style = onboardingTitle1Style, color = onboardingText)
|
||||
|
||||
@@ -1539,7 +1523,7 @@ private fun FinalStep(
|
||||
SummaryCard(
|
||||
icon = Icons.Default.Cloud,
|
||||
label = "Gateway",
|
||||
value = gatewayAddress,
|
||||
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
|
||||
accentColor = Color(0xFF7C5AC7),
|
||||
)
|
||||
SummaryCard(
|
||||
@@ -1623,7 +1607,7 @@ private fun FinalStep(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingWarningSoft,
|
||||
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
@@ -1648,66 +1632,13 @@ private fun FinalStep(
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
if (pairingRequired) "Pairing Required" else "Connection Failed",
|
||||
style = onboardingHeadlineStyle,
|
||||
color = onboardingWarning,
|
||||
)
|
||||
Text(
|
||||
if (pairingRequired) {
|
||||
"Approve this phone on the gateway host, or copy the report below."
|
||||
} else {
|
||||
"Copy this report and give it to your Claw."
|
||||
},
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
|
||||
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
if (showDiagnostics) {
|
||||
Text("Error", style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), color = onboardingTextSecondary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = onboardingCommandBg,
|
||||
border = BorderStroke(1.dp, onboardingCommandBorder),
|
||||
) {
|
||||
Text(
|
||||
statusLabel,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace),
|
||||
color = onboardingCommandText,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"OpenClaw Android ${openClawAndroidVersionLabel()}",
|
||||
style = onboardingCaption1Style,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
copyGatewayDiagnosticsReport(
|
||||
context = context,
|
||||
screen = "onboarding final check",
|
||||
gatewayAddress = gatewayAddress,
|
||||
statusText = statusLabel,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = onboardingSurface, contentColor = onboardingWarning),
|
||||
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.3f)),
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Copy Report for Claw", style = onboardingCalloutStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
if (pairingRequired) {
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,16 +247,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val smsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val sendOk = perms[Manifest.permission.SEND_SMS] == true
|
||||
val readOk = perms[Manifest.permission.READ_SMS] == true
|
||||
smsPermissionGranted = sendOk && readOk
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
smsPermissionGranted = granted
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
@@ -291,8 +287,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -513,7 +507,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("SMS", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Send and search SMS from this device.", style = mobileCallout)
|
||||
Text("Send SMS from this device.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
@@ -521,7 +515,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
if (smsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
|
||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,7 +28,8 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState {
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.util.LruCache
|
||||
import androidx.core.graphics.scale
|
||||
import ai.openclaw.app.node.JpegSizeLimiter
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
|
||||
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
|
||||
private const val CHAT_ATTACHMENT_START_QUALITY = 85
|
||||
private const val CHAT_DECODE_MAX_DIMENSION = 1600
|
||||
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
private val decodedBitmapCache =
|
||||
object : LruCache<String, Bitmap>(CHAT_IMAGE_CACHE_BYTES) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||
val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/'))
|
||||
val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH)
|
||||
if (bitmap == null) {
|
||||
throw IllegalStateException("unsupported attachment")
|
||||
}
|
||||
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
|
||||
val encoded =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = bitmap.width,
|
||||
initialHeight = bitmap.height,
|
||||
startQuality = CHAT_ATTACHMENT_START_QUALITY,
|
||||
maxBytes = maxBytes,
|
||||
minSize = 240,
|
||||
encode = { width, height, quality ->
|
||||
val working =
|
||||
if (width == bitmap.width && height == bitmap.height) {
|
||||
bitmap
|
||||
} else {
|
||||
bitmap.scale(width, height, true)
|
||||
}
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
|
||||
throw IllegalStateException("attachment encode failed")
|
||||
}
|
||||
out.toByteArray()
|
||||
} finally {
|
||||
if (working !== bitmap) {
|
||||
working.recycle()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP)
|
||||
return PendingImageAttachment(
|
||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||
fileName = fileName,
|
||||
mimeType = "image/jpeg",
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? {
|
||||
val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}"
|
||||
decodedBitmapCache.get(cacheKey)?.let { return it }
|
||||
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
if (bytes.isEmpty()) return null
|
||||
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val bitmap =
|
||||
BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||
inPreferredConfig = Bitmap.Config.RGB_565
|
||||
},
|
||||
) ?: return null
|
||||
|
||||
decodedBitmapCache.put(cacheKey, bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||
if (width <= 0 || height <= 0 || maxDimension <= 0) return 1
|
||||
|
||||
var sample = 1
|
||||
var longestEdge = max(width, height)
|
||||
while (longestEdge > maxDimension && sample < 64) {
|
||||
sample *= 2
|
||||
longestEdge = max(width / sample, height / sample)
|
||||
}
|
||||
return sample.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
internal fun normalizeAttachmentFileName(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "image.jpg"
|
||||
val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" }
|
||||
return "$stem.jpg"
|
||||
}
|
||||
|
||||
private fun decodeScaledBitmap(
|
||||
resolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxDimension: Int,
|
||||
): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(input, null, bounds)
|
||||
}
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val decoded =
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(
|
||||
input,
|
||||
null,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
} ?: return null
|
||||
|
||||
val longestEdge = max(decoded.width, decoded.height)
|
||||
if (longestEdge <= maxDimension) return decoded
|
||||
|
||||
val scale = maxDimension.toDouble() / longestEdge.toDouble()
|
||||
val targetWidth = max(1, (decoded.width * scale).roundToInt())
|
||||
val targetHeight = max(1, (decoded.height * scale).roundToInt())
|
||||
val scaled = decoded.scale(targetWidth, targetHeight, true)
|
||||
if (scaled !== decoded) {
|
||||
decoded.recycle()
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
@@ -6,14 +6,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -36,19 +34,11 @@ fun ChatMessageListCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
|
||||
// New list items/tool rows should animate into view, but token streaming should not restart
|
||||
// that animation on every delta.
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
// With reverseLayout the newest item is at index 0 (bottom of screen).
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
@@ -60,6 +50,8 @@ fun ChatMessageListCard(
|
||||
) {
|
||||
// With reverseLayout = true, index 0 renders at the BOTTOM.
|
||||
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
|
||||
|
||||
val stream = streamingAssistantText?.trim()
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
@@ -78,8 +70,8 @@ fun ChatMessageListCard(
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatMessageBubble(message = message)
|
||||
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
|
||||
ChatMessageBubble(message = messages[messages.size - 1 - idx])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -44,6 +47,7 @@ import ai.openclaw.app.ui.mobileDanger
|
||||
import ai.openclaw.app.ui.mobileDangerSoft
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -79,7 +83,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val next =
|
||||
uris.take(8).mapNotNull { uri ->
|
||||
try {
|
||||
loadSizedImageAttachment(resolver, uri)
|
||||
loadImageAttachment(resolver, uri)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
@@ -156,10 +160,7 @@ private fun ChatThreadSelector(
|
||||
mainSessionKey: String,
|
||||
onSelectSession: (String) -> Unit,
|
||||
) {
|
||||
val sessionOptions =
|
||||
remember(sessionKey, sessions, mainSessionKey) {
|
||||
resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
}
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
@@ -213,3 +214,24 @@ data class PendingImageAttachment(
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
)
|
||||
|
||||
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||
val mimeType = resolver.getType(uri) ?: "image/*"
|
||||
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
|
||||
val bytes =
|
||||
withContext(Dispatchers.IO) {
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
val out = ByteArrayOutputStream()
|
||||
input.copyTo(out)
|
||||
out.toByteArray()
|
||||
} ?: ByteArray(0)
|
||||
}
|
||||
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
return PendingImageAttachment(
|
||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||
fileName = fileName,
|
||||
mimeType = mimeType,
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatControllerMessageIdentityTest {
|
||||
@Test
|
||||
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
|
||||
val previous =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "msg-2",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hi")),
|
||||
timestampMs = 2000L,
|
||||
),
|
||||
)
|
||||
|
||||
val incoming =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "new-1",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "new-2",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hi")),
|
||||
timestampMs = 2000L,
|
||||
),
|
||||
)
|
||||
|
||||
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
|
||||
|
||||
assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileMessageIdsLeavesNewMessagesUntouched() {
|
||||
val previous =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
),
|
||||
)
|
||||
|
||||
val incoming =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "new-1",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "new-2",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "new reply")),
|
||||
timestampMs = 3000L,
|
||||
),
|
||||
)
|
||||
|
||||
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
|
||||
|
||||
assertEquals("msg-1", reconciled[0].id)
|
||||
assertEquals("new-2", reconciled[1].id)
|
||||
assertNotEquals(reconciled[0].id, reconciled[1].id)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawMotionCommand.Activity.rawValue,
|
||||
OpenClawMotionCommand.Pedometer.rawValue,
|
||||
OpenClawSmsCommand.Send.rawValue,
|
||||
OpenClawSmsCommand.Search.rawValue,
|
||||
)
|
||||
|
||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||
@@ -84,8 +83,7 @@ class InvokeCommandRegistryTest {
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
sendSmsAvailable = true,
|
||||
readSmsAvailable = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
@@ -110,8 +108,7 @@ class InvokeCommandRegistryTest {
|
||||
defaultFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
sendSmsAvailable = true,
|
||||
readSmsAvailable = true,
|
||||
smsAvailable = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
@@ -128,8 +125,7 @@ class InvokeCommandRegistryTest {
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
sendSmsAvailable = false,
|
||||
readSmsAvailable = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
@@ -141,43 +137,10 @@ class InvokeCommandRegistryTest {
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
|
||||
val readOnlyCommands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
defaultFlags(readSmsAvailable = true),
|
||||
)
|
||||
val sendOnlyCommands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
defaultFlags(sendSmsAvailable = true),
|
||||
)
|
||||
|
||||
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
|
||||
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() {
|
||||
val readOnlyCapabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
defaultFlags(readSmsAvailable = true),
|
||||
)
|
||||
val sendOnlyCapabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
defaultFlags(sendSmsAvailable = true),
|
||||
)
|
||||
|
||||
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
}
|
||||
|
||||
private fun defaultFlags(
|
||||
cameraEnabled: Boolean = false,
|
||||
locationEnabled: Boolean = false,
|
||||
sendSmsAvailable: Boolean = false,
|
||||
readSmsAvailable: Boolean = false,
|
||||
smsAvailable: Boolean = false,
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
@@ -186,8 +149,7 @@ class InvokeCommandRegistryTest {
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled,
|
||||
locationEnabled = locationEnabled,
|
||||
sendSmsAvailable = sendSmsAvailable,
|
||||
readSmsAvailable = readSmsAvailable,
|
||||
smsAvailable = smsAvailable,
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
|
||||
@@ -88,95 +88,4 @@ class SmsManagerTest {
|
||||
assertFalse(plan.useMultipart)
|
||||
assertEquals(listOf("hello"), plan.parts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsAcceptsEmptyPayload() {
|
||||
val result = SmsManager.parseQueryParams(null, json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(25, ok.params.limit)
|
||||
assertEquals(0, ok.params.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsRejectsInvalidJson() {
|
||||
val result = SmsManager.parseQueryParams("not-json", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Error)
|
||||
val error = result as SmsManager.QueryParseResult.Error
|
||||
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsRejectsNonObjectJson() {
|
||||
val result = SmsManager.parseQueryParams("[]", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Error)
|
||||
val error = result as SmsManager.QueryParseResult.Error
|
||||
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesLimitAndOffset() {
|
||||
val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(10, ok.params.limit)
|
||||
assertEquals(5, ok.params.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsClampsLimitRange() {
|
||||
val result = SmsManager.parseQueryParams("{\"limit\":300}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(200, ok.params.limit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesPhoneNumber() {
|
||||
val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals("+1234567890", ok.params.phoneNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesContactName() {
|
||||
val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals("lixuankai", ok.params.contactName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesKeyword() {
|
||||
val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals("test", ok.params.keyword)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesTimeRange() {
|
||||
val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(1000L, ok.params.startTime)
|
||||
assertEquals(2000L, ok.params.endTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesType() {
|
||||
val result = SmsManager.parseQueryParams("{\"type\":1}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(1, ok.params.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQueryParamsParsesReadStatus() {
|
||||
val result = SmsManager.parseQueryParams("{\"isRead\":true}", json)
|
||||
assertTrue(result is SmsManager.QueryParseResult.Ok)
|
||||
val ok = result as SmsManager.QueryParseResult.Ok
|
||||
assertEquals(true, ok.params.isRead)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +90,4 @@ class OpenClawProtocolConstantsTest {
|
||||
fun callLogCommandsUseStableStrings() {
|
||||
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smsCommandsUseStableStrings() {
|
||||
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatImageCodecTest {
|
||||
@Test
|
||||
fun computeInSampleSizeCapsLongestEdge() {
|
||||
assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600))
|
||||
assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeAttachmentFileNameForcesJpegExtension() {
|
||||
assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png"))
|
||||
assertEquals("image.jpg", normalizeAttachmentFileName(""))
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
|
||||
|
||||
PACKAGE="ai.openclaw.app"
|
||||
ACTIVITY=".MainActivity"
|
||||
DEVICE_SERIAL=""
|
||||
INSTALL_APP="1"
|
||||
LAUNCH_RUNS="4"
|
||||
SCREEN_LOOPS="6"
|
||||
CHAT_LOOPS="8"
|
||||
POLL_ATTEMPTS="40"
|
||||
POLL_INTERVAL_SECONDS="0.3"
|
||||
SCREEN_MODE="transition"
|
||||
CHAT_MODE="session-switch"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-online-benchmark.sh [options]
|
||||
|
||||
Measures the fully-online Android app path on a connected device/emulator.
|
||||
Assumes the app can reach a live gateway and will show "Connected" in the UI.
|
||||
|
||||
Options:
|
||||
--device <serial> adb device serial
|
||||
--package <pkg> package name (default: ai.openclaw.app)
|
||||
--activity <activity> launch activity (default: .MainActivity)
|
||||
--skip-install skip :app:installDebug
|
||||
--launch-runs <n> launch-to-connected runs (default: 4)
|
||||
--screen-loops <n> screen benchmark loops (default: 6)
|
||||
--chat-loops <n> chat benchmark loops (default: 8)
|
||||
--screen-mode <mode> transition | scroll (default: transition)
|
||||
--chat-mode <mode> session-switch | scroll (default: session-switch)
|
||||
-h, --help show help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--device)
|
||||
DEVICE_SERIAL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--package)
|
||||
PACKAGE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--activity)
|
||||
ACTIVITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-install)
|
||||
INSTALL_APP="0"
|
||||
shift
|
||||
;;
|
||||
--launch-runs)
|
||||
LAUNCH_RUNS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--screen-loops)
|
||||
SCREEN_LOOPS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--chat-loops)
|
||||
CHAT_LOOPS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--screen-mode)
|
||||
SCREEN_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--chat-mode)
|
||||
CHAT_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "$1 required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd adb
|
||||
require_cmd awk
|
||||
require_cmd rg
|
||||
require_cmd node
|
||||
|
||||
adb_cmd() {
|
||||
if [[ -n "$DEVICE_SERIAL" ]]; then
|
||||
adb -s "$DEVICE_SERIAL" "$@"
|
||||
else
|
||||
adb "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then
|
||||
echo "Multiple adb devices found. Pass --device <serial>." >&2
|
||||
adb devices -l >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then
|
||||
echo "Unsupported --screen-mode: $SCREEN_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then
|
||||
echo "Unsupported --chat-mode: $CHAT_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
run_dir="$RESULTS_DIR/online-$timestamp"
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$run_dir"/ui-*.xml
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$INSTALL_APP" == "1" ]]; then
|
||||
(
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1
|
||||
)
|
||||
fi
|
||||
|
||||
read -r display_width display_height <<<"$(
|
||||
adb_cmd shell wm size \
|
||||
| awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }'
|
||||
)"
|
||||
|
||||
if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then
|
||||
echo "Failed to read device display size." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pct_of() {
|
||||
local total="$1"
|
||||
local pct="$2"
|
||||
awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }'
|
||||
}
|
||||
|
||||
tab_connect_x="$(pct_of "$display_width" "0.11")"
|
||||
tab_chat_x="$(pct_of "$display_width" "0.31")"
|
||||
tab_screen_x="$(pct_of "$display_width" "0.69")"
|
||||
tab_y="$(pct_of "$display_height" "0.93")"
|
||||
chat_session_y="$(pct_of "$display_height" "0.13")"
|
||||
chat_session_left_x="$(pct_of "$display_width" "0.16")"
|
||||
chat_session_right_x="$(pct_of "$display_width" "0.85")"
|
||||
center_x="$(pct_of "$display_width" "0.50")"
|
||||
screen_swipe_top_y="$(pct_of "$display_height" "0.27")"
|
||||
screen_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||
screen_swipe_low_y="$(pct_of "$display_height" "0.75")"
|
||||
screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")"
|
||||
chat_swipe_top_y="$(pct_of "$display_height" "0.29")"
|
||||
chat_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||
chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")"
|
||||
|
||||
dump_ui() {
|
||||
local name="$1"
|
||||
local file="$run_dir/ui-$name.xml"
|
||||
adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1
|
||||
adb_cmd shell cat "/sdcard/$name.xml" >"$file"
|
||||
printf '%s\n' "$file"
|
||||
}
|
||||
|
||||
ui_has() {
|
||||
local pattern="$1"
|
||||
local name="$2"
|
||||
local file
|
||||
file="$(dump_ui "$name")"
|
||||
rg -q "$pattern" "$file"
|
||||
}
|
||||
|
||||
wait_for_pattern() {
|
||||
local pattern="$1"
|
||||
local prefix="$2"
|
||||
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||
if ui_has "$pattern" "$prefix-$attempt"; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECONDS"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_connected() {
|
||||
if ! wait_for_pattern 'text="Connected"' "connected"; then
|
||||
echo "App never reached visible Connected state." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_screen_online() {
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 2
|
||||
if ! ui_has 'android\.webkit\.WebView' "screen"; then
|
||||
echo "Screen benchmark expected a live WebView." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_chat_online() {
|
||||
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||
sleep 2
|
||||
if ! ui_has 'Type a message' "chat"; then
|
||||
echo "Chat benchmark expected the live chat composer." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
capture_mem() {
|
||||
local file="$1"
|
||||
adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file"
|
||||
}
|
||||
|
||||
start_cpu_sampler() {
|
||||
local file="$1"
|
||||
local samples="$2"
|
||||
: >"$file"
|
||||
(
|
||||
for _ in $(seq 1 "$samples"); do
|
||||
adb_cmd shell top -b -n 1 \
|
||||
| awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file"
|
||||
sleep 0.5
|
||||
done
|
||||
) &
|
||||
CPU_SAMPLER_PID="$!"
|
||||
}
|
||||
|
||||
summarize_cpu() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
local avg max median count
|
||||
avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")"
|
||||
max="$(sort -n "$file" | tail -n 1)"
|
||||
median="$(
|
||||
sort -n "$file" \
|
||||
| awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }'
|
||||
)"
|
||||
count="$(wc -l <"$file" | tr -d ' ')"
|
||||
printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
summarize_mem() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
awk -v prefix="$prefix" '
|
||||
/TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 }
|
||||
/Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 }
|
||||
/WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF }
|
||||
' "$file" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
summarize_gfx() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
awk -v prefix="$prefix" '
|
||||
/Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 }
|
||||
/Janky frames:/ && $4 ~ /\(/ {
|
||||
pct=$4
|
||||
gsub(/[()%]/, "", pct)
|
||||
printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct
|
||||
}
|
||||
/50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 }
|
||||
/90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 }
|
||||
/95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 }
|
||||
/99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 }
|
||||
' "$file" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
measure_launch() {
|
||||
: >"$run_dir/launch-runs.txt"
|
||||
for run in $(seq 1 "$LAUNCH_RUNS"); do
|
||||
adb_cmd shell am force-stop "$PACKAGE" >/dev/null
|
||||
sleep 1
|
||||
start_ms="$(node -e 'console.log(Date.now())')"
|
||||
am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")"
|
||||
total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')"
|
||||
connected_ms="timeout"
|
||||
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||
if ui_has 'text="Connected"' "launch-run-$run"; then
|
||||
now_ms="$(node -e 'console.log(Date.now())')"
|
||||
connected_ms="$((now_ms - start_ms))"
|
||||
break
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECONDS"
|
||||
done
|
||||
printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \
|
||||
| tee -a "$run_dir/launch-runs.txt"
|
||||
done
|
||||
|
||||
awk -F'[ =]' '
|
||||
/total_time_ms=[0-9]+/ {
|
||||
value=$4
|
||||
sum+=value
|
||||
count+=1
|
||||
if (min==0 || value<min) min=value
|
||||
if (value>max) max=value
|
||||
}
|
||||
END {
|
||||
if (count==0) exit
|
||||
printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max
|
||||
}
|
||||
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||
|
||||
awk -F'[ =]' '
|
||||
/connected_ms=[0-9]+/ {
|
||||
value=$6
|
||||
sum+=value
|
||||
count+=1
|
||||
if (min==0 || value<min) min=value
|
||||
if (value>max) max=value
|
||||
}
|
||||
END {
|
||||
if (count==0) exit
|
||||
printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max
|
||||
}
|
||||
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
run_screen_benchmark() {
|
||||
ensure_screen_online
|
||||
capture_mem "$run_dir/screen-mem-before.txt"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||
start_cpu_sampler "$run_dir/screen-cpu.txt" 18
|
||||
|
||||
if [[ "$SCREEN_MODE" == "transition" ]]; then
|
||||
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 1.0
|
||||
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||
sleep 0.8
|
||||
done
|
||||
else
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 1.5
|
||||
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||
adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
done
|
||||
fi
|
||||
|
||||
wait "$CPU_SAMPLER_PID"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt"
|
||||
capture_mem "$run_dir/screen-mem-after.txt"
|
||||
summarize_gfx "$run_dir/screen-gfx.txt" "screen"
|
||||
summarize_cpu "$run_dir/screen-cpu.txt" "screen"
|
||||
summarize_mem "$run_dir/screen-mem-before.txt" "screen.before"
|
||||
summarize_mem "$run_dir/screen-mem-after.txt" "screen.after"
|
||||
}
|
||||
|
||||
run_chat_benchmark() {
|
||||
ensure_chat_online
|
||||
capture_mem "$run_dir/chat-mem-before.txt"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||
start_cpu_sampler "$run_dir/chat-cpu.txt" 18
|
||||
|
||||
if [[ "$CHAT_MODE" == "session-switch" ]]; then
|
||||
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||
adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null
|
||||
sleep 0.8
|
||||
adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null
|
||||
sleep 0.8
|
||||
done
|
||||
else
|
||||
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||
adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
done
|
||||
fi
|
||||
|
||||
wait "$CPU_SAMPLER_PID"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt"
|
||||
capture_mem "$run_dir/chat-mem-after.txt"
|
||||
summarize_gfx "$run_dir/chat-gfx.txt" "chat"
|
||||
summarize_cpu "$run_dir/chat-cpu.txt" "chat"
|
||||
summarize_mem "$run_dir/chat-mem-before.txt" "chat.before"
|
||||
summarize_mem "$run_dir/chat-mem-after.txt" "chat.after"
|
||||
}
|
||||
|
||||
printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt"
|
||||
printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt"
|
||||
printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt"
|
||||
printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt"
|
||||
printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt"
|
||||
printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt"
|
||||
printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt"
|
||||
|
||||
ensure_connected
|
||||
measure_launch
|
||||
ensure_connected
|
||||
run_screen_benchmark
|
||||
ensure_connected
|
||||
run_chat_benchmark
|
||||
|
||||
printf 'results_dir=%s\n' "$run_dir"
|
||||
cat "$run_dir/summary.txt"
|
||||
@@ -8,24 +8,6 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
static let messageName = "openclawCanvasA2UIAction"
|
||||
static let allMessageNames = [messageName]
|
||||
|
||||
// Compatibility helper for debug/test shims. Runtime dispatch remains
|
||||
// limited to in-app canvas schemes in `didReceive`.
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.lowercased(), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
guard let ip = Self.parseIPv4(host) else {
|
||||
return false
|
||||
}
|
||||
return Self.isLocalNetworkIPv4(ip)
|
||||
}
|
||||
|
||||
private let sessionKey: String
|
||||
|
||||
init(sessionKey: String) {
|
||||
@@ -122,24 +104,5 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
if a == 10 { return true }
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
if a == 192, b == 168 { return true }
|
||||
if a == 127 { return true }
|
||||
if a == 169, b == 254 { return true }
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
||||
}
|
||||
|
||||
@@ -81,23 +81,22 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
return self.html("Not Found", title: "Canvas: 404")
|
||||
}
|
||||
|
||||
// Resolve symlinks before enforcing the session-root boundary so links inside
|
||||
// the canvas tree cannot escape to arbitrary host files.
|
||||
let resolvedRoot = sessionRoot.resolvingSymlinksInPath().standardizedFileURL
|
||||
let resolvedFile = fileURL.resolvingSymlinksInPath().standardizedFileURL
|
||||
guard self.isFileURL(resolvedFile, withinDirectory: resolvedRoot) else {
|
||||
// Directory traversal guard: served files must live under the session root.
|
||||
let standardizedRoot = sessionRoot.standardizedFileURL
|
||||
let standardizedFile = fileURL.standardizedFileURL
|
||||
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
|
||||
return self.html("Forbidden", title: "Canvas: 403")
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: resolvedFile)
|
||||
let mime = CanvasScheme.mimeType(forExtension: resolvedFile.pathExtension)
|
||||
let servedPath = resolvedFile.path
|
||||
let data = try Data(contentsOf: standardizedFile)
|
||||
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||
let servedPath = standardizedFile.path
|
||||
canvasLogger.debug(
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
} catch {
|
||||
let failedPath = resolvedFile.path
|
||||
let failedPath = standardizedFile.path
|
||||
let errorText = error.localizedDescription
|
||||
canvasLogger
|
||||
.error(
|
||||
@@ -146,11 +145,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isFileURL(_ fileURL: URL, withinDirectory rootURL: URL) -> Bool {
|
||||
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
|
||||
return fileURL.path == rootURL.path || fileURL.path.hasPrefix(rootPath)
|
||||
}
|
||||
|
||||
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
|
||||
let html = """
|
||||
<!doctype html>
|
||||
|
||||
@@ -254,71 +254,6 @@ struct CronJob: Identifiable, Codable, Equatable {
|
||||
case state
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
agentId: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
deleteAfterRun: Bool?,
|
||||
createdAtMs: Int,
|
||||
updatedAtMs: Int,
|
||||
schedule: CronSchedule,
|
||||
sessionTarget: CronSessionTarget,
|
||||
wakeMode: CronWakeMode,
|
||||
payload: CronPayload,
|
||||
delivery: CronDelivery?,
|
||||
state: CronJobState)
|
||||
{
|
||||
self.init(
|
||||
id: id,
|
||||
agentId: agentId,
|
||||
name: name,
|
||||
description: description,
|
||||
enabled: enabled,
|
||||
deleteAfterRun: deleteAfterRun,
|
||||
createdAtMs: createdAtMs,
|
||||
updatedAtMs: updatedAtMs,
|
||||
schedule: schedule,
|
||||
sessionTarget: .predefined(sessionTarget),
|
||||
wakeMode: wakeMode,
|
||||
payload: payload,
|
||||
delivery: delivery,
|
||||
state: state)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
agentId: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
deleteAfterRun: Bool?,
|
||||
createdAtMs: Int,
|
||||
updatedAtMs: Int,
|
||||
schedule: CronSchedule,
|
||||
sessionTarget: CronCustomSessionTarget,
|
||||
wakeMode: CronWakeMode,
|
||||
payload: CronPayload,
|
||||
delivery: CronDelivery?,
|
||||
state: CronJobState)
|
||||
{
|
||||
self.id = id
|
||||
self.agentId = agentId
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteAfterRun = deleteAfterRun
|
||||
self.createdAtMs = createdAtMs
|
||||
self.updatedAtMs = updatedAtMs
|
||||
self.schedule = schedule
|
||||
self.sessionTargetRaw = sessionTarget.rawValue
|
||||
self.wakeMode = wakeMode
|
||||
self.payload = payload
|
||||
self.delivery = delivery
|
||||
self.state = state
|
||||
}
|
||||
|
||||
/// Parsed session target (predefined or custom session ID)
|
||||
var parsedSessionTarget: CronCustomSessionTarget {
|
||||
CronCustomSessionTarget.from(self.sessionTargetRaw)
|
||||
|
||||
@@ -89,20 +89,6 @@ private func readLineFromHandle(_ handle: FileHandle, maxBytes: Int) throws -> S
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
func timingSafeHexStringEquals(_ lhs: String, _ rhs: String) -> Bool {
|
||||
let lhsBytes = Array(lhs.utf8)
|
||||
let rhsBytes = Array(rhs.utf8)
|
||||
guard lhsBytes.count == rhsBytes.count else {
|
||||
return false
|
||||
}
|
||||
|
||||
var diff: UInt8 = 0
|
||||
for index in lhsBytes.indices {
|
||||
diff |= lhsBytes[index] ^ rhsBytes[index]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketClient {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
@@ -868,7 +854,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
||||
}
|
||||
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
||||
if !timingSafeHexStringEquals(expected, request.hmac) {
|
||||
if expected != request.hmac {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: request.id,
|
||||
|
||||
@@ -23,23 +23,11 @@ enum HostEnvSecurityPolicy {
|
||||
"PS4",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"PYTHONBREAKPOINT",
|
||||
"DOTNET_STARTUP_HOOKS",
|
||||
"DOTNET_ADDITIONAL_DEPS",
|
||||
"GLIBC_TUNABLES",
|
||||
"MAVEN_OPTS",
|
||||
"SBT_OPTS",
|
||||
"GRADLE_OPTS",
|
||||
"ANT_OPTS"
|
||||
"SSLKEYLOGFILE"
|
||||
]
|
||||
|
||||
static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"GRADLE_USER_HOME",
|
||||
"ZDOTDIR",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_SSH",
|
||||
|
||||
@@ -26,12 +26,7 @@ enum LaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String) {
|
||||
let plist = self.plistContents(bundlePath: bundlePath)
|
||||
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func plistContents(bundlePath: String) -> String {
|
||||
"""
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -46,6 +41,8 @@ enum LaunchAgentManager {
|
||||
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
@@ -58,6 +55,7 @@ enum LaunchAgentManager {
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
||||
@@ -1099,31 +1099,36 @@ extension MenuSessionsInjector {
|
||||
// MARK: - Width + placement
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findDynamicSectionInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findDynamicSectionInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
private func findDynamicSectionInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Keep controls and action buttons visible by inserting dynamic rows at the
|
||||
// built-in footer boundary, not by matching localized menu item titles.
|
||||
if let footerSeparatorIndex = menu.items.lastIndex(where: { item in
|
||||
item.isSeparatorItem && !self.isInjectedItem(item)
|
||||
}) {
|
||||
return footerSeparatorIndex
|
||||
// Insert right before the separator above "Send Heartbeats".
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let firstBaseItemIndex = menu.items.firstIndex(where: { !self.isInjectedItem($0) }) {
|
||||
return min(firstBaseItemIndex + 1, menu.items.count)
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func isInjectedItem(_ item: NSMenuItem) -> Bool {
|
||||
item.tag == self.tag || item.tag == self.nodesTag
|
||||
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
||||
@@ -1231,13 +1236,5 @@ extension MenuSessionsInjector {
|
||||
func injectForTesting(into menu: NSMenu) {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
|
||||
func testingFindInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findNodesInsertIndex(in: menu)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,7 +6,7 @@ enum NodeServiceManager {
|
||||
|
||||
static func start() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["start"],
|
||||
["node", "start"],
|
||||
timeout: 20,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
|
||||
@@ -18,7 +18,7 @@ enum NodeServiceManager {
|
||||
|
||||
static func stop() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["stop"],
|
||||
["node", "stop"],
|
||||
timeout: 15,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
|
||||
@@ -30,14 +30,6 @@ enum NodeServiceManager {
|
||||
}
|
||||
|
||||
extension NodeServiceManager {
|
||||
private static func serviceCommand(_ args: [String]) -> [String] {
|
||||
CommandResolver.openclawCommand(
|
||||
subcommand: "node",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Service management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
}
|
||||
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
@@ -60,7 +52,11 @@ extension NodeServiceManager {
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = self.serviceCommand(args)
|
||||
let command = CommandResolver.openclawCommand(
|
||||
subcommand: "service",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Service management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
@@ -140,11 +136,3 @@ extension NodeServiceManager {
|
||||
TextSummarySupport.summarizeLastLine(text)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension NodeServiceManager {
|
||||
static func _testServiceCommand(_ args: [String]) -> [String] {
|
||||
self.serviceCommand(args)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -515,8 +515,6 @@ public struct PollParams: Codable, Sendable {
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public let message: String
|
||||
public let agentid: String?
|
||||
public let provider: String?
|
||||
public let model: String?
|
||||
public let to: String?
|
||||
public let replyto: String?
|
||||
public let sessionid: String?
|
||||
@@ -544,8 +542,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
public init(
|
||||
message: String,
|
||||
agentid: String?,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
to: String?,
|
||||
replyto: String?,
|
||||
sessionid: String?,
|
||||
@@ -572,8 +568,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
{
|
||||
self.message = message
|
||||
self.agentid = agentid
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.to = to
|
||||
self.replyto = replyto
|
||||
self.sessionid = sessionid
|
||||
@@ -602,8 +596,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case agentid = "agentId"
|
||||
case provider
|
||||
case model
|
||||
case to
|
||||
case replyto = "replyTo"
|
||||
case sessionid = "sessionId"
|
||||
@@ -1326,124 +1318,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
public let label: String?
|
||||
public let model: String?
|
||||
public let parentsessionkey: String?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
agentid: String?,
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
task: String?,
|
||||
message: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.model = model
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.task = task
|
||||
self.message = message
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case model
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
case task
|
||||
case message
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsSendParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.message = message
|
||||
self.thinking = thinking
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case message
|
||||
case thinking
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsAbortParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let runid: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
runid: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.runid = runid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let label: AnyCodable?
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct ExecApprovalsSocketAuthTests {
|
||||
@Test
|
||||
func `timing safe hex compare matches equal strings`() {
|
||||
#expect(timingSafeHexStringEquals(String(repeating: "a", count: 64), String(repeating: "a", count: 64)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `timing safe hex compare rejects mismatched strings`() {
|
||||
let expected = String(repeating: "a", count: 63) + "b"
|
||||
let provided = String(repeating: "a", count: 63) + "c"
|
||||
#expect(!timingSafeHexStringEquals(expected, provided))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `timing safe hex compare rejects different length strings`() {
|
||||
#expect(!timingSafeHexStringEquals(String(repeating: "a", count: 64), "deadbeef"))
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct LaunchAgentManagerTests {
|
||||
@Test func `launch at login plist does not keep app alive after manual quit`() throws {
|
||||
let plist = LaunchAgentManager.plistContents(bundlePath: "/Applications/OpenClaw.app")
|
||||
let data = try #require(plist.data(using: .utf8))
|
||||
let object = try #require(
|
||||
PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
|
||||
)
|
||||
|
||||
#expect(object["RunAtLoad"] as? Bool == true)
|
||||
#expect(object["KeepAlive"] == nil)
|
||||
|
||||
let args = try #require(object["ProgramArguments"] as? [String])
|
||||
#expect(args == ["/Applications/OpenClaw.app/Contents/MacOS/OpenClaw"])
|
||||
}
|
||||
}
|
||||
@@ -216,32 +216,6 @@ struct LowCoverageHelperTests {
|
||||
#expect(handler._testTextEncodingName(for: "application/octet-stream") == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canvas scheme handler blocks symlink escapes`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
let session = root.appendingPathComponent("main", isDirectory: true)
|
||||
try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
|
||||
|
||||
let outside = root.deletingLastPathComponent().appendingPathComponent("canvas-secret-\(UUID().uuidString).txt")
|
||||
defer { try? FileManager().removeItem(at: outside) }
|
||||
try "top-secret".write(to: outside, atomically: true, encoding: .utf8)
|
||||
|
||||
let symlink = session.appendingPathComponent("index.html")
|
||||
try FileManager().createSymbolicLink(at: symlink, withDestinationURL: outside)
|
||||
|
||||
let handler = CanvasSchemeHandler(root: root)
|
||||
let url = try #require(CanvasScheme.makeURL(session: "main", path: "index.html"))
|
||||
let response = handler._testResponse(for: url)
|
||||
let body = String(data: response.data, encoding: .utf8) ?? ""
|
||||
|
||||
#expect(response.mime == "text/html")
|
||||
#expect(body.contains("Forbidden"))
|
||||
#expect(!body.contains("top-secret"))
|
||||
}
|
||||
|
||||
@Test @MainActor func `menu context card injector inserts and finds index`() {
|
||||
let injector = MenuContextCardInjector()
|
||||
let menu = NSMenu()
|
||||
|
||||
@@ -5,26 +5,7 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct MenuSessionsInjectorTests {
|
||||
@Test func anchorsDynamicRowsBelowControlsAndActions() throws {
|
||||
let injector = MenuSessionsInjector()
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Open Chat", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
|
||||
|
||||
let footerSeparatorIndex = try #require(menu.items.lastIndex(where: { $0.isSeparatorItem }))
|
||||
#expect(injector.testingFindInsertIndex(in: menu) == footerSeparatorIndex)
|
||||
#expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex)
|
||||
}
|
||||
|
||||
@Test func injectsDisconnectedMessage() {
|
||||
@Test func `injects disconnected message`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(false)
|
||||
injector.setTestingSnapshot(nil, errorText: nil)
|
||||
@@ -38,7 +19,7 @@ struct MenuSessionsInjectorTests {
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||
}
|
||||
|
||||
@Test func injectsSessionRows() throws {
|
||||
@Test func `injects session rows`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(true)
|
||||
|
||||
@@ -107,22 +88,10 @@ struct MenuSessionsInjectorTests {
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(NSMenuItem(title: "Browser Control", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Open Dashboard", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(NSMenuItem(title: "Settings…", action: nil, keyEquivalent: ""))
|
||||
|
||||
injector.injectForTesting(into: menu)
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
|
||||
let sendHeartbeatsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }))
|
||||
let openDashboardIndex = try #require(menu.items.firstIndex(where: { $0.title == "Open Dashboard" }))
|
||||
let firstInjectedIndex = try #require(menu.items.firstIndex(where: { $0.tag == 9_415_557 }))
|
||||
let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings…" }))
|
||||
#expect(sendHeartbeatsIndex < firstInjectedIndex)
|
||||
#expect(openDashboardIndex < firstInjectedIndex)
|
||||
#expect(firstInjectedIndex < settingsIndex)
|
||||
}
|
||||
|
||||
@Test func `cost usage submenu does not use injector delegate`() {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct NodeServiceManagerTests {
|
||||
@Test func `builds node service commands with current CLI shape`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: openclawPath)
|
||||
|
||||
let start = NodeServiceManager._testServiceCommand(["start"])
|
||||
#expect(start == [openclawPath.path, "node", "start", "--json"])
|
||||
|
||||
let stop = NodeServiceManager._testServiceCommand(["stop"])
|
||||
#expect(stop == [openclawPath.path, "node", "stop", "--json"])
|
||||
}
|
||||
}
|
||||
@@ -515,8 +515,6 @@ public struct PollParams: Codable, Sendable {
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public let message: String
|
||||
public let agentid: String?
|
||||
public let provider: String?
|
||||
public let model: String?
|
||||
public let to: String?
|
||||
public let replyto: String?
|
||||
public let sessionid: String?
|
||||
@@ -544,8 +542,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
public init(
|
||||
message: String,
|
||||
agentid: String?,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
to: String?,
|
||||
replyto: String?,
|
||||
sessionid: String?,
|
||||
@@ -572,8 +568,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
{
|
||||
self.message = message
|
||||
self.agentid = agentid
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.to = to
|
||||
self.replyto = replyto
|
||||
self.sessionid = sessionid
|
||||
@@ -602,8 +596,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case agentid = "agentId"
|
||||
case provider
|
||||
case model
|
||||
case to
|
||||
case replyto = "replyTo"
|
||||
case sessionid = "sessionId"
|
||||
@@ -1326,124 +1318,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
public let label: String?
|
||||
public let model: String?
|
||||
public let parentsessionkey: String?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
agentid: String?,
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
task: String?,
|
||||
message: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.model = model
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.task = task
|
||||
self.message = message
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case model
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
case task
|
||||
case message
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsSendParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.message = message
|
||||
self.thinking = thinking
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case message
|
||||
case thinking
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsAbortParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let runid: String?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
runid: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.runid = runid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let label: AnyCodable?
|
||||
|
||||
23
assets/chrome-extension/README.md
Normal file
23
assets/chrome-extension/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# OpenClaw Chrome Extension (Browser Relay)
|
||||
|
||||
Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
|
||||
|
||||
## Dev / load unpacked
|
||||
|
||||
1. Build/run OpenClaw Gateway with browser control enabled.
|
||||
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
|
||||
3. Install the extension to a stable path:
|
||||
|
||||
```bash
|
||||
openclaw browser extension install
|
||||
openclaw browser extension path
|
||||
```
|
||||
|
||||
4. Chrome → `chrome://extensions` → enable “Developer mode”.
|
||||
5. “Load unpacked” → select the path printed above.
|
||||
6. Pin the extension. Click the icon on a tab to attach/detach.
|
||||
|
||||
## Options
|
||||
|
||||
- `Relay port`: defaults to `18792`.
|
||||
- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).
|
||||
64
assets/chrome-extension/background-utils.js
Normal file
64
assets/chrome-extension/background-utils.js
Normal file
@@ -0,0 +1,64 @@
|
||||
export function reconnectDelayMs(
|
||||
attempt,
|
||||
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
|
||||
) {
|
||||
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
|
||||
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
|
||||
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
|
||||
const random = typeof opts.random === "function" ? opts.random : Math.random;
|
||||
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
|
||||
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
|
||||
return backoff + Math.max(0, jitterMs) * random();
|
||||
}
|
||||
|
||||
export async function deriveRelayToken(gatewayToken, port) {
|
||||
const enc = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(gatewayToken),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
enc.encode(`openclaw-extension-relay-v1:${port}`),
|
||||
);
|
||||
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function buildRelayWsUrl(port, gatewayToken) {
|
||||
const token = String(gatewayToken || "").trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)",
|
||||
);
|
||||
}
|
||||
const relayToken = await deriveRelayToken(token, port);
|
||||
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`;
|
||||
}
|
||||
|
||||
export function isRetryableReconnectError(err) {
|
||||
const message = err instanceof Error ? err.message : String(err || "");
|
||||
if (message.includes("Missing gatewayToken")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isMissingTabError(err) {
|
||||
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
|
||||
return (
|
||||
message.includes("no tab with id") ||
|
||||
message.includes("no tab with given id") ||
|
||||
message.includes("tab not found")
|
||||
);
|
||||
}
|
||||
|
||||
export function isLastRemainingTab(allTabs, tabIdToClose) {
|
||||
if (!Array.isArray(allTabs)) {
|
||||
return true;
|
||||
}
|
||||
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
|
||||
}
|
||||
1025
assets/chrome-extension/background.js
Normal file
1025
assets/chrome-extension/background.js
Normal file
File diff suppressed because it is too large
Load Diff
25
assets/chrome-extension/manifest.json
Normal file
25
assets/chrome-extension/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenClaw Browser Relay",
|
||||
"version": "0.1.0",
|
||||
"description": "Attach OpenClaw to your existing Chrome tab via a local CDP relay server.",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
|
||||
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": {
|
||||
"default_title": "OpenClaw Browser Relay (click to attach/detach)",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"options_ui": { "page": "options.html", "open_in_tab": true }
|
||||
}
|
||||
57
assets/chrome-extension/options-validation.js
Normal file
57
assets/chrome-extension/options-validation.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).'
|
||||
|
||||
function hasCdpVersionShape(data) {
|
||||
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
|
||||
}
|
||||
|
||||
export function classifyRelayCheckResponse(res, port) {
|
||||
if (!res) {
|
||||
return { action: 'throw', error: 'No response from service worker' }
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' }
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
return { action: 'throw', error: res.error }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { action: 'throw', error: `HTTP ${res.status}` }
|
||||
}
|
||||
|
||||
const contentType = String(res.contentType || '')
|
||||
if (!contentType.includes('application/json')) {
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCdpVersionShape(res.json)) {
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` }
|
||||
}
|
||||
|
||||
export function classifyRelayCheckException(err, port) {
|
||||
const message = String(err || '').toLowerCase()
|
||||
if (message.includes('json') || message.includes('syntax')) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`,
|
||||
}
|
||||
}
|
||||
200
assets/chrome-extension/options.html
Normal file
200
assets/chrome-extension/options.html
Normal file
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenClaw Browser Relay</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #ff5a36;
|
||||
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
--border: color-mix(in oklab, canvasText 18%, transparent);
|
||||
--muted: color-mix(in oklab, canvasText 70%, transparent);
|
||||
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
|
||||
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
|
||||
"SF Pro Display", "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
|
||||
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
|
||||
canvas;
|
||||
color: canvasText;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 820px;
|
||||
margin: 36px auto;
|
||||
padding: 0 24px 48px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
width: 160px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
color: canvasText;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--accent) 80%, white 20%),
|
||||
var(--accent)
|
||||
);
|
||||
color: white;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
a {
|
||||
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
|
||||
min-height: 16px;
|
||||
}
|
||||
.status[data-kind='ok'] {
|
||||
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
|
||||
}
|
||||
.status[data-kind='error'] {
|
||||
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo" aria-hidden="true">
|
||||
<img src="icons/icon128.png" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>OpenClaw Browser Relay</h1>
|
||||
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Getting started</h2>
|
||||
<p>
|
||||
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
||||
Start OpenClaw’s browser relay on this machine (Gateway or node host), then click the toolbar button again.
|
||||
</p>
|
||||
<p>
|
||||
Full guide (install, remote Gateway, security): <a href="https://docs.openclaw.ai/tools/chrome-extension" target="_blank" rel="noreferrer">docs.openclaw.ai/tools/chrome-extension</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Relay connection</h2>
|
||||
<label for="port">Port</label>
|
||||
<div class="row">
|
||||
<input id="port" inputmode="numeric" pattern="[0-9]*" />
|
||||
</div>
|
||||
<label for="token" style="margin-top: 10px">Gateway token</label>
|
||||
<div class="row">
|
||||
<input id="token" type="password" autocomplete="off" style="width: min(520px, 100%)" />
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:<port>/</code>.
|
||||
Gateway token must match <code>gateway.auth.token</code> (or <code>OPENCLAW_GATEWAY_TOKEN</code>).
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
74
assets/chrome-extension/options.js
Normal file
74
assets/chrome-extension/options.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { deriveRelayToken } from './background-utils.js'
|
||||
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
function clampPort(value) {
|
||||
const n = Number.parseInt(String(value || ''), 10)
|
||||
if (!Number.isFinite(n)) return DEFAULT_PORT
|
||||
if (n <= 0 || n > 65535) return DEFAULT_PORT
|
||||
return n
|
||||
}
|
||||
|
||||
function updateRelayUrl(port) {
|
||||
const el = document.getElementById('relay-url')
|
||||
if (!el) return
|
||||
el.textContent = `http://127.0.0.1:${port}/`
|
||||
}
|
||||
|
||||
function setStatus(kind, message) {
|
||||
const status = document.getElementById('status')
|
||||
if (!status) return
|
||||
status.dataset.kind = kind || ''
|
||||
status.textContent = message || ''
|
||||
}
|
||||
|
||||
async function checkRelayReachable(port, token) {
|
||||
const url = `http://127.0.0.1:${port}/json/version`
|
||||
const trimmedToken = String(token || '').trim()
|
||||
if (!trimmedToken) {
|
||||
setStatus('error', 'Gateway token required. Save your gateway token to connect.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const relayToken = await deriveRelayToken(trimmedToken, port)
|
||||
// Delegate the fetch to the background service worker to bypass
|
||||
// CORS preflight on the custom x-openclaw-relay-token header.
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: 'relayCheck',
|
||||
url,
|
||||
token: relayToken,
|
||||
})
|
||||
const result = classifyRelayCheckResponse(res, port)
|
||||
if (result.action === 'throw') throw new Error(result.error)
|
||||
setStatus(result.kind, result.message)
|
||||
} catch (err) {
|
||||
const result = classifyRelayCheckException(err, port)
|
||||
setStatus(result.kind, result.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken'])
|
||||
const port = clampPort(stored.relayPort)
|
||||
const token = String(stored.gatewayToken || '').trim()
|
||||
document.getElementById('port').value = String(port)
|
||||
document.getElementById('token').value = token
|
||||
updateRelayUrl(port)
|
||||
await checkRelayReachable(port, token)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const portInput = document.getElementById('port')
|
||||
const tokenInput = document.getElementById('token')
|
||||
const port = clampPort(portInput.value)
|
||||
const token = String(tokenInput.value || '').trim()
|
||||
await chrome.storage.local.set({ relayPort: port, gatewayToken: token })
|
||||
portInput.value = String(port)
|
||||
tokenInput.value = token
|
||||
updateRelayUrl(port)
|
||||
await checkRelayReachable(port, token)
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => void save())
|
||||
void load()
|
||||
1
changelog/fragments/openai-codex-auth-tests-gpt54.md
Normal file
1
changelog/fragments/openai-codex-auth-tests-gpt54.md
Normal file
@@ -0,0 +1 @@
|
||||
- tests: align OpenAI Codex auth login expectations with the `gpt-5.4` default model to prevent stale CI failures. (#44367) thanks @jrrcdev
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixes
|
||||
|
||||
- Gateway/session history: return `404` for unknown session history lookups, unsubscribe session lifecycle listeners during shutdown, add coverage for the new transcript and lifecycle helpers, and tighten session history plus live transcript tests so the Control UI session surfaces stay stable under restart and follow mode.
|
||||
@@ -0,0 +1 @@
|
||||
- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -47,22 +47,6 @@
|
||||
"source": "Quick Start",
|
||||
"target": "快速开始"
|
||||
},
|
||||
{
|
||||
"source": "Capability Cookbook",
|
||||
"target": "能力扩展手册"
|
||||
},
|
||||
{
|
||||
"source": "Setup Wizard Reference",
|
||||
"target": "设置向导参考"
|
||||
},
|
||||
{
|
||||
"source": "CLI Setup Reference",
|
||||
"target": "CLI 设置参考"
|
||||
},
|
||||
{
|
||||
"source": "Setup Wizard (CLI)",
|
||||
"target": "设置向导(CLI)"
|
||||
},
|
||||
{
|
||||
"source": "Docs directory",
|
||||
"target": "文档目录"
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Capability Catalog And Arbitration Spec
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines how the system compiles agent-visible, operator-visible, and runtime-internal catalogs from active contributions and how it resolves conflicting or parallel providers.
|
||||
|
||||
The kernel should expose canonical actions, not raw plugin identities.
|
||||
|
||||
Host-managed install, onboarding, and lightweight channel catalogs remain separate from the kernel capability catalog.
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Implement kernel-owned internal and agent-visible catalogs.
|
||||
- [ ] Implement host-owned operator catalogs and static setup catalogs.
|
||||
- [ ] Implement canonical action registration and review workflow in code.
|
||||
- [ ] Implement arbitration and conflict handling for at least one multi-provider family.
|
||||
- [ ] Migrate the existing tool, provider, setup, and slot-selection surfaces so they no longer act as parallel catalog or arbitration systems.
|
||||
- [ ] Record pilot parity for `thread-ownership` first and `telegram` second before broader catalog publication.
|
||||
- [ ] Track which current `main` actions have been mapped into canonical action ids.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this spec:
|
||||
|
||||
- canonical catalogs and arbitration have not started
|
||||
- host-managed static metadata work and early runtime/lifecycle boundary extraction have landed
|
||||
|
||||
What has been implemented:
|
||||
|
||||
- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- channel catalog package metadata parsing now routes through host-owned schema helpers
|
||||
- host-owned resolved-extension records now carry the static metadata needed for install, onboarding, and lightweight operator UX
|
||||
- config doc baseline generation now uses the same host-owned resolved-extension metadata path
|
||||
- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts`
|
||||
- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts`
|
||||
- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts`
|
||||
- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts`
|
||||
- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts`
|
||||
- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts`
|
||||
- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts`
|
||||
- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts`
|
||||
- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts`
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts`
|
||||
- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts`
|
||||
- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts`
|
||||
- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts`
|
||||
- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts`
|
||||
- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts`
|
||||
- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts`
|
||||
- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts`
|
||||
- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts`
|
||||
- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts`
|
||||
- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts`
|
||||
- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts`
|
||||
- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts`
|
||||
- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts`
|
||||
- channel, provider, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now has a host-owned helper boundary for future catalog migration
|
||||
- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts` ahead of broader catalog-backed registry ownership
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts` ahead of broader catalog-backed registry ownership
|
||||
- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts` ahead of broader catalog-backed registry ownership
|
||||
- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts` ahead of broader catalog-backed registry ownership, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now route through `src/extension-host/contributions/command-runtime.ts` ahead of broader catalog-backed ownership
|
||||
- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts` ahead of broader catalog-backed lifecycle ownership
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts` ahead of broader catalog-backed CLI ownership
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts` ahead of broader catalog-backed gateway ownership
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts` ahead of broader catalog-backed tool ownership
|
||||
- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts` ahead of broader catalog-backed provider ownership
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts` ahead of broader catalog-backed provider-discovery ownership
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts` ahead of broader catalog-backed provider-auth ownership
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts` ahead of broader catalog-backed provider-setup ownership
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts` ahead of broader catalog-backed provider-setup ownership
|
||||
- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts` ahead of broader catalog-backed provider-setup ownership
|
||||
|
||||
How it has been implemented:
|
||||
|
||||
- by moving package metadata parsing behind `src/extension-host/manifests/schema.ts`
|
||||
- by keeping the existing catalog behavior intact while shifting metadata ownership into normalized host-owned records
|
||||
- by reusing the resolved-extension registry for static operator/documentation surfaces instead of creating separate metadata caches
|
||||
- by beginning runtime registration migration with host-owned normalization helpers before attempting full canonical catalog publication
|
||||
- by beginning actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations before attempting full canonical catalog publication
|
||||
- by moving cache-key construction and registry cache control behind host-owned helpers before attempting canonical catalog publication
|
||||
- by beginning loader-path migration with host-owned compatibility, candidate-planning, import-flow, policy, runtime, register-flow, candidate-orchestration, top-level load orchestration, record-state with compatibility lifecycle mapping, and finalization helpers before attempting canonical catalog publication
|
||||
- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before catalog publication work
|
||||
- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before catalog publication work
|
||||
- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before catalog publication work
|
||||
- by converting the compatibility record-state layer into an enforced loader lifecycle state machine before catalog publication work
|
||||
- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before catalog publication work
|
||||
- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before catalog publication work
|
||||
- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before catalog publication work
|
||||
- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before catalog publication work
|
||||
- by moving mutable activation state into a host-owned loader session before catalog publication work
|
||||
- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation
|
||||
- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before catalog publication work
|
||||
- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before catalog publication work
|
||||
- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before catalog publication work
|
||||
- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged
|
||||
- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface
|
||||
- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner
|
||||
- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged
|
||||
- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline
|
||||
- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper before broader catalog-backed service ownership
|
||||
- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper before broader catalog-backed CLI ownership
|
||||
- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper before broader catalog-backed gateway ownership
|
||||
- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper before broader catalog-backed tool ownership
|
||||
- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper before broader catalog-backed provider ownership
|
||||
- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper before broader catalog-backed provider-discovery ownership
|
||||
- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper before broader catalog-backed provider-auth ownership
|
||||
- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper before broader catalog-backed provider-setup ownership
|
||||
- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper before broader catalog-backed provider-setup ownership
|
||||
- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper before broader catalog-backed provider-setup ownership
|
||||
- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts`
|
||||
- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph
|
||||
- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps before broader catalog publication or arbitration work
|
||||
- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing into `src/extension-host/contributions/command-runtime.ts` before broader catalog publication or arbitration work
|
||||
|
||||
What remains pending:
|
||||
|
||||
- canonical capability ids
|
||||
- runtime-derived kernel catalogs
|
||||
- host-owned operator catalogs beyond the existing lightweight static paths
|
||||
- arbitration modes and selection logic
|
||||
- tool/provider/slot migration into one canonical catalog and arbitration model
|
||||
|
||||
## Goals
|
||||
|
||||
- agents see a stable, context-aware catalog of what they can do
|
||||
- multiple active providers for the same functional area are supported
|
||||
- collisions are detected and resolved deterministically
|
||||
- operator commands and runtime backends stay separate from agent tools
|
||||
- the catalog covers the broader current action surface, not only send and reply
|
||||
- slot-backed providers such as context engines are selected explicitly
|
||||
- setup and install metadata stay in host-managed catalogs instead of leaking into runtime catalogs
|
||||
|
||||
## Migration Framing
|
||||
|
||||
This spec replaces existing partial catalog and arbitration behavior already present on `main`.
|
||||
|
||||
It is not a standalone greenfield system.
|
||||
|
||||
Current behavior already exists in at least these places:
|
||||
|
||||
- agent-visible plugin tool grouping in `src/gateway/server-methods/tools-catalog.ts:71`
|
||||
- provider auth and setup selection in `src/commands/auth-choice.apply.plugin-provider.ts:106`
|
||||
- slot selection in `src/plugins/slots.ts:39`
|
||||
- channel picker and onboarding metadata in `src/channels/plugins/catalog.ts:26`
|
||||
|
||||
Implementation rule:
|
||||
|
||||
- Phase 5 and Phase 6 are only complete when those legacy paths have been absorbed into the canonical or host-owned catalog model rather than left as a second source of truth
|
||||
|
||||
## Catalog Types
|
||||
|
||||
The system should maintain separate catalogs for:
|
||||
|
||||
- agent-visible capabilities
|
||||
- operator-visible capabilities
|
||||
- runtime-internal providers
|
||||
|
||||
These catalogs may draw from the same contributions but have different visibility and arbitration rules.
|
||||
|
||||
Ownership split:
|
||||
|
||||
- the kernel publishes runtime-derived internal and agent-visible catalogs
|
||||
- the extension host publishes operator-visible catalogs, including host-only surfaces and any runtime-derived entries the operator surface needs
|
||||
|
||||
## Host-Managed Setup And Install Catalogs
|
||||
|
||||
Current `main` also has host-managed metadata that is not a kernel capability catalog:
|
||||
|
||||
- install metadata from `src/plugins/install.ts:48`
|
||||
- channel picker and onboarding metadata from `src/channels/plugins/catalog.ts:26`
|
||||
- lightweight shared channel behavior from `src/channels/dock.ts:228`
|
||||
|
||||
The extension host should keep publishing these static catalogs for setup and operator UX.
|
||||
|
||||
They should not be folded into the agent capability catalog.
|
||||
|
||||
This host-managed layer should also publish:
|
||||
|
||||
- local operator CLI commands from `surface.cli`
|
||||
- setup and onboarding flows from `surface.setup`
|
||||
- static channel picker metadata and lightweight dock-derived operator hints without activating heavy runtimes
|
||||
|
||||
Sequencing rule:
|
||||
|
||||
- these host-managed static catalogs should migrate before broad runtime catalog publication because they depend on static metadata, not heavy activation
|
||||
|
||||
## Canonical Capability Model
|
||||
|
||||
Each catalog entry should contain:
|
||||
|
||||
- `capabilityId`
|
||||
- `kind`
|
||||
- `canonicalAction`
|
||||
- `displayName`
|
||||
- `description`
|
||||
- `providerKey`
|
||||
- `scope`
|
||||
- `availability`
|
||||
- `requiresSelection`
|
||||
- `inputSchema`
|
||||
- `outputSchema`
|
||||
- `policy`
|
||||
- `telemetryTags`
|
||||
|
||||
### `capabilityId`
|
||||
|
||||
Stable runtime id for the contribution-backed capability.
|
||||
|
||||
### `canonicalAction`
|
||||
|
||||
A stable action family such as:
|
||||
|
||||
- `message.send`
|
||||
- `message.reply`
|
||||
- `directory.lookup`
|
||||
- `provider.authenticate`
|
||||
- `provider.configure`
|
||||
- `memory.search`
|
||||
- `memory.store`
|
||||
- `message.broadcast`
|
||||
- `message.poll`
|
||||
- `message.react`
|
||||
- `message.edit`
|
||||
- `message.delete`
|
||||
- `message.pin`
|
||||
- `message.thread.manage`
|
||||
- `voice.call.start`
|
||||
- `diff.render`
|
||||
|
||||
The agent planner reasons over canonical actions first.
|
||||
|
||||
Governance decision:
|
||||
|
||||
- canonical action ids are open, namespaced strings
|
||||
- core action families should still live in one source-of-truth registry in code
|
||||
- if a new capability fits an existing family, reuse it
|
||||
- if semantics are new, add a reviewed canonical action id to that registry
|
||||
- contributions may not define new arbitration modes or planner semantics outside the core catalog and arbitration schema
|
||||
|
||||
### `providerKey`
|
||||
|
||||
Identifies the concrete provider instance behind the action.
|
||||
|
||||
Examples:
|
||||
|
||||
- `messaging:slack:work`
|
||||
- `messaging:telegram:personal`
|
||||
- `memory:lancedb:default`
|
||||
- `runtime-backend:acp:acpx`
|
||||
|
||||
## Visibility Rules
|
||||
|
||||
### Agent-visible
|
||||
|
||||
Used for agent planning and tool calling.
|
||||
|
||||
Includes:
|
||||
|
||||
- agent tools
|
||||
- channel messaging actions such as send, reply, broadcast, poll, react, edit, delete, pin, and thread actions when available in context
|
||||
- memory actions when policy allows them
|
||||
- voice or telephony actions
|
||||
- selected interaction or workflow actions
|
||||
|
||||
Important interaction rule:
|
||||
|
||||
- interaction-driven actions must be filtered by the current binding and route context
|
||||
- a bound conversation should only surface interaction actions that are valid for the owning extension and current adapter capabilities
|
||||
|
||||
### Operator-visible
|
||||
|
||||
Used for admin, control, setup, CLI, and diagnostic surfaces.
|
||||
|
||||
Includes:
|
||||
|
||||
- control commands
|
||||
- setup flows
|
||||
- provider integration and auth flows
|
||||
- status surfaces
|
||||
- CLI commands
|
||||
|
||||
Important distinction:
|
||||
|
||||
- `capability.control-command` is for chat or native commands that bypass the model
|
||||
- `surface.cli` and `surface.setup` are host-managed local operator surfaces and are not kernel runtime capabilities
|
||||
|
||||
Operator-visible control-command surfaces should preserve current command metadata such as:
|
||||
|
||||
- whether the command accepts arguments
|
||||
- provider-specific native command names when a provider supports native slash or menu registration
|
||||
|
||||
### Runtime-internal
|
||||
|
||||
Not shown to agents or operators as catalog actions.
|
||||
|
||||
Includes:
|
||||
|
||||
- runtime backends
|
||||
- context engines
|
||||
- pure event observers
|
||||
- route augmenters
|
||||
|
||||
## Conflict Classes
|
||||
|
||||
The host must resolve different conflict types differently.
|
||||
|
||||
### 1. Runtime id conflict
|
||||
|
||||
Fatal during validation.
|
||||
|
||||
### 2. Canonical action overlap
|
||||
|
||||
Multiple providers implement the same action family.
|
||||
|
||||
This is expected for messaging, auth, or directory.
|
||||
|
||||
### 3. Planner-visible name collision
|
||||
|
||||
Two agent-visible capabilities want the same public name.
|
||||
|
||||
This must be resolved before catalog publication.
|
||||
|
||||
### 4. Singleton slot conflict
|
||||
|
||||
Two contributions claim a slot that is intentionally exclusive.
|
||||
|
||||
Examples:
|
||||
|
||||
- default memory backend
|
||||
- default context engine
|
||||
|
||||
### 5. Route surface conflict
|
||||
|
||||
Two contributions require the same target or routing ownership semantics.
|
||||
|
||||
### 6. Backend selector conflict
|
||||
|
||||
Two runtime backends claim the same selector with incompatible exclusivity.
|
||||
|
||||
## Arbitration Modes
|
||||
|
||||
### `exclusive`
|
||||
|
||||
Exactly one active provider may exist for the slot.
|
||||
|
||||
Examples:
|
||||
|
||||
- one default context engine
|
||||
- one default memory store, unless the operator opts into parallel memory providers
|
||||
|
||||
### `ranked`
|
||||
|
||||
Many providers may exist, but one default is chosen by rank.
|
||||
|
||||
Examples:
|
||||
|
||||
- multiple auth methods for one provider
|
||||
- multiple backends for the same subsystem
|
||||
|
||||
### `parallel`
|
||||
|
||||
Many providers may remain simultaneously available.
|
||||
|
||||
Examples:
|
||||
|
||||
- Slack, Discord, and Telegram messaging providers for the same agent
|
||||
- multiple directory sources
|
||||
|
||||
### `composed`
|
||||
|
||||
Many providers contribute to a single pipeline.
|
||||
|
||||
Examples:
|
||||
|
||||
- context augmentation
|
||||
- prompt guidance
|
||||
- telemetry enrichment
|
||||
|
||||
## Agent Catalog Compilation
|
||||
|
||||
The kernel compiles the agent-visible catalog from:
|
||||
|
||||
- active contributions
|
||||
- current workspace
|
||||
- current agent
|
||||
- active session bindings
|
||||
- route and account context
|
||||
- current adapter action support
|
||||
- policy restrictions
|
||||
- contribution visibility rules
|
||||
|
||||
Catalog compilation is context-sensitive.
|
||||
|
||||
The same agent may see different capability sets in:
|
||||
|
||||
- Slack thread context
|
||||
- Telegram DM context
|
||||
- voice call context
|
||||
- local CLI session
|
||||
|
||||
First-cut migration targets:
|
||||
|
||||
- plugin tools currently exposed by plugin grouping
|
||||
- messaging actions for the first channel pilot
|
||||
- route-affecting behaviors that influence whether an action is available at all
|
||||
|
||||
## Capability Selection Rules
|
||||
|
||||
When the agent or runtime needs one provider for a canonical action, selection should use this order:
|
||||
|
||||
1. explicit target or provider selector
|
||||
2. explicit session binding
|
||||
3. current conversation or thread route binding
|
||||
4. current adapter or account capability support
|
||||
5. policy-forced default
|
||||
6. ranked default provider
|
||||
7. deterministic fallback by extension id and contribution id
|
||||
|
||||
This is especially important for `message.send` and `message.reply`.
|
||||
|
||||
It also applies to interaction and conversation-control actions, which should prefer:
|
||||
|
||||
- current binding owner
|
||||
- current adapter support
|
||||
- explicit target selection only when ownership or adapter support is ambiguous
|
||||
|
||||
## Messaging Example
|
||||
|
||||
One agent may have:
|
||||
|
||||
- Discord adapter on work account
|
||||
- Slack adapter on work account
|
||||
- Telegram adapter on personal account
|
||||
|
||||
The agent should not see three unrelated tools named “send message”.
|
||||
|
||||
Instead it should see canonical action families, with provider resolution handled by:
|
||||
|
||||
- current conversation route
|
||||
- current session binding
|
||||
- explicit target selector when needed
|
||||
|
||||
Examples:
|
||||
|
||||
- `message.send`
|
||||
- `message.reply`
|
||||
- `message.broadcast`
|
||||
- `message.poll`
|
||||
- `message.react`
|
||||
|
||||
If disambiguation is required, the planner or runtime can use structured selectors such as:
|
||||
|
||||
- target channel kind
|
||||
- account id
|
||||
- conversation ref
|
||||
|
||||
## Agent Naming Rules
|
||||
|
||||
Agent-visible names must be stable and minimally ambiguous.
|
||||
|
||||
Rules:
|
||||
|
||||
- canonical names belong to action families
|
||||
- provider labels are attached only when needed for disambiguation
|
||||
- aliases do not create additional planner-visible tools unless explicitly requested
|
||||
- the host rejects duplicate planner-visible names when the runtime cannot disambiguate them
|
||||
|
||||
This avoids exposing raw extension names unless necessary.
|
||||
|
||||
## Operator Command Separation
|
||||
|
||||
Control commands are not agent tools.
|
||||
|
||||
Examples today:
|
||||
|
||||
- `src/extension-host/contributions/command-runtime.ts:1`
|
||||
- `extensions/phone-control/index.ts:330`
|
||||
|
||||
They belong only in operator catalogs and control surfaces.
|
||||
|
||||
## Provider Integration Selection
|
||||
|
||||
Provider integration flows should be modeled as operator-visible capabilities, not agent-visible tools.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- provider id first
|
||||
- method id second
|
||||
- rank or policy third
|
||||
|
||||
Multiple auth methods for one provider may coexist.
|
||||
|
||||
The selected provider integration may also contribute:
|
||||
|
||||
- discovery order
|
||||
- onboarding metadata
|
||||
- token refresh behavior
|
||||
- model-selected hooks
|
||||
|
||||
It should not silently absorb unrelated subsystem runtimes such as embeddings, transcription, media understanding, or TTS.
|
||||
It should also not silently absorb agent-visible search surfaces, which belong in the agent-tool catalog even when they call remote search services.
|
||||
|
||||
## Memory Arbitration
|
||||
|
||||
Memory needs both backend arbitration and agent action arbitration.
|
||||
|
||||
### Backend arbitration
|
||||
|
||||
Usually `exclusive` or `ranked`.
|
||||
|
||||
### Agent action arbitration
|
||||
|
||||
May still expose:
|
||||
|
||||
- `memory.search`
|
||||
- `memory.store`
|
||||
|
||||
If parallel memory providers are enabled, the planner should either target the default store or use explicit selectors.
|
||||
|
||||
## Context Engine Arbitration
|
||||
|
||||
Context engines are runtime-internal providers selected through an explicit exclusive slot.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- explicit configured engine id wins
|
||||
- otherwise use the slot default
|
||||
- if the selected engine is unavailable, fail with a typed configuration error rather than silently picking an arbitrary fallback
|
||||
|
||||
## Runtime Backend Arbitration
|
||||
|
||||
Runtime backends such as ACP are runtime-internal providers.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- explicit backend id wins
|
||||
- otherwise use healthy highest-ranked backend
|
||||
- if a subsystem declares an exclusive slot, the host enforces it before kernel startup
|
||||
|
||||
This is why `capability.runtime-backend` must be a first-class family.
|
||||
|
||||
The same model should be available for other subsystem runtimes discovered during migration:
|
||||
|
||||
- embeddings
|
||||
- audio transcription
|
||||
- image understanding
|
||||
- video understanding
|
||||
- text-to-speech
|
||||
|
||||
Selection rules for these subsystem runtimes should preserve these required behaviors:
|
||||
|
||||
- capability-based selection
|
||||
- normalized provider ids
|
||||
- explicit built-in fallback policy
|
||||
- typed host-injected request envelopes
|
||||
|
||||
Architecture rule:
|
||||
|
||||
- keep those selection and envelope rules inside host-owned subsystem runtime registries for typed backend families
|
||||
- do not widen provider-integration or legacy plugin-provider APIs into a universal surface for unrelated runtime subsystems
|
||||
- if search is agent-visible, publish it through canonical tool catalogs; reserve `capability.runtime-backend` for search backends that are consumed internally by the host or another subsystem
|
||||
|
||||
## Catalog Publication
|
||||
|
||||
The kernel should publish:
|
||||
|
||||
- a full internal catalog
|
||||
- a filtered agent catalog
|
||||
|
||||
The extension host should publish:
|
||||
|
||||
- a filtered operator catalog
|
||||
|
||||
Publication should occur after:
|
||||
|
||||
- dependency resolution
|
||||
- policy approval
|
||||
- contribution activation
|
||||
- route and account context binding
|
||||
|
||||
Host-managed install and onboarding descriptors may move into host ownership earlier because they come from static metadata, not runtime activation.
|
||||
|
||||
Full catalog publication, consolidation, and legacy-path replacement still belong to the catalog-migration phase.
|
||||
|
||||
Performance requirement:
|
||||
|
||||
- publishing host-managed setup and install catalogs must not require activating heavy adapter runtimes
|
||||
- publishing operator-visible static catalogs must preserve current dock-style cheap-path behavior, including prompt hints and shared formatting helpers where those are consumed without runtime activation
|
||||
|
||||
## Telemetry And Auditing
|
||||
|
||||
Capability selection must emit structured events for:
|
||||
|
||||
- conflict detection
|
||||
- provider selection
|
||||
- fallback selection
|
||||
- planner-visible disambiguation
|
||||
- veto or cancellation caused by route augmenters
|
||||
- slot selection for context engines or other exclusive runtime providers
|
||||
|
||||
## Migration Mapping From Today
|
||||
|
||||
- channel capabilities from `extensions/discord/src/channel.ts:74`, `extensions/slack/src/channel.ts:107`, and `extensions/telegram/src/channel.ts:120` collapse into canonical messaging action families
|
||||
- diffs becomes an agent-visible tool family plus a host-managed route surface from `extensions/diffs/index.ts:27`
|
||||
- provider integration from `extensions/google-gemini-cli-auth/index.ts:24` becomes operator-visible setup and auth capabilities
|
||||
- embedding, media-understanding, and TTS provider overrides should become runtime-internal subsystem registries rather than remaining part of a universal plugin-provider API
|
||||
- extension-backed web search should become an agent-visible tool family unless it is only a runtime-internal backend feeding another host-owned surface
|
||||
- voice-call from `extensions/voice-call/index.ts:230` becomes a mix of agent-visible actions, runtime providers, and operator surfaces
|
||||
- ACP backend registration from `extensions/acpx/src/service.ts:55` becomes runtime-internal backend arbitration
|
||||
- context-engine registration becomes runtime-internal slot arbitration from `src/context-engine/registry.ts:60`
|
||||
- native command registration remains an operator or transport surface concern rather than an agent-visible catalog concern
|
||||
|
||||
## Immediate Implementation Work
|
||||
|
||||
1. Add canonical action ids and provider keys to resolved contributions.
|
||||
2. Implement host-side conflict detection for planner-visible names and singleton slots.
|
||||
3. Implement kernel-side context-aware catalog compilation.
|
||||
4. Add host-managed static catalogs for install and onboarding metadata alongside the runtime catalogs.
|
||||
5. Migrate the existing plugin tool grouping path onto canonical agent catalog entries.
|
||||
6. Migrate the existing provider auth and setup selection path onto host-owned setup catalogs and canonical provider metadata.
|
||||
7. Add provider selection logic for the broader messaging action family before migrating all channels.
|
||||
8. Add runtime-backend and context-engine arbitration using the same rank and slot model where appropriate.
|
||||
9. Add host-owned embedding, media-understanding, and TTS subsystem registries with explicit capability routing and built-in fallback policy.
|
||||
10. Decide whether extension-backed search needs only canonical tool publication or also a host-owned runtime registry for internal search backends, and keep those two cases distinct.
|
||||
11. Ensure lightweight setup catalogs can be built from static descriptors alone.
|
||||
12. Add a reviewed core registry for canonical action families and document how new ids are introduced.
|
||||
13. Record catalog and arbitration parity for `thread-ownership` first and `telegram` second before broader rollout.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,659 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Extension Host Implementation Guide
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This is the main execution guide for implementing the extension-host and kernel transition.
|
||||
|
||||
Use it as the top-level implementation document.
|
||||
|
||||
## How We Fix It
|
||||
|
||||
Fix this as a staged architectural migration, not a broad refactor.
|
||||
|
||||
1. Lock the boundary first by writing the cutover inventory and adding anti-corruption interfaces so no new plugin-specific behavior leaks into the kernel.
|
||||
2. Introduce source-of-truth extension schema types and the `ResolvedExtension` model while preserving current `openclaw/plugin-sdk/*` loading through minimal compatibility support.
|
||||
3. Move discovery, policy, provenance, static metadata, and registration ownership into the extension host, including hooks, channels, providers, tools, routes, CLI, setup, services, and slot-backed providers.
|
||||
4. Prove the path with pilot migrations: `thread-ownership` first for non-channel hook behavior, then `telegram` for channel compatibility.
|
||||
5. After pilot parity is established, move runtime behavior onto canonical event stages and replace the fragmented tool, provider, and slot-selection paths with one catalog and arbitration model.
|
||||
6. Remove the legacy plugin runtime as the default path only after the host path has parity and the duplicate legacy systems are gone or explicitly downgraded to compatibility-only shims.
|
||||
|
||||
The other docs remain the source of truth for their domains:
|
||||
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
- `openclaw-extension-host-lifecycle-and-security-spec.md`
|
||||
- `openclaw-kernel-event-pipeline-spec.md`
|
||||
- `openclaw-capability-catalog-and-arbitration-spec.md`
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Confirm the implementation order and owners for each phase.
|
||||
- [x] Create the initial code skeleton for kernel and extension-host boundaries.
|
||||
- [x] Write the initial boundary cutover inventory for every current plugin-owned surface.
|
||||
- [ ] Keep the boundary cutover inventory updated as surfaces move.
|
||||
- [ ] Track PRs, migrations, and follow-up gaps by phase.
|
||||
- [ ] Keep the linked spec TODO sections in sync with implementation progress.
|
||||
- [ ] Define the detailed pilot migration matrix and parity checks before Phase 3 starts.
|
||||
- [ ] Mark this guide complete only when the legacy plugin path is no longer the primary runtime path.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this guide:
|
||||
|
||||
- Phase 0 has started but is not complete.
|
||||
- Phase 1 has started but is not complete.
|
||||
- Phase 2 has started in a broad, compatibility-preserving form but is not complete.
|
||||
- Phases 3 through 7 have not started in a meaningful way yet.
|
||||
|
||||
What has been implemented so far:
|
||||
|
||||
- a new `src/extension-host/*` boundary now exists in code
|
||||
- active runtime registry ownership moved into `src/extension-host/static/active-registry.ts`
|
||||
- `src/plugins/runtime.ts` now acts as a compatibility facade over the host-owned active registry
|
||||
- registry activation now routes through `src/extension-host/activation.ts`
|
||||
- initial source-of-truth types landed in `src/extension-host/manifests/schema.ts`, including `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy`
|
||||
- static manifest and package metadata are now normalized through host-owned helpers rather than being interpreted only inside plugin-era modules
|
||||
- `src/plugins/manifest-registry.ts` now carries a normalized `resolvedExtension` alongside the legacy flat manifest record
|
||||
- `src/extension-host/manifests/resolved-registry.ts` now exposes a host-owned resolved-extension registry view
|
||||
- an initial Phase 0 inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- plugin SDK alias resolution now routes through `src/extension-host/compat/loader-compat.ts`
|
||||
- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts`
|
||||
- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts`
|
||||
- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts`
|
||||
- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts`
|
||||
- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts`
|
||||
- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts`
|
||||
- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts`
|
||||
- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts`
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts`
|
||||
- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts`
|
||||
- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts`
|
||||
- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts`
|
||||
- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts`
|
||||
- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts`
|
||||
- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts`
|
||||
- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts`
|
||||
- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts`
|
||||
- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts`
|
||||
- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts`
|
||||
- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts`
|
||||
- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts`
|
||||
- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts`
|
||||
- runtime registration normalization has started in `src/extension-host/contributions/runtime-registrations.ts` for channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registrations
|
||||
- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now route through `src/extension-host/contributions/registry-writes.ts`
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now route through `src/extension-host/compat/hook-compat.ts`
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now route through `src/extension-host/compat/plugin-api.ts`
|
||||
- compatibility plugin-registry facade ownership now routes through `src/extension-host/compat/plugin-registry.ts`
|
||||
- compatibility plugin-registry policy now routes through `src/extension-host/compat/plugin-registry-compat.ts`
|
||||
- compatibility plugin-registry registration actions now route through `src/extension-host/compat/plugin-registry-registrations.ts`
|
||||
- host-owned runtime registry accessors now route through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- service startup, stop ordering, service-context creation, and failure logging now route through `src/extension-host/contributions/service-lifecycle.ts`
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now route through `src/extension-host/contributions/cli-lifecycle.ts`
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now route through `src/extension-host/contributions/gateway-methods.ts`
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now route through `src/extension-host/contributions/tool-runtime.ts`
|
||||
- plugin provider projection from registry entries into runtime provider objects now routes through `src/extension-host/contributions/provider-runtime.ts`
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now route through `src/extension-host/contributions/provider-discovery.ts`
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now route through `src/extension-host/contributions/provider-auth.ts`
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now route through `src/extension-host/contributions/provider-wizard.ts`
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now route through `src/extension-host/contributions/provider-auth-flow.ts`
|
||||
- provider post-selection hook lookup and invocation now route through `src/extension-host/contributions/provider-model-selection.ts`
|
||||
- several static and lookup consumers now read through the host boundary or resolved-extension model:
|
||||
- channel registry and dock lookups
|
||||
- message-channel normalization
|
||||
- plugin HTTP route registry default lookup
|
||||
- discovery and install package metadata parsing
|
||||
- channel catalog package metadata parsing
|
||||
- plugin skill discovery
|
||||
- plugin auto-enable
|
||||
- config doc baseline generation
|
||||
- config validation indexing
|
||||
- several runtime consumers now also read through host-owned runtime-registry accessors instead of touching raw plugin-registry arrays or handler maps directly:
|
||||
- channel lookup
|
||||
- provider projection
|
||||
- tool resolution
|
||||
- service lifecycle startup
|
||||
- CLI registration
|
||||
- command runtime entry detection
|
||||
- gateway method aggregation
|
||||
- gateway plugin HTTP route matching
|
||||
- plugin command execution and command-status listing now read through `src/extension-host/contributions/command-runtime.ts` instead of the legacy `src/plugins/commands.ts` implementation
|
||||
- the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now also keep host-owned runtime-registry storage with mirrored legacy compatibility arrays and handler maps
|
||||
- `src/cli/plugin-registry.ts` now treats any pre-seeded runtime entry surface as already loaded, not just plugins, channels, or tools
|
||||
|
||||
How it has been done:
|
||||
|
||||
- by extracting narrow host-owned modules first and making existing plugin modules delegate to them
|
||||
- by preserving current behavior and import surfaces wherever possible instead of attempting a broad rewrite
|
||||
- by introducing normalized static records before touching heavy runtime activation paths
|
||||
- by converting one static consumer at a time so each call site can move without forcing a loader rewrite
|
||||
- by extracting low-risk runtime registration helpers next and letting `src/plugins/registry.ts` delegate to them as a compatibility facade
|
||||
- by starting actual low-risk runtime write ownership next for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations while keeping lifecycle semantics in legacy owners where that behavior still lives
|
||||
- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing behind `src/extension-host/contributions/command-runtime.ts` while keeping `src/plugins/commands.ts` as the compatibility facade
|
||||
- by starting loader and lifecycle migration with compatibility helpers for activation and SDK alias resolution before changing discovery or policy behavior
|
||||
- by moving cache-key construction, cache reads, cache writes, and cache clearing behind host-owned helpers before changing activation-state ownership
|
||||
- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes
|
||||
- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes
|
||||
- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes
|
||||
- by moving loader-owned policy helpers next, while keeping module loading and enablement flow behavior unchanged
|
||||
- by moving initial candidate planning and record construction behind host-owned helpers before changing import and registration flow
|
||||
- by moving entry-path opening and module import behind host-owned helpers before changing cache wiring or lifecycle orchestration
|
||||
- by moving loader runtime decisions behind host-owned helpers while preserving lazy loading, config validation behavior, and memory-slot policy behavior
|
||||
- by moving post-import planning and `register(...)` execution behind host-owned helpers before changing entry-path and import flow
|
||||
- by composing those seams into one host-owned per-candidate orchestrator before changing cache and lifecycle finalization behavior
|
||||
- by moving loader record-state transitions into host-owned helpers before enforcing them as a loader lifecycle state machine
|
||||
- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine
|
||||
- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine
|
||||
- by turning that compatibility `lifecycleState` field into an enforced loader lifecycle state machine with readiness promotion during finalization
|
||||
- by moving the remaining top-level loader orchestration into a host-owned module so `src/plugins/loader.ts` becomes a compatibility facade instead of the real owner
|
||||
- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface
|
||||
- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface
|
||||
- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface
|
||||
- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface
|
||||
- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session instead of leaving them in top-level loader variables
|
||||
- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation
|
||||
- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them
|
||||
- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes instead of leaving them inline in the loader flow
|
||||
- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them
|
||||
- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged
|
||||
- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface
|
||||
- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner
|
||||
- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged
|
||||
- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline
|
||||
- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point
|
||||
- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point
|
||||
- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code
|
||||
- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path
|
||||
- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade
|
||||
- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks
|
||||
- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point
|
||||
- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface
|
||||
- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts`
|
||||
- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph
|
||||
- by introducing host-owned runtime-registry accessors for low-risk runtime consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps
|
||||
- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools
|
||||
- by moving central readers first, so later lifecycle and compatibility work can land on one boundary instead of many ad hoc call sites
|
||||
- by adding focused tests for each extracted seam before widening the boundary further
|
||||
|
||||
Committed implementation slices so far:
|
||||
|
||||
- `6abf6750ee` `Plugins: add extension host registry boundary`
|
||||
- `1aab89e820` `Plugins: extract loader host seams`
|
||||
- `7bc3135082` `Plugins: extract loader candidate planning`
|
||||
- `3a122c95fa` `Plugins: extract loader register flow`
|
||||
- `fc81454038` `Plugins: extract loader import flow`
|
||||
- `e1b207f4cf` `Plugins: extract loader candidate orchestration`
|
||||
- `0c44d8049b` `Plugins: extract loader finalization`
|
||||
- `33ef55a9ee` `Plugins: add loader lifecycle state mapping`
|
||||
- `6590e19095` `Plugins: extract loader cache control`
|
||||
- `c8d82a8f19` `Plugins: extract loader orchestration`
|
||||
- `d32f65eb5e` `Plugins: add loader lifecycle state machine`
|
||||
- `da9aad0c0f` `Plugins: add loader activation session`
|
||||
- `fc51ce2867` `Plugins: add loader activation policy`
|
||||
- `fd7488e10a` `Plugins: add loader finalization policy`
|
||||
- `97e2af7f97` `Plugins: add loader discovery policy`
|
||||
- `83b18eab72` `Plugins: share loader provenance helpers`
|
||||
- `52495d23d5` `Plugins: extract loader runtime factories`
|
||||
- `6e187ffb62` `Plugins: extract loader bootstrap`
|
||||
- `234a540720` `Plugins: extract loader session runner`
|
||||
- `a98443c39d` `Plugins: extract loader execution setup`
|
||||
- `c9323aa016` `Plugins: extract loader preflight`
|
||||
- `0df51ae6b4` `Plugins: extract loader pipeline`
|
||||
- `e557b39cb2` `Plugins: extract loader host state`
|
||||
- `07c3ae9c87` `Plugins: extract low-risk registry writes`
|
||||
- `bc71592270` `Plugins: extend registry write helpers`
|
||||
- `27fc645484` `Plugins: extend registry writes for hooks`
|
||||
- `b407d7f476` `Plugins: extract hook compatibility`
|
||||
- `a1e1dcc01a` `Plugins: extract plugin api facade`
|
||||
- `0e190d64d4` `Plugins: extract registry compatibility facade`
|
||||
- `944d787df1` `Plugins: extract registry compatibility policy`
|
||||
- `4ca9cd7e5e` `Plugins: extract registry registration actions`
|
||||
- `6b24e65719` `Plugins: extract service lifecycle`
|
||||
- `b5757a6625` `Plugins: extract CLI lifecycle`
|
||||
- `e0e3229bcb` `Gateway: extract extension host method surface`
|
||||
- `af7ac14eed` `Plugins: extract tool runtime`
|
||||
- `19087405d2` `Plugins: extract provider runtime`
|
||||
- `1303419471` `Plugins: extract provider discovery`
|
||||
- `afb6e4b185` `Plugins: extract provider auth and wizard flows`
|
||||
- `cc3d59d59e` `Plugins: extract provider auth application flow`
|
||||
- `e6cd834f8e` `Plugins: extract provider model selection hook`
|
||||
- `11cbe08ec6` `Plugins: add host-owned route and gateway storage`
|
||||
- `89e6b38152` `Docs: refresh runtime registry storage status`
|
||||
- `ad0c235d16` `Plugins: add host-owned CLI and service storage`
|
||||
- `d34a5aa870` `Docs: refresh runtime registry storage progress`
|
||||
- `2be54e9861` `Plugins: add host-owned tool and provider storage`
|
||||
- `235021766c` `Docs: refresh tool and provider storage status`
|
||||
- `e109d5ef1b` `Plugins: add host-owned channel storage`
|
||||
- `24fca48453` `Docs: refresh channel storage status`
|
||||
- `961015f08c` `Channels: finish message-channel host lookup`
|
||||
- `4c7f62649b` `Plugins: extract command runtime`
|
||||
- `89414ed857` `Docs: track extension host migration internally`
|
||||
- `d8af1eceaf` `Docs: refresh extension host migration status`
|
||||
|
||||
What is still missing for these phases:
|
||||
|
||||
- keeping the cutover inventory current as more surfaces move
|
||||
- broader lifecycle ownership beyond the loader state machine, service-lifecycle boundary, CLI-lifecycle boundary, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes, remaining policy gate ownership, and broad host-owned registries described for Phase 2
|
||||
- minimal SDK compatibility work beyond preserving current behavior indirectly through existing loading
|
||||
- host-owned conversation binding, interaction routing, ingress claim, and generic interactive control surfaces
|
||||
- host-owned subsystem runtime registries for embeddings, media understanding, and TTS
|
||||
- explicit support for extension-backed search, with a generic split between agent-visible tool publication and optional runtime-internal search backends
|
||||
- any pilot migration, event pipeline, canonical catalog, or arbitration implementation
|
||||
|
||||
Recent plan refinements:
|
||||
|
||||
- the plan now explicitly treats conversation binding ownership, approval persistence, restore-on-restart behavior, and detached-binding cleanup as first-class migration surfaces
|
||||
- it now explicitly treats interactive callback routing, namespace ownership, dedupe, and fallback behavior as first-class migration surfaces
|
||||
- it now explicitly treats inbound claim as a canonical ingress-stage concern rather than a permanent plugin-era hook shape
|
||||
- it now explicitly treats Telegram and Discord as the first validated rollout targets for interactive control surfaces while keeping the underlying contracts generic, host-owned, and kernel-agnostic
|
||||
- it now explicitly treats embeddings, media understanding, and TTS as host-owned subsystem runtimes with capability routing, typed request envelopes, provider-id normalization, and fallback policy
|
||||
- it now explicitly rejects widening the legacy `registerProvider(...)` or `ProviderPlugin` surface into a universal runtime API while retaining capability routing, typed request envelopes, provider-id normalization, and fallback behavior where those are part of the target model
|
||||
- it now explicitly treats extension-backed search as either a canonical tool contribution or a host-owned runtime backend depending on whether the search surface is agent-visible
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Implement phases in this order:
|
||||
|
||||
1. Phase 0: boundary inventory and anti-corruption layer
|
||||
2. Phase 1: contribution schema, package metadata, and minimal SDK compatibility
|
||||
3. Phase 2: extension host lifecycle and registries
|
||||
4. Phase 3: broader legacy compatibility bridges
|
||||
5. Phase 4: canonical event pipeline
|
||||
6. Phase 5: capability catalog migration
|
||||
7. Phase 6: arbitration migration
|
||||
8. Phase 7: broader migration and legacy removal
|
||||
|
||||
This order matters because each layer depends on the previous one:
|
||||
|
||||
- catalogs depend on normalized contributions
|
||||
- normalized contributions depend on host discovery and validation
|
||||
- existing extensions must keep loading while the schema and SDK boundary changes
|
||||
- migrated hooks depend on the canonical event pipeline
|
||||
- install, onboarding, and status flows depend on static metadata before runtime activation
|
||||
- catalogs and arbitration already exist in partial forms, so their phases are migrations, not greenfield work
|
||||
- useful ideas from implementation review should be harvested as parity requirements and host-owned capabilities, not by broadening legacy `src/plugins/*` or `src/plugin-sdk/*` surfaces as the target architecture
|
||||
- safe removal of legacy paths depends on compatibility coverage and parity checks
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
Do not implement every abstraction in the docs in the first cut.
|
||||
|
||||
Treat some parts of the design as ceilings rather than immediate scope:
|
||||
|
||||
- event taxonomy should start with three execution modes only:
|
||||
- parallel observers
|
||||
- sequential merge or decision handlers
|
||||
- sync transcript hot paths
|
||||
- permission modes should implement `advisory` and `host-enforced` first
|
||||
- `sandbox-enforced` should remain a future contract until real isolation exists
|
||||
- catalog publication should start small:
|
||||
- kernel internal catalog
|
||||
- kernel agent catalog
|
||||
- host operator and static registries
|
||||
- adapter metadata should stay minimal and parity-driven
|
||||
- setup flow typing should start with a small result set:
|
||||
- config patch
|
||||
- credential result
|
||||
- status note
|
||||
- follow-up action
|
||||
- canonical action governance should start as one source file plus tests, not a larger process framework
|
||||
- arbitration should start with:
|
||||
- exclusive slot
|
||||
- ranked provider
|
||||
- parallel provider
|
||||
|
||||
The first implementation goal is parity for pilot migrations, not maximum generality.
|
||||
|
||||
If a design choice is not required to migrate one channel extension and one non-channel extension safely, defer it.
|
||||
|
||||
## Current Runtime Surfaces That Must Be Accounted For
|
||||
|
||||
The current plugin system already owns more than runtime activation.
|
||||
|
||||
Before implementation starts, write and maintain a cutover inventory for these surfaces:
|
||||
|
||||
- manifest loading and static metadata
|
||||
- package-level install and onboarding metadata
|
||||
- discovery, provenance, and origin precedence
|
||||
- config schema and UI hint loading
|
||||
- typed hooks and legacy hook bridges
|
||||
- channels and channel lookup
|
||||
- providers and provider auth/setup flows
|
||||
- tools and agent-visible tool catalogs
|
||||
- HTTP routes and gateway methods
|
||||
- CLI registrars and plugin commands
|
||||
- services and context-engine registrations
|
||||
- slot selection and other existing arbitration paths
|
||||
- status, reload, install, update, and diagnostics surfaces
|
||||
|
||||
Do not treat Phase 5 and Phase 6 as new systems built in isolation.
|
||||
|
||||
They must absorb and replace the existing partial catalog and arbitration behaviors rather than creating a second source of truth.
|
||||
|
||||
## Phase Guide
|
||||
|
||||
### Phase 0: Lock the boundary
|
||||
|
||||
Goal:
|
||||
|
||||
- define the kernel versus extension-host boundary in code and imports
|
||||
- inventory every current plugin-owned surface that crosses that boundary
|
||||
|
||||
Deliverables:
|
||||
|
||||
- boundary cutover inventory
|
||||
- anti-corruption interfaces for host-owned registration surfaces
|
||||
- initial feature flags for host-path versus legacy-path execution
|
||||
- directory and import boundaries for kernel and extension-host code
|
||||
|
||||
Primary docs:
|
||||
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- kernel code does not take new dependencies on legacy plugin shapes
|
||||
- extension-host directory structure exists
|
||||
- compatibility-only surfaces are identified
|
||||
- each current plugin-owned surface is tagged as kernel-owned, host-owned, or compatibility-only
|
||||
- no new direct writes to global registries are introduced without going through the new boundary
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- partially implemented
|
||||
- the code boundary exists in `src/extension-host/*`
|
||||
- central active-registry ownership now routes through the host boundary
|
||||
- several central runtime readers now consume the host-owned boundary instead of reading directly from `src/plugins/runtime.ts`
|
||||
- the initial cutover inventory now exists in `src/extension-host/cutover-inventory.md` and is being updated as surfaces move, but the phase is still incomplete because loader orchestration, lifecycle ownership, and later compatibility phases have not moved yet
|
||||
|
||||
### Phase 1: Define the schema
|
||||
|
||||
Goal:
|
||||
|
||||
- implement the source-of-truth manifest and contribution types
|
||||
- preserve existing extension loading while the schema and SDK boundary changes
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- manifest parser
|
||||
- package metadata parser
|
||||
- contribution validators
|
||||
- `ResolvedExtension`
|
||||
- `ResolvedContribution`
|
||||
- typed `ContributionPolicy`
|
||||
- static metadata parser
|
||||
- new versioned SDK contract surface
|
||||
- minimal SDK compatibility loading surface
|
||||
- normalized install and onboarding metadata model
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- extensions can be normalized into static and runtime sections without activating heavy runtime code
|
||||
- existing extension SDK imports still resolve through the compatibility loading path
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- partially implemented
|
||||
- `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` landed as initial code types
|
||||
- legacy manifest and package metadata now converge into a normalized `resolvedExtension` record carried by the manifest registry
|
||||
- discovery, install, and catalog metadata parsing now go through host-owned schema helpers
|
||||
- partial explicit compatibility now exists through host-owned loader-compat and loader-runtime helpers, but full manifest or contribution validators and a versioned SDK compatibility layer are not implemented yet
|
||||
|
||||
### Phase 2: Build the extension host
|
||||
|
||||
Goal:
|
||||
|
||||
- implement discovery, validation, policy, registries, and lifecycle ownership
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-extension-host-lifecycle-and-security-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- discovery pipeline
|
||||
- activation state machine
|
||||
- policy evaluator
|
||||
- host-owned registries
|
||||
- host-owned adapters for hooks, channels, providers, tools, HTTP routes, gateway methods, CLI, services, commands, and context engines
|
||||
- per-extension state ownership
|
||||
- provenance and origin handling
|
||||
- config redaction-aware schema loading
|
||||
- reload and route ownership handling
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- the host can load bundled and external extensions into normalized registries
|
||||
- the host can populate normalized registries without direct kernel writes except through explicit compatibility adapters
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- partially implemented in a compatibility-preserving form
|
||||
- the host owns the active registry state
|
||||
- the host exposes a resolved-extension registry view for static consumers
|
||||
- plugin skills, plugin auto-enable, and config validation indexing now consume host-owned resolved-extension data
|
||||
- activation, loader cache control, loader policy, loader discovery-policy outcomes, loader activation-policy outcomes, loader finalization-policy outcomes, loader candidate planning, loader import flow, loader runtime decisions, loader post-import register flow, loader candidate orchestration, loader top-level load orchestration, loader session state, loader record-state helpers, and loader finalization now route through `src/extension-host/*`
|
||||
- broader lifecycle state ownership beyond the loader state machine, activation states, policy evaluation, and broad host-owned registries are still not implemented
|
||||
|
||||
### Phase 3: Build compatibility bridges
|
||||
|
||||
Goal:
|
||||
|
||||
- keep current extensions working through the host without leaking legacy contracts into the kernel
|
||||
|
||||
Primary docs:
|
||||
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
- `openclaw-extension-contribution-schema-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `ChannelPlugin` compatibility translators
|
||||
- plugin SDK compatibility loading
|
||||
- runtime-channel namespace translation into the new SDK modules
|
||||
- legacy setup and CLI translation
|
||||
- legacy config schema and UI hint translation
|
||||
- pilot migration matrix with explicit parity labels
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- `thread-ownership` runs through the host path as the first non-channel pilot
|
||||
- `telegram` runs through the host path as the first channel pilot
|
||||
- both pilots have explicit parity results for discovery, config, activation, diagnostics, and runtime behavior
|
||||
|
||||
### Phase 4: Implement the canonical event pipeline
|
||||
|
||||
Goal:
|
||||
|
||||
- move runtime hook behavior onto explicit canonical events
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-kernel-event-pipeline-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- event type definitions
|
||||
- stage runner
|
||||
- sync transcript-write stages
|
||||
- bridges from legacy hook buses
|
||||
- mapping table from existing typed and legacy hooks to canonical stages
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- migrated extensions can use canonical events without relying directly on old plugin hook execution
|
||||
- pilot hook behaviors have parity coverage against the pre-host path
|
||||
|
||||
### Phase 5: Implement catalogs
|
||||
|
||||
Goal:
|
||||
|
||||
- compile runtime-derived agent and internal catalogs, plus host-owned operator catalogs
|
||||
- replace existing plugin-identity-driven catalog surfaces with canonical family-based catalogs
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-capability-catalog-and-arbitration-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- kernel internal catalog
|
||||
- kernel agent catalog
|
||||
- host operator catalog
|
||||
- static setup and install catalogs
|
||||
- canonical action registry
|
||||
- migration plan for existing tool, provider, and setup catalog surfaces
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- agent-visible tools are compiled from canonical action families instead of plugin identity
|
||||
- setup and install catalogs no longer depend on duplicated legacy metadata paths
|
||||
|
||||
### Phase 6: Implement arbitration
|
||||
|
||||
Goal:
|
||||
|
||||
- resolve overlap, ranking, selection, and slot conflicts deterministically
|
||||
- absorb the existing slot and provider selection behavior into canonical arbitration
|
||||
|
||||
Primary doc:
|
||||
|
||||
- `openclaw-capability-catalog-and-arbitration-spec.md`
|
||||
|
||||
Deliverables:
|
||||
|
||||
- conflict detection
|
||||
- provider selection
|
||||
- slot arbitration
|
||||
- planner-visible name collision handling
|
||||
- migration plan for existing slot and name-collision behaviors
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- at least one multi-provider family works through canonical arbitration
|
||||
- legacy slot and provider-selection paths no longer act as separate arbitration systems
|
||||
|
||||
### Phase 7: Migrate and remove legacy paths
|
||||
|
||||
Goal:
|
||||
|
||||
- finish migration and shrink compatibility-only surfaces
|
||||
|
||||
Primary docs:
|
||||
|
||||
- `openclaw-kernel-extension-host-transition-plan.md`
|
||||
- all other docs as parity references
|
||||
|
||||
Deliverables:
|
||||
|
||||
- channel migrations
|
||||
- non-channel extension migrations
|
||||
- parity tests
|
||||
- deprecation markers
|
||||
- removal plan for obsolete compatibility shims
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- legacy plugin runtime is no longer the default execution path
|
||||
|
||||
## Pilot Matrix
|
||||
|
||||
Initial pilot set:
|
||||
|
||||
- non-channel pilot: `thread-ownership`
|
||||
- channel pilot: `telegram`
|
||||
|
||||
Why these pilots:
|
||||
|
||||
- `thread-ownership` exercises typed hook loading without introducing CLI, HTTP route, or service migration at the same time
|
||||
- `telegram` exercises the `ChannelPlugin` compatibility path with a minimal top-level plugin registration surface
|
||||
|
||||
Second-wave compatibility candidates after the pilots are stable:
|
||||
|
||||
- `line` for channel plus command registration
|
||||
- `device-pair` for command, service, and setup flow coverage
|
||||
|
||||
Each pilot must record parity for:
|
||||
|
||||
- discovery and precedence
|
||||
- manifest and static metadata loading
|
||||
- config schema and UI hints
|
||||
- enabled and disabled state handling
|
||||
- activation and reload behavior
|
||||
- diagnostics and status output
|
||||
- runtime behavior on the migrated path
|
||||
- compatibility-only gaps that still remain
|
||||
|
||||
## Recommended First Implementation Slice
|
||||
|
||||
If you want the lowest-risk start, do this first:
|
||||
|
||||
1. write the boundary cutover inventory
|
||||
2. add source-of-truth types
|
||||
3. add the static metadata and package metadata parsers
|
||||
4. add `ResolvedExtension`
|
||||
5. add minimal SDK compatibility loading
|
||||
6. add host discovery and validation
|
||||
7. bring `thread-ownership` through the host path first
|
||||
8. bring `telegram` through the host path second
|
||||
|
||||
Status of this slice:
|
||||
|
||||
- steps 2 through 6 are underway
|
||||
- step 1 has landed as `src/extension-host/cutover-inventory.md`
|
||||
- steps 7 and 8 have not started
|
||||
|
||||
Concrete landings from this slice:
|
||||
|
||||
- the host boundary exists
|
||||
- source-of-truth schema types exist
|
||||
- package metadata parsing now routes through the host schema layer
|
||||
- `ResolvedExtension` exists in code and is attached to manifest-registry records
|
||||
- host-owned active-registry and resolved-registry views exist
|
||||
- early static consumers have moved onto the new host-owned data path
|
||||
|
||||
Do not start with catalogs or arbitration first.
|
||||
|
||||
Also avoid these first-cut traps:
|
||||
|
||||
- do not build a broad event scheduling framework before the canonical stages exist
|
||||
- do not turn permission descriptors into fake sandbox guarantees
|
||||
- do not build a large operator catalog publication layer before the host registries are real
|
||||
- do not over-type setup flows before the pilot migrations prove the minimum result model is insufficient
|
||||
|
||||
## Tracking Rules
|
||||
|
||||
When implementation begins:
|
||||
|
||||
- update this guide first with phase status
|
||||
- update the matching spec TODOs when a domain changes
|
||||
- record where the implementation intentionally diverged from the spec
|
||||
- record which behaviors are full parity, partial parity, or compatibility-only
|
||||
- update the pilot parity matrix whenever a migrated surface changes
|
||||
|
||||
## Suggested Status Format
|
||||
|
||||
Use this format in each doc when work starts:
|
||||
|
||||
- `not started`
|
||||
- `in progress`
|
||||
- `implemented`
|
||||
- `verified`
|
||||
- `deferred`
|
||||
|
||||
For example:
|
||||
|
||||
- `ResolvedExtension` registry: `implemented`
|
||||
- setup fallback removal: `deferred`
|
||||
- sync transcript-write parity tests: `in progress`
|
||||
@@ -0,0 +1,750 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Extension Host Lifecycle And Security Spec
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines how the extension host discovers, validates, activates, isolates, and stops extensions while applying operator policy, permission metadata, persistence boundaries, and contribution dependencies.
|
||||
|
||||
The kernel does not participate in these concerns directly.
|
||||
|
||||
## TODOs
|
||||
|
||||
- [x] Write the initial boundary cutover inventory for every current plugin-owned surface.
|
||||
- [ ] Keep the boundary cutover inventory updated as surfaces move.
|
||||
- [ ] Extend the loader lifecycle state machine into full extension-host lifecycle ownership and document the concrete runtime states in code.
|
||||
- [ ] Implement advisory versus enforced permission handling exactly as specified here.
|
||||
- [ ] Implement host-owned registries for config, setup, CLI, routes, services, slots, and backends.
|
||||
- [ ] Implement per-extension state ownership and migration from current shared plugin state.
|
||||
- [ ] Record pilot parity for `thread-ownership` first and `telegram` second before broad legacy rollout.
|
||||
- [ ] Track which hardening, reload, and provenance rules have reached parity with `main`.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this spec:
|
||||
|
||||
- registry ownership and the first compatibility-preserving loader slices have landed
|
||||
- a loader-scoped lifecycle state machine has landed
|
||||
- broader lifecycle orchestration, policy gates, and activation-state management beyond the current loader, service, and CLI seams have not landed
|
||||
|
||||
What has been implemented:
|
||||
|
||||
- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- active registry ownership now lives in the extension host boundary rather than only in plugin-era runtime state
|
||||
- central lookup surfaces now consume the host-owned active registry
|
||||
- registry activation now routes through `src/extension-host/activation.ts`
|
||||
- a host-owned resolved-extension registry exists for static consumers
|
||||
- static config-baseline generation now reads bundled extension metadata through the host-owned resolved-extension registry
|
||||
- channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now delegates through `src/extension-host/contributions/runtime-registrations.ts`
|
||||
- low-risk runtime compatibility writes for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations now delegate through `src/extension-host/contributions/registry-writes.ts`
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now delegate through `src/extension-host/compat/hook-compat.ts`
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now delegate through `src/extension-host/compat/plugin-api.ts`
|
||||
- compatibility plugin-registry facade ownership now delegates through `src/extension-host/compat/plugin-registry.ts`
|
||||
- compatibility plugin-registry policy now delegates through `src/extension-host/compat/plugin-registry-compat.ts`
|
||||
- compatibility plugin-registry registration actions now delegate through `src/extension-host/compat/plugin-registry-registrations.ts`
|
||||
- host-owned runtime registry accessors now delegate through `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now delegate through `src/extension-host/contributions/command-runtime.ts`
|
||||
- service startup, stop ordering, service-context creation, and failure logging now delegate through `src/extension-host/contributions/service-lifecycle.ts`
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now delegate through `src/extension-host/contributions/cli-lifecycle.ts`
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now delegate through `src/extension-host/contributions/gateway-methods.ts`
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now delegate through `src/extension-host/contributions/tool-runtime.ts`
|
||||
- plugin provider projection from registry entries into runtime provider objects now delegates through `src/extension-host/contributions/provider-runtime.ts`
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now delegate through `src/extension-host/contributions/provider-discovery.ts`
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now delegate through `src/extension-host/contributions/provider-auth.ts`
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now delegate through `src/extension-host/contributions/provider-wizard.ts`
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now delegate through `src/extension-host/contributions/provider-auth-flow.ts`
|
||||
- provider post-selection hook lookup and invocation now delegate through `src/extension-host/contributions/provider-model-selection.ts`
|
||||
- loader alias-wired module loader creation now routes through `src/extension-host/activation/loader-module-loader.ts`
|
||||
- loader cache key construction and registry cache control now route through `src/extension-host/activation/loader-cache.ts`
|
||||
- loader lazy runtime proxy creation now routes through `src/extension-host/activation/loader-runtime-proxy.ts`
|
||||
- loader provenance helpers now route through `src/extension-host/policy/loader-provenance.ts`
|
||||
- loader duplicate-order and record/error policy now route through `src/extension-host/policy/loader-policy.ts`
|
||||
- loader discovery policy outcomes now route through `src/extension-host/policy/loader-discovery-policy.ts`
|
||||
- loader initial candidate planning and record creation now route through `src/extension-host/activation/loader-records.ts`
|
||||
- loader entry-path opening and module import now route through `src/extension-host/activation/loader-import.ts`
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now route through `src/extension-host/activation/loader-runtime.ts`
|
||||
- loader post-import planning and `register(...)` execution now route through `src/extension-host/activation/loader-register.ts`
|
||||
- loader per-candidate orchestration now routes through `src/extension-host/activation/loader-flow.ts`
|
||||
- loader top-level load orchestration now routes through `src/extension-host/activation/loader-orchestrator.ts`
|
||||
- loader host process state now routes through `src/extension-host/activation/loader-host-state.ts`
|
||||
- loader preflight and cache-hit setup now routes through `src/extension-host/activation/loader-preflight.ts`
|
||||
- loader post-preflight pipeline composition now routes through `src/extension-host/activation/loader-pipeline.ts`
|
||||
- loader execution setup composition now routes through `src/extension-host/activation/loader-execution.ts`
|
||||
- loader discovery and manifest bootstrap now routes through `src/extension-host/activation/loader-bootstrap.ts`
|
||||
- loader mutable activation state now routes through `src/extension-host/activation/loader-session.ts`
|
||||
- loader session run and finalization composition now routes through `src/extension-host/activation/loader-run.ts`
|
||||
- loader activation policy outcomes now route through `src/extension-host/policy/loader-activation-policy.ts`
|
||||
- loader record-state transitions now route through `src/extension-host/activation/loader-state.ts`, which now enforces an explicit loader lifecycle state machine while preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy results now route through `src/extension-host/policy/loader-finalization-policy.ts`
|
||||
- loader final cache, readiness promotion, and activation finalization now routes through `src/extension-host/activation/loader-finalize.ts`
|
||||
|
||||
How it has been implemented:
|
||||
|
||||
- by extracting `src/extension-host/static/active-registry.ts` and making `src/plugins/runtime.ts` delegate to it
|
||||
- by leaving lifecycle behavior unchanged for now and only moving ownership of the shared registry boundary
|
||||
- by moving low-risk readers first, such as channel lookup, dock lookup, message-channel lookup, and default HTTP route registry access
|
||||
- by extending that same host-owned boundary into static consumers instead of introducing separate one-off metadata loaders
|
||||
- by starting runtime-registry migration with low-risk validation and normalization helpers while leaving lifecycle ordering and activation behavior unchanged
|
||||
- by starting actual low-risk runtime write ownership for channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook registrations only after normalization helpers existed, while leaving lifecycle ordering and activation behavior unchanged
|
||||
- by leaving start/stop ordering and duplicate-enforcement behavior in legacy subsystems where those subsystems are still the real owner
|
||||
- by treating hook execution and hook registration as separate migration concerns so event-pipeline work does not get conflated with record normalization
|
||||
- by starting loader/lifecycle migration with activation and SDK alias compatibility helpers while leaving discovery and policy flow unchanged
|
||||
- by moving cache-key construction, cache reads, cache writes, and cache clearing next while leaving activation-state ownership unchanged
|
||||
- by moving provenance and duplicate-order policy next, so lifecycle migration can land on host-owned policy helpers instead of loader-local utilities
|
||||
- by extracting lazy runtime proxy creation and alias-wired Jiti module-loader creation into host-owned helpers before broader bootstrap or lifecycle ownership changes
|
||||
- by extracting discovery, manifest loading, manifest diagnostics, discovery-policy logging, provenance building, and candidate ordering into a host-owned loader-bootstrap helper before broader lifecycle ownership changes
|
||||
- by extracting candidate iteration, manifest lookup, per-candidate session processing, and finalization handoff into a host-owned loader-run helper before broader lifecycle ownership changes
|
||||
- by moving initial candidate planning and record construction next while leaving module import and registration flow unchanged
|
||||
- by moving entry-path opening and module import next while leaving cache wiring and lifecycle orchestration unchanged
|
||||
- by moving loader runtime decisions next while preserving the current lazy-load, config-validation, and memory-slot behavior
|
||||
- by moving post-import planning and `register(...)` execution next while leaving entry-path and import flow unchanged
|
||||
- by composing those seams into one host-owned per-candidate loader orchestrator before moving final lifecycle-state behavior
|
||||
- by moving the remaining top-level loader orchestration into a host-owned module before enforcing the loader lifecycle state machine
|
||||
- by extracting shared discovery warning-cache state and loader reset behavior into a host-owned loader-host-state helper before shrinking the remaining orchestrator surface
|
||||
- by extracting test-default application, config normalization, cache-key construction, cache-hit activation, and command-clear setup into a host-owned loader-preflight helper before shrinking the remaining orchestrator surface
|
||||
- by extracting post-preflight execution setup and session-run composition into a host-owned loader-pipeline helper before shrinking the remaining orchestrator surface
|
||||
- by extracting runtime creation, registry creation, bootstrap setup, module-loader creation, and session creation into a host-owned loader-execution helper before shrinking the remaining orchestrator surface
|
||||
- by moving record-state transitions first into a compatibility layer and then into an enforced loader lifecycle state machine
|
||||
- by moving cache writes, provenance warnings, final memory-slot warnings, and activation into a host-owned loader finalizer before introducing an explicit lifecycle state machine
|
||||
- by adding explicit compatibility `lifecycleState` mapping on loader-owned plugin records before enforcing the loader lifecycle state machine
|
||||
- by promoting successfully registered plugins to `ready` during host-owned finalization while leaving broader activation-state semantics for later phases
|
||||
- by moving mutable activation state such as seen-id tracking, memory-slot selection, and finalization inputs into a host-owned loader session before broader activation-state semantics move
|
||||
- by extracting shared provenance path matching and install-rule evaluation into `src/extension-host/policy/loader-provenance.ts` so activation and finalization policy seams reuse one host-owned implementation
|
||||
- by turning open-allowlist discovery warnings into explicit host-owned discovery-policy results before the orchestrator logs them
|
||||
- by moving duplicate precedence, config enablement, and early memory-slot gating into explicit host-owned activation-policy outcomes before broader policy semantics move
|
||||
- by turning provenance-based untracked-extension warnings and final memory-slot warnings into explicit host-owned finalization-policy results before the finalizer applies them
|
||||
- by extracting legacy internal-hook bridging and typed prompt-injection compatibility policy into a host-owned hook-compat helper while leaving actual hook execution ownership unchanged
|
||||
- by extracting compatibility `OpenClawPluginApi` composition and logger shaping into a host-owned plugin-api helper while keeping the concrete registration callbacks in the legacy registry surface
|
||||
- by extracting the remaining compatibility plugin-registry facade into a host-owned helper so `src/plugins/registry.ts` becomes a thin wrapper instead of the real owner
|
||||
- by extracting provider normalization, command duplicate enforcement, and registry-local diagnostic shaping into a host-owned registry-compat helper while leaving the underlying provider-validation and plugin-command subsystems unchanged
|
||||
- by extracting low-risk registry registration actions into a host-owned registry-registrations helper so the compatibility facade composes host-owned actions instead of implementing them inline
|
||||
- by extracting service startup, stop ordering, service-context creation, and failure logging into a host-owned service-lifecycle helper while `src/plugins/services.ts` remains the compatibility entry point
|
||||
- by extracting CLI duplicate detection, registrar invocation, and async failure logging into a host-owned CLI-lifecycle helper while `src/plugins/cli.ts` remains the compatibility entry point
|
||||
- by extracting gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition into a host-owned gateway-methods helper while request dispatch semantics remain in the gateway server code
|
||||
- by extracting plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking into a host-owned tool-runtime helper while `src/plugins/tools.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider projection from registry entries into runtime provider objects into a host-owned provider-runtime helper while `src/plugins/providers.ts` remains the loader and config-normalization facade
|
||||
- by extracting provider discovery filtering, order grouping, and result normalization into a host-owned provider-discovery helper while `src/plugins/provider-discovery.ts` remains the compatibility facade around the legacy provider loader path
|
||||
- by extracting provider matching, auth-method selection, config-patch merging, and default-model application into a host-owned provider-auth helper while `src/commands/provider-auth-helpers.ts` remains the command-facing compatibility facade
|
||||
- by extracting provider onboarding option building, model-picker entry building, and provider-method choice resolution into a host-owned provider-wizard helper while `src/plugins/provider-wizard.ts` remains the compatibility facade around loader-backed provider access and post-selection hooks
|
||||
- by extracting loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling into a host-owned provider-auth-flow helper while `src/commands/auth-choice.apply.plugin-provider.ts` remains the compatibility entry point
|
||||
- by extracting provider post-selection hook lookup and invocation into a host-owned provider-model-selection helper while `src/plugins/provider-wizard.ts` remains the compatibility facade and existing command consumers continue migrating onto the host-owned surface
|
||||
- by extracting provider-id normalization into `src/agents/provider-id.ts` so provider-only host seams do not inherit the heavier agent and browser dependency graph from `src/agents/model-selection.ts`
|
||||
- by extracting model-ref parsing into `src/agents/model-ref.ts` and Google model-id normalization into `src/agents/google-model-id.ts` so provider auth and setup seams can be tested without pulling the heavier provider-loader and browser dependency graph
|
||||
- by introducing host-owned runtime-registry accessors for channel, provider, tool, service, CLI, command, gateway-method, and HTTP-route consumers first, then moving channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service storage into that host-owned state while keeping mirrored legacy compatibility arrays and handler maps
|
||||
- by moving plugin command duplicate enforcement, registration, matching, execution, listing, native command-spec projection, and loader reload clearing into `src/extension-host/contributions/command-runtime.ts` while keeping the legacy command module as a compatibility facade
|
||||
- by tightening the CLI pre-load fast path to treat any host-known runtime entry surface as already loaded rather than only plugins, channels, or tools
|
||||
|
||||
What is still pending from this spec:
|
||||
|
||||
- broader extension-host lifecycle ownership beyond the loader state machine, service-lifecycle boundary, CLI-lifecycle boundary, session-owned activation state, and explicit discovery-policy, activation-policy, and finalization-policy outcomes
|
||||
- activation pipeline ownership
|
||||
- host-owned registries for setup, CLI, routes, services, slots, and backends
|
||||
- host-owned subsystem runtime registries for embeddings, media understanding, and TTS, including explicit fallback and override policy instead of plugin-era capability reads
|
||||
- a clear host-owned split for extension-backed search between agent-visible tool publication and any optional runtime-internal search backend registry
|
||||
- permission-mode enforcement
|
||||
- per-extension state ownership and migration
|
||||
- provenance, reload, and hardening parity tracking
|
||||
|
||||
## Goals
|
||||
|
||||
- deterministic activation and shutdown
|
||||
- explicit failure states
|
||||
- no hidden privilege escalation
|
||||
- stable persistence ownership rules
|
||||
- truthful security semantics for the current trusted in-process model
|
||||
- safe support for bundled and external extensions under the same model
|
||||
- preserve existing hardening and prompt-mutation policy behavior during the migration
|
||||
|
||||
## Implementation Sequencing Constraints
|
||||
|
||||
This spec is not a greenfield host design.
|
||||
|
||||
The host must absorb existing behavior that already lives in:
|
||||
|
||||
- plugin discovery and manifest loading
|
||||
- config schema and UI hint handling
|
||||
- route and gateway registration
|
||||
- channels and channel lookup
|
||||
- providers and provider auth or setup flows
|
||||
- tools, commands, and CLI registration
|
||||
- services, backends, and slot-backed providers
|
||||
- reload, diagnostics, install, update, and status behavior
|
||||
|
||||
Therefore:
|
||||
|
||||
- Phase 0 must produce a cutover inventory for those surfaces before registry ownership changes begin
|
||||
- Phase 1 must preserve current SDK loading through minimal compatibility support
|
||||
- Phase 2 registry work must be broad enough to cover all currently registered surfaces, not only a narrow runtime subset
|
||||
- Phase 3 must prove parity through `thread-ownership` first and `telegram` second before broader rollout
|
||||
|
||||
## Trust Model Reality
|
||||
|
||||
Current `main` treats installed and enabled extensions as trusted code running in-process:
|
||||
|
||||
- trusted plugin concept in `SECURITY.md:108`
|
||||
- in-process loading in `src/plugins/loader.ts:621`
|
||||
|
||||
That means the initial extension host has two separate jobs:
|
||||
|
||||
- enforce operator policy for activation, route exposure, host-owned registries, and auditing
|
||||
- accurately communicate that this is not yet a hard sandbox against arbitrary extension code
|
||||
|
||||
Recommended enforcement levels:
|
||||
|
||||
- `advisory`
|
||||
Host policy, audit, and compatibility guidance only. This is the current default. Permission mismatch alone should not block activation in this mode, though the host may warn and withhold optional host-published surfaces.
|
||||
- `host-enforced`
|
||||
Host-owned capabilities and registries are gated, but extension code still runs in-process.
|
||||
- `sandbox-enforced`
|
||||
A future mode with real process, VM, or IPC isolation where permissions become a true security boundary.
|
||||
|
||||
## Lifecycle States
|
||||
|
||||
Every extension instance moves through these states:
|
||||
|
||||
1. `discovered`
|
||||
2. `manifest-loaded`
|
||||
3. `validated`
|
||||
4. `dependency-resolved`
|
||||
5. `policy-approved`
|
||||
6. `instantiated`
|
||||
7. `registered`
|
||||
8. `starting`
|
||||
9. `ready`
|
||||
10. `degraded`
|
||||
11. `stopping`
|
||||
12. `stopped`
|
||||
13. `failed`
|
||||
|
||||
The host owns the state machine.
|
||||
|
||||
## Activation Pipeline
|
||||
|
||||
### 1. Discovery
|
||||
|
||||
The host scans:
|
||||
|
||||
- bundled extension inventory
|
||||
- configured external extension paths or packages
|
||||
- disabled extension state
|
||||
|
||||
Discovery is metadata-only. No extension code executes in this phase.
|
||||
|
||||
### 2. Manifest Load
|
||||
|
||||
The host loads and validates manifest syntax.
|
||||
|
||||
Failures here prevent instantiation.
|
||||
|
||||
This phase must cover both:
|
||||
|
||||
- runtime contribution descriptors
|
||||
- package-level static metadata used for install, onboarding, status, and lightweight operator UX
|
||||
|
||||
### 3. Schema Validation
|
||||
|
||||
The host validates:
|
||||
|
||||
- top-level extension manifest
|
||||
- contribution descriptors
|
||||
- config schema
|
||||
- config UI hints and sensitivity metadata
|
||||
- permission declarations
|
||||
- dependency declarations
|
||||
- policy declarations such as prompt-mutation behavior
|
||||
|
||||
### 4. Dependency Resolution
|
||||
|
||||
The host resolves:
|
||||
|
||||
- extension api compatibility
|
||||
- SDK compatibility mode and deprecation requirements
|
||||
- required contribution dependencies
|
||||
- optional dependencies
|
||||
- conflict declarations
|
||||
- singleton slot collisions
|
||||
|
||||
Compatibility decision:
|
||||
|
||||
- the host should support only a short compatibility window, ideally one or two older SDK contract versions at a time
|
||||
- extensions outside that window must fail validation with a clear remediation path
|
||||
|
||||
Sequencing rule:
|
||||
|
||||
- minimal compatibility loading must exist before broader schema or registry changes depend on the new manifest model
|
||||
|
||||
### 5. Policy Gate
|
||||
|
||||
The host computes the requested permission set and compares it against operator policy.
|
||||
|
||||
In `host-enforced` or `sandbox-enforced` mode, extensions that are not allowed to receive all required permissions do not activate or do not register the gated contributions.
|
||||
|
||||
In `advisory` mode, this gate records warnings, informs operator-visible policy state, and may withhold optional host-published surfaces, but permission mismatch alone does not fail activation.
|
||||
|
||||
It does not sandbox arbitrary filesystem, network, or child-process access from trusted in-process extension code.
|
||||
|
||||
### 6. Instantiation
|
||||
|
||||
The host loads the extension entrypoint and asks it to emit contribution descriptors and runtime factories.
|
||||
|
||||
Unless the host is running in a future isolated mode, instantiation still executes trusted extension code inside the OpenClaw process.
|
||||
|
||||
### 7. Registration
|
||||
|
||||
The host resolves runtime ids, arbitration metadata, and activation order, then registers contributions into host-owned registries.
|
||||
|
||||
This includes host-managed operator registries for:
|
||||
|
||||
- CLI commands
|
||||
- setup and onboarding flows
|
||||
- config and status surfaces
|
||||
- dynamic HTTP routes
|
||||
- config reload descriptors and gateway feature advertisement where those surfaces remain host-managed during migration
|
||||
|
||||
Callable gateway or runtime methods are separate from this advertisement layer and should continue to register through the runtime contribution model as `capability.rpc`.
|
||||
|
||||
The registration boundary should cover the full current surface area as one migration set:
|
||||
|
||||
- hooks and event handlers
|
||||
- channels and lightweight channel descriptors
|
||||
- providers and provider-setup surfaces
|
||||
- tools and control commands
|
||||
- CLI, setup, config, and status surfaces
|
||||
- HTTP routes and gateway methods
|
||||
- services, runtime backends, and slot-backed providers
|
||||
|
||||
Do not migrate only a subset and leave the rest writing into the legacy registry model indefinitely.
|
||||
|
||||
### 8. Start
|
||||
|
||||
The host starts host-managed services, assigns per-extension state and route ownership, and activates kernel-facing contributions.
|
||||
|
||||
### 9. Ready
|
||||
|
||||
The extension is active and visible to kernel or operator surfaces as appropriate.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
Supported failure classes:
|
||||
|
||||
- `manifest-invalid`
|
||||
- `api-version-unsupported`
|
||||
- `dependency-missing`
|
||||
- `dependency-conflict`
|
||||
- `policy-denied`
|
||||
- `instantiation-failed`
|
||||
- `registration-conflict`
|
||||
- `startup-failed`
|
||||
- `runtime-degraded`
|
||||
|
||||
The host must record failure class, extension id, contribution ids, and operator-visible remediation.
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
Dependencies must be explicit and machine-checkable.
|
||||
|
||||
### Extension-level dependencies
|
||||
|
||||
Used when one extension package requires another package to be present.
|
||||
|
||||
### Contribution-level dependencies
|
||||
|
||||
Used when a specific runtime contract depends on another contribution.
|
||||
|
||||
Examples:
|
||||
|
||||
- a route augmenter may require a specific adapter family
|
||||
- an auth helper may require a provider contribution
|
||||
- a diagnostics extension may optionally bind to a runtime backend if present
|
||||
|
||||
### Conflict rules
|
||||
|
||||
Extensions may declare:
|
||||
|
||||
- `conflicts`
|
||||
- `supersedes`
|
||||
- `replaces`
|
||||
|
||||
The host resolves these before activation.
|
||||
|
||||
## Discovery And Load Hardening
|
||||
|
||||
The extension host must preserve current path-safety, provenance, and duplicate-resolution protections.
|
||||
|
||||
At minimum, preserve parity with:
|
||||
|
||||
- path and boundary checks during load in `src/plugins/loader.ts:744`
|
||||
- manifest precedence and duplicate-origin handling in `src/plugins/manifest-registry.ts:15`
|
||||
- provenance warnings during activation in `src/plugins/loader.ts:500`
|
||||
|
||||
Security hardening from the current loader is part of the host contract, not an optional implementation detail.
|
||||
|
||||
Parity requirement:
|
||||
|
||||
- the pilot migrations must show that these hardening rules still apply on the host path, not only on the legacy path
|
||||
|
||||
## Policy And Permission Model
|
||||
|
||||
Permissions are granted to extension instances by the host as policy metadata and host capability grants.
|
||||
|
||||
The kernel must never infer privilege from contribution kind alone.
|
||||
|
||||
The host must track both:
|
||||
|
||||
- requested permissions
|
||||
- enforcement level (`advisory`, `host-enforced`, or `sandbox-enforced`)
|
||||
- host-managed policy gates such as prompt mutation and sync hot-path eligibility
|
||||
|
||||
### Recommended permission set
|
||||
|
||||
- `runtime.adapter`
|
||||
- `runtime.route-augment`
|
||||
- `runtime.veto-send`
|
||||
- `runtime.backend-register`
|
||||
- `agent.tool.expose`
|
||||
- `control.command.expose`
|
||||
- `interaction.handle`
|
||||
- `conversation.bind`
|
||||
- `conversation.bind.approve`
|
||||
- `conversation.control`
|
||||
- `rpc.expose`
|
||||
- `service.background`
|
||||
- `http.route.gateway`
|
||||
- `http.route.plugin`
|
||||
- `config.read`
|
||||
- `config.write`
|
||||
- `state.read`
|
||||
- `state.write`
|
||||
- `credentials.read`
|
||||
- `credentials.write`
|
||||
- `network.outbound`
|
||||
- `process.spawn`
|
||||
- `filesystem.workspace.read`
|
||||
- `filesystem.workspace.write`
|
||||
|
||||
Permissions should be independently reviewable and denyable.
|
||||
|
||||
In `advisory` mode they also function as:
|
||||
|
||||
- operator review prompts
|
||||
- activation policy inputs
|
||||
- audit and telemetry tags
|
||||
- documentation of why an extension needs sensitive host-owned surfaces
|
||||
|
||||
### Fine-grained policy gates
|
||||
|
||||
Some behavior should remain under dedicated policy gates instead of being flattened into generic permissions.
|
||||
|
||||
Examples:
|
||||
|
||||
- prompt mutation or prompt injection behavior
|
||||
- sync transcript-write participation
|
||||
- fail-open versus fail-closed route augmentation
|
||||
- whether an extension may bind conversations without per-request operator approval
|
||||
- whether an interaction handler may invoke conversation-control verbs
|
||||
|
||||
This preserves the intent of current controls such as `plugins.entries.<id>.hooks.allowPromptInjection`.
|
||||
|
||||
### High-risk permissions
|
||||
|
||||
These should require explicit operator approval or a strong default policy:
|
||||
|
||||
- `runtime.veto-send`
|
||||
- `runtime.route-augment`
|
||||
- `conversation.bind`
|
||||
- `conversation.bind.approve`
|
||||
- `runtime.backend-register`
|
||||
- `credentials.write`
|
||||
- `process.spawn`
|
||||
- `http.route.plugin`
|
||||
- `filesystem.workspace.write`
|
||||
|
||||
High-risk permissions should still matter in `advisory` mode because they drive operator trust decisions even before real isolation exists.
|
||||
|
||||
### Binding and interaction ownership
|
||||
|
||||
Conversation binding and interactive callback routing should be treated as host-owned lifecycle surfaces.
|
||||
|
||||
The host must own:
|
||||
|
||||
- namespace registration and dedupe for interactive callbacks
|
||||
- approval persistence for extension-requested conversation binds
|
||||
- restore-on-restart behavior for approved bindings
|
||||
- cleanup behavior for detached or stale bindings
|
||||
- channel-surface gating for first-cut conversation-control verbs
|
||||
|
||||
Extensions may own:
|
||||
|
||||
- the logic that decides whether to request a bind
|
||||
- the interaction payload semantics
|
||||
- channel-specific presentation details that fit inside the host-owned adapter contract
|
||||
|
||||
Important migration rule:
|
||||
|
||||
- do not turn `src/plugins/conversation-binding.ts` or `src/plugins/interactive.ts` into the permanent architecture target
|
||||
- those behaviors should migrate into host-owned lifecycle and policy surfaces, with compatibility bridges only where needed
|
||||
|
||||
## Persistence Ownership
|
||||
|
||||
Persistence must be partitioned by owner and intent.
|
||||
|
||||
### Config
|
||||
|
||||
Operator-managed configuration belongs to the host.
|
||||
|
||||
Extensions may contribute:
|
||||
|
||||
- config schema
|
||||
- config UI hints and sensitivity metadata
|
||||
- defaults
|
||||
- migration hints
|
||||
- setup flow outputs such as config patches produced through host-owned setup primitives
|
||||
|
||||
Extensions must not arbitrarily mutate unrelated config keys.
|
||||
|
||||
The host must also preserve current config redaction semantics:
|
||||
|
||||
- config UI hints such as `sensitive` affect host behavior, not only UI decoration
|
||||
- config read, redact, restore, and validate flows must preserve round-trippable secret handling comparable to `src/gateway/server-methods/config.ts:151` and `src/config/redact-snapshot.ts:349`
|
||||
|
||||
### State
|
||||
|
||||
Each extension gets a host-assigned state directory.
|
||||
|
||||
This is where background services and caches persist local state.
|
||||
|
||||
This is a required migration change from the current shared plugin service state shape in `src/plugins/services.ts:18`.
|
||||
|
||||
The host must also define a migration strategy for existing state:
|
||||
|
||||
- detect old shared plugin state layouts
|
||||
- migrate or alias data into per-extension directories
|
||||
- keep rollback behavior explicit
|
||||
|
||||
### Credentials
|
||||
|
||||
Credential persistence is host-owned.
|
||||
|
||||
Provider integration extensions may return credential payloads, but they must not choose final storage shape or bypass the credential store.
|
||||
|
||||
This is required because auth flows like `extensions/google-gemini-cli-auth/index.ts:24` interact with credentials and config together.
|
||||
|
||||
This rule also applies when those flows are invoked through extension-owned CLI or setup flows.
|
||||
|
||||
### Session and transcript state
|
||||
|
||||
Kernel-owned.
|
||||
|
||||
Extensions may observe or augment session state through declared runtime contracts, but they do not own transcript persistence.
|
||||
|
||||
### Backend-owned state
|
||||
|
||||
Runtime backends such as ACP may require separate service state, but ownership still flows through the host-assigned state boundary.
|
||||
|
||||
### Distribution and onboarding metadata
|
||||
|
||||
Install metadata, channel catalog metadata, docs links, and quickstart hints are host-owned static metadata.
|
||||
|
||||
They are not kernel persistence and they are not extension-private state.
|
||||
|
||||
That static metadata should preserve current channel catalog fields from `src/plugins/manifest.ts:121`, including aliases, docs labels, precedence hints, binding hints, picker extras, and announce-target hints.
|
||||
|
||||
## HTTP And Webhook Ownership
|
||||
|
||||
The host owns all HTTP route registration and conflict resolution.
|
||||
|
||||
This is required because routes can conflict across extensions today, as seen in `src/plugins/http-registry.ts:12`.
|
||||
|
||||
### Route classes
|
||||
|
||||
- ingress transport routes
|
||||
- authenticated plugin routes
|
||||
- public callback routes
|
||||
- diagnostic or admin routes
|
||||
- dynamic account-scoped routes
|
||||
|
||||
### Required route metadata
|
||||
|
||||
- path
|
||||
- auth mode
|
||||
- match mode
|
||||
- owner contribution id
|
||||
- whether the route is externally reachable
|
||||
- whether the route is safe to expose when the extension is disabled
|
||||
- lifecycle mode (`static` or `dynamic`)
|
||||
- scope metadata such as account, workspace, or provider binding
|
||||
|
||||
### Conflict rules
|
||||
|
||||
- exact path collisions require explicit resolution
|
||||
- prefix collisions require overlap analysis
|
||||
- auth mismatches are fatal
|
||||
- one extension may not replace another extension's route without explicit policy
|
||||
|
||||
Dynamic route registration must also return an unregister handle so route ownership can be cleaned up during reload, account removal, or degraded shutdown.
|
||||
|
||||
## Runtime Backend Contract
|
||||
|
||||
Some extension contributions provide runtime backends consumed by subsystems rather than directly by the agent.
|
||||
|
||||
ACP is the reference case today:
|
||||
|
||||
- backend type in `src/acp/runtime/registry.ts:4`
|
||||
- registration in `extensions/acpx/src/service.ts:55`
|
||||
|
||||
### Required backend descriptor
|
||||
|
||||
- backend class id
|
||||
- backend instance id
|
||||
- selector key
|
||||
- health probe
|
||||
- capability list
|
||||
- selection rank
|
||||
- arbitration mode
|
||||
|
||||
### Required backend lifecycle
|
||||
|
||||
- register
|
||||
- unregister
|
||||
- probe
|
||||
- health
|
||||
- degrade
|
||||
- recover
|
||||
|
||||
### Backend selection rules
|
||||
|
||||
- explicit requested backend id wins
|
||||
- if none requested, pick the healthiest backend with the best rank
|
||||
- if multiple healthy backends tie, use deterministic ordering by extension id then contribution id
|
||||
- if all backends are unhealthy, expose a typed unavailability error
|
||||
|
||||
### Singleton vs parallel
|
||||
|
||||
Not every backend is singleton.
|
||||
|
||||
ACP may remain effectively singleton at first, but the contract should support future parallel backends with explicit selectors.
|
||||
|
||||
## Slot-Backed Provider Contract
|
||||
|
||||
Not every exclusive runtime provider is a generic backend.
|
||||
|
||||
Current `main` already has slot-backed provider selection in:
|
||||
|
||||
- `src/plugins/slots.ts:12`
|
||||
- `src/context-engine/registry.ts:60`
|
||||
|
||||
The host must model explicit slot-backed providers for cases such as:
|
||||
|
||||
- context engines
|
||||
- default memory providers
|
||||
- future execution or planning engines
|
||||
|
||||
Required slot rules:
|
||||
|
||||
- each slot has a stable slot id
|
||||
- each slot has a host-defined default
|
||||
- explicit config selection wins
|
||||
- only one active provider may own an exclusive slot
|
||||
- migration preserves existing config semantics such as `plugins.slots.memory` and `plugins.slots.contextEngine`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- slot-backed providers must move into host-owned registries before broader catalog and arbitration migration claims are considered complete
|
||||
|
||||
## Isolation Rules
|
||||
|
||||
The host must isolate extension failures from the kernel as much as possible.
|
||||
|
||||
Minimum requirements:
|
||||
|
||||
- one extension failing startup does not block unrelated extensions
|
||||
- one contribution registration failure does not corrupt host state
|
||||
- background-service failures transition the extension to `degraded` or `failed` without leaving stale registrations behind
|
||||
- stop hooks are best-effort and time-bounded
|
||||
|
||||
In the current trusted in-process mode, "isolation" here means lifecycle and registry isolation, not a security sandbox.
|
||||
|
||||
## Reload And Upgrade Rules
|
||||
|
||||
Hot reload is optional. Deterministic restart behavior is required.
|
||||
|
||||
On reload or upgrade:
|
||||
|
||||
1. stop host-managed services
|
||||
2. unregister contributions
|
||||
3. clear host-owned route, command, backend, and slot registrations
|
||||
4. clear dynamic account-scoped routes and stale runtime handles
|
||||
5. instantiate the new version
|
||||
6. reactivate only after validation and policy checks succeed
|
||||
|
||||
If the host continues to support config-driven hot reload during migration, it must also preserve:
|
||||
|
||||
- channel-owned reload prefix behavior equivalent to current `configPrefixes` and `noopPrefixes`
|
||||
- gateway feature advertisement cleanup and re-registration
|
||||
- setup-flow and native-command registrations that depend on account-scoped runtime state
|
||||
|
||||
This advertisement handling does not replace callable RPC registration. If a migrated extension exposes callable gateway-style methods, those should still be re-registered through `capability.rpc`.
|
||||
|
||||
During migration, keep the current built-in onboarding fallback in place until host-owned setup surfaces cover bundled channels with parity.
|
||||
|
||||
Pilot rule:
|
||||
|
||||
- the fallback stays in place until `telegram` parity has been recorded for setup-adjacent host behavior, even if runtime messaging parity lands earlier
|
||||
|
||||
## Operator Policy
|
||||
|
||||
The host should support policy controls for:
|
||||
|
||||
- allowed extension ids
|
||||
- denied permissions
|
||||
- default permission grants for bundled extensions
|
||||
- allowed extension origins and provenance requirements
|
||||
- origin precedence and duplicate resolution
|
||||
- workspace extensions disabled by default unless explicitly allowed
|
||||
- bundled channel auto-enable rules tied to channel config
|
||||
- route exposure policy
|
||||
- network egress policy
|
||||
- backend selection policy
|
||||
- whether external extensions are permitted at all
|
||||
- SDK compatibility level and deprecation mode
|
||||
- prompt-mutation policy defaults
|
||||
- whether interactive extension-owned CLI and setup flows are allowed
|
||||
- whether extension-owned native command registration is allowed on specific providers
|
||||
- whether config-driven hot reload descriptors are honored or downgraded to restart-only behavior
|
||||
|
||||
## Observability
|
||||
|
||||
The host must emit structured telemetry for:
|
||||
|
||||
- activation timings
|
||||
- policy denials
|
||||
- contribution conflicts
|
||||
- route conflicts
|
||||
- backend registration and health
|
||||
- service start and stop
|
||||
- extension degradation and recovery
|
||||
- provenance warnings and origin overrides
|
||||
- state migration outcomes
|
||||
- compatibility-mode activation and deprecated SDK usage
|
||||
- setup flow phase transitions and fallback-path usage
|
||||
- config redaction or restore validation failures
|
||||
- reload descriptor application and gateway feature re-registration
|
||||
|
||||
## Immediate Implementation Work
|
||||
|
||||
1. Write the boundary cutover inventory for every current plugin-owned surface.
|
||||
2. Introduce an extension-host lifecycle state machine.
|
||||
3. Move route registration policy out of plugin internals into host-owned registries.
|
||||
4. Add a policy evaluator that understands advisory versus enforced permission modes.
|
||||
5. Add host-owned credential and per-extension state boundaries for extension services.
|
||||
6. Generalize backend registration into a host-managed `capability.runtime-backend` registry.
|
||||
7. Add host-owned subsystem runtime registries for embeddings, media understanding, and TTS instead of widening `registerProvider(...)`.
|
||||
8. Keep extension-backed search generic by publishing agent-visible search through tool contracts and using runtime-backend only for search backends consumed internally by the host or another subsystem.
|
||||
9. Add slot-backed provider management for context engines and other exclusive runtime providers.
|
||||
10. Preserve provenance, origin precedence, and current workspace and bundled enablement rules in host policy.
|
||||
11. Preserve prompt-mutation policy gates and add explicit state migration handling.
|
||||
12. Add explicit host registries and typed contracts for extension-owned hooks, channels, providers, tools, commands, CLI, setup flows, config surfaces, and status surfaces.
|
||||
13. Preserve config redaction-aware schema behavior and current reload or gateway feature contracts during migration.
|
||||
14. Record lifecycle parity for `thread-ownership` first and `telegram` second before broadening the compatibility bridges.
|
||||
@@ -0,0 +1,835 @@
|
||||
Temporary internal migration note: remove this document once the extension-host migration is complete.
|
||||
|
||||
# OpenClaw Kernel Event Pipeline Spec
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the canonical kernel event model, execution stages, handler classes, ordering, mutation rules, and veto semantics.
|
||||
|
||||
The goal is to replace today's mixed plugin hook behavior with one explicit runtime pipeline and a small set of execution modes that match current `main` behavior.
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] Implement canonical event types and stage ordering in code.
|
||||
- [ ] Bridge current plugin hooks, internal hooks, and agent event streams into the pipeline.
|
||||
- [ ] Implement sync transcript-write stages with parity for current hot paths.
|
||||
- [ ] Record the legacy-to-canonical mapping table used by the first pilot migrations.
|
||||
- [ ] Record parity for `thread-ownership` first and `telegram` second before broader event migration.
|
||||
- [ ] Document which legacy hook sources are still bridged and which have been retired.
|
||||
- [ ] Add parity tests for veto, resolver, and sync-stage behavior.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Current status against this spec:
|
||||
|
||||
- no canonical event pipeline work has landed yet
|
||||
- only the prerequisites from earlier phases are underway
|
||||
|
||||
Relevant prerequisite work that has landed:
|
||||
|
||||
- an initial Phase 0 cutover inventory now exists in `src/extension-host/cutover-inventory.md`
|
||||
- the extension-host boundary now owns active registry state
|
||||
- registry activation now routes through `src/extension-host/activation.ts`
|
||||
- initial normalized extension schema types now exist
|
||||
- static consumers can now read host-owned resolved-extension data
|
||||
- config doc baseline generation now uses the same host-owned resolved-extension data path
|
||||
- channel, provider, HTTP-route, gateway-method, tool, CLI, service, command, context-engine, and hook registration normalization now has a host-owned helper boundary
|
||||
- loader cache key construction and registry cache control now have a host-owned helper boundary
|
||||
- loader provenance helpers now have a host-owned helper boundary
|
||||
- loader duplicate-order policy now has a host-owned helper boundary
|
||||
- loader alias-wired module loader creation now has a host-owned helper boundary
|
||||
- loader lazy runtime proxy creation now has a host-owned helper boundary
|
||||
- loader initial candidate planning and record creation now have a host-owned helper boundary
|
||||
- loader entry-path opening and module import now have a host-owned helper boundary
|
||||
- loader module-export resolution, config validation, and memory-slot load decisions now have a host-owned helper boundary
|
||||
- loader post-import planning and `register(...)` execution now have a host-owned helper boundary
|
||||
- loader per-candidate orchestration now has a host-owned helper boundary
|
||||
- loader top-level load orchestration now has a host-owned helper boundary
|
||||
- loader host process state now has a host-owned helper boundary
|
||||
- loader preflight and cache-hit setup now has a host-owned helper boundary
|
||||
- loader post-preflight pipeline composition now has a host-owned helper boundary
|
||||
- loader execution setup composition now has a host-owned helper boundary
|
||||
- loader discovery and manifest bootstrap now has a host-owned helper boundary
|
||||
- loader discovery policy outcomes now have a host-owned helper boundary
|
||||
- loader mutable activation state now has a host-owned helper boundary
|
||||
- loader session run and finalization composition now has a host-owned helper boundary
|
||||
- loader activation policy outcomes now have a host-owned helper boundary
|
||||
- loader record-state transitions now have a host-owned helper boundary and enforced loader lifecycle state machine, while still preserving compatibility `PluginRecord.status` values
|
||||
- loader finalization policy outcomes now have a host-owned helper boundary
|
||||
- loader final cache, readiness promotion, and activation finalization now has a host-owned helper boundary
|
||||
- low-risk channel, provider, gateway-method, HTTP-route, tool, CLI, service, command, context-engine, and hook compatibility writes now have a host-owned helper boundary in `src/extension-host/contributions/registry-writes.ts`
|
||||
- legacy internal-hook bridging and typed prompt-injection compatibility policy now have a host-owned helper boundary in `src/extension-host/compat/hook-compat.ts`
|
||||
- compatibility `OpenClawPluginApi` composition and logger shaping now have a host-owned helper boundary in `src/extension-host/compat/plugin-api.ts`
|
||||
- compatibility plugin-registry facade ownership now has a host-owned helper boundary in `src/extension-host/compat/plugin-registry.ts`
|
||||
- compatibility plugin-registry policy now has a host-owned helper boundary in `src/extension-host/compat/plugin-registry-compat.ts`
|
||||
- compatibility plugin-registry registration actions now have a host-owned helper boundary in `src/extension-host/compat/plugin-registry-registrations.ts`
|
||||
- host-owned runtime registry accessors now have a host-owned helper boundary in `src/extension-host/contributions/runtime-registry.ts`, and the channel, provider, tool, command, HTTP-route, gateway-method, CLI, and service slices now keep host-owned storage there with mirrored legacy compatibility views
|
||||
- plugin command registration, matching, execution, listing, native command-spec projection, and loader reload clearing now have a host-owned helper boundary in `src/extension-host/contributions/command-runtime.ts`
|
||||
- service startup, stop ordering, service-context creation, and failure logging now have a host-owned helper boundary in `src/extension-host/contributions/service-lifecycle.ts`
|
||||
- CLI duplicate detection, registrar invocation, and async failure logging now have a host-owned helper boundary in `src/extension-host/contributions/cli-lifecycle.ts`
|
||||
- gateway method-id aggregation, plugin diagnostic shaping, and extra-handler composition now have a host-owned helper boundary in `src/extension-host/contributions/gateway-methods.ts`
|
||||
- plugin tool resolution, conflict handling, optional-tool gating, and plugin-tool metadata tracking now have a host-owned helper boundary in `src/extension-host/contributions/tool-runtime.ts`
|
||||
- plugin provider projection from registry entries into runtime provider objects now have a host-owned helper boundary in `src/extension-host/contributions/provider-runtime.ts`
|
||||
- plugin provider discovery filtering, order grouping, and result normalization now have a host-owned helper boundary in `src/extension-host/contributions/provider-discovery.ts`
|
||||
- provider matching, auth-method selection, config-patch merging, and default-model application now have a host-owned helper boundary in `src/extension-host/contributions/provider-auth.ts`
|
||||
- provider onboarding option building, model-picker entry building, and provider-method choice resolution now have a host-owned helper boundary in `src/extension-host/contributions/provider-wizard.ts`
|
||||
- loaded-provider auth application, plugin-enable gating, auth-method execution, and post-auth default-model handling now have a host-owned helper boundary in `src/extension-host/contributions/provider-auth-flow.ts`
|
||||
- provider post-selection hook lookup and invocation now have a host-owned helper boundary in `src/extension-host/contributions/provider-model-selection.ts`
|
||||
|
||||
Why this matters for this spec:
|
||||
|
||||
- event work should land on top of a host-owned boundary and normalized contribution model rather than on top of more plugin-era runtime seams
|
||||
- the current implementation has deliberately not started canonical bridge or stage work before those earlier boundaries were in place, including the first loader-runtime, record-state, discovery-policy, activation-policy, finalization-policy, low-risk registry-write, hook-compat, plugin-api, plugin-registry, plugin-registry-compat, plugin-registry-registrations, runtime-registry storage and accessors, command-runtime, service-lifecycle, CLI-lifecycle, gateway-methods, tool-runtime, provider-runtime, provider-discovery, provider-auth, provider-wizard, provider-auth-flow, and provider-model-selection seams
|
||||
|
||||
## Design Goals
|
||||
|
||||
- every inbound and outbound path goes through one canonical pipeline
|
||||
- handler behavior is declared, not inferred
|
||||
- routing-affecting handlers are distinct from passive observers
|
||||
- ordering and merge rules are deterministic
|
||||
- extension failures are isolated and visible
|
||||
- sync transcript-write paths remain explicit rather than being hidden inside generic async stages
|
||||
- current plugin hooks, internal hooks, and agent event streams can be bridged into one model incrementally
|
||||
- the migration path for legacy event buses is explicit rather than accidental
|
||||
|
||||
## Sequencing Constraints
|
||||
|
||||
This pipeline is a migration target, not a prerequisite for every other host change.
|
||||
|
||||
Therefore:
|
||||
|
||||
- minimal SDK compatibility and host registry ownership should land before broad hook migration
|
||||
- the first event migration should prove parity for a small non-channel hook case and a channel case
|
||||
- do not require every event family to be implemented before pilot migrations can bridge the current hook set
|
||||
- do not leave legacy hook buses as undocumented permanent peers to the canonical pipeline
|
||||
|
||||
## Canonical Event Families
|
||||
|
||||
The kernel should emit typed event families instead of raw plugin hook names.
|
||||
|
||||
Recommended families:
|
||||
|
||||
- `runtime.started`
|
||||
- `runtime.stopping`
|
||||
- `gateway.starting`
|
||||
- `gateway.started`
|
||||
- `gateway.stopping`
|
||||
- `command.received`
|
||||
- `command.completed`
|
||||
- `account.started`
|
||||
- `account.stopped`
|
||||
- `ingress.received`
|
||||
- `ingress.normalized`
|
||||
- `ingress.claiming`
|
||||
- `routing.resolving`
|
||||
- `routing.resolved`
|
||||
- `session.starting`
|
||||
- `session.started`
|
||||
- `session.resetting`
|
||||
- `agent.starting`
|
||||
- `agent.model.resolving`
|
||||
- `agent.prompt.building`
|
||||
- `agent.llm.input`
|
||||
- `agent.llm.output`
|
||||
- `agent.tool.calling`
|
||||
- `agent.tool.called`
|
||||
- `transcript.tool-result.persisting`
|
||||
- `transcript.message.writing`
|
||||
- `compaction.before`
|
||||
- `compaction.after`
|
||||
- `agent.completed`
|
||||
- `egress.preparing`
|
||||
- `egress.sending`
|
||||
- `egress.sent`
|
||||
- `egress.cancelled`
|
||||
- `egress.failed`
|
||||
- `interaction.received`
|
||||
- `subagent.spawning`
|
||||
- `subagent.spawned`
|
||||
- `subagent.delivery.resolving`
|
||||
- `subagent.delivery.resolved`
|
||||
- `subagent.completed`
|
||||
|
||||
These families intentionally cover the behavior currently spread across `src/plugins/hooks.ts:1`, `src/hooks/internal-hooks.ts:13`, `src/infra/agent-events.ts:3`, and channel monitors.
|
||||
|
||||
`ingress.claiming` exists to absorb behavior that is currently tempting to model as plugin-specific hooks or direct dispatch short-circuits:
|
||||
|
||||
- bound conversation ownership
|
||||
- first-claim-wins plugin or extension routing
|
||||
- future route-claim or veto decisions that must run before command or agent dispatch
|
||||
|
||||
## Canonical Event Envelope
|
||||
|
||||
Every event should carry:
|
||||
|
||||
- `eventId`
|
||||
- `family`
|
||||
- `occurredAt`
|
||||
- `workspaceId`
|
||||
- `agentId`
|
||||
- `sessionId`
|
||||
- `accountRef`
|
||||
- `conversationRef`
|
||||
- `threadRef`
|
||||
- `messageRef`
|
||||
- `sourceContributionId`
|
||||
- `correlationId`
|
||||
- `payload`
|
||||
- `metadata`
|
||||
- `providerMetadata`
|
||||
- `hotPath`
|
||||
|
||||
The event envelope is immutable. Mutation happens through stage outputs, not by mutating the event object in place.
|
||||
|
||||
## Handler Classes
|
||||
|
||||
Each handler contribution must declare exactly one class:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
- `veto`
|
||||
- `resolver`
|
||||
|
||||
### `observer`
|
||||
|
||||
Side effects only. No runtime decision output.
|
||||
|
||||
### `augmenter`
|
||||
|
||||
May attach additional context for downstream stages.
|
||||
|
||||
Examples:
|
||||
|
||||
- prompt context injection
|
||||
- memory recall summaries
|
||||
- diagnostics enrichment
|
||||
|
||||
### `mutator`
|
||||
|
||||
May modify a typed working object for the current pipeline stage.
|
||||
|
||||
Examples:
|
||||
|
||||
- prompt build additions
|
||||
- model override
|
||||
- tool call decoration
|
||||
|
||||
### `veto`
|
||||
|
||||
May cancel a downstream action with a typed reason.
|
||||
|
||||
Examples today:
|
||||
|
||||
- send cancellation in `extensions/thread-ownership/index.ts:63`
|
||||
|
||||
### `resolver`
|
||||
|
||||
May produce a selected target or route decision.
|
||||
|
||||
Examples today:
|
||||
|
||||
- subagent delivery target selection in `extensions/discord/src/subagent-hooks.ts:103`
|
||||
|
||||
Only `veto` and `resolver` handlers may influence routing or delivery decisions.
|
||||
|
||||
`ingress.claiming` is the first concrete place where a resolver-like route claim is expected to matter during migration.
|
||||
|
||||
First-cut parity rule for `ingress.claiming`:
|
||||
|
||||
- claim handlers run sequentially in deterministic order
|
||||
- the first successful claim wins ownership of the inbound turn
|
||||
- passive observers still run in their own stages instead of being skipped accidentally
|
||||
- the migration bridge may target a single extension when a host-owned binding already resolved the owner
|
||||
|
||||
## Execution Modes
|
||||
|
||||
The semantic handler class is not enough by itself.
|
||||
|
||||
Each stage must also declare one of three execution modes:
|
||||
|
||||
- `parallel`
|
||||
For read-only observers and low-risk side effects.
|
||||
- `sequential`
|
||||
For merge, mutation, veto, and resolver stages.
|
||||
- `sync-sequential`
|
||||
For transcript and persistence hot paths where async handlers are not allowed.
|
||||
|
||||
This mirrors current `main` behavior in `src/plugins/hooks.ts:199`, `src/plugins/hooks.ts:226`, `src/plugins/hooks.ts:465`, and `src/plugins/hooks.ts:528`.
|
||||
|
||||
## Deterministic Ordering
|
||||
|
||||
Within a stage, handlers run in this order:
|
||||
|
||||
1. explicit priority descending
|
||||
2. extension id ascending
|
||||
3. contribution id ascending
|
||||
|
||||
Priority is optional. Ties must resolve deterministically.
|
||||
|
||||
## Stage Execution Model
|
||||
|
||||
Every pipeline stage declares:
|
||||
|
||||
- which handler classes are allowed
|
||||
- execution mode
|
||||
- whether handlers run in parallel or sequentially
|
||||
- how outputs are merged
|
||||
- whether errors fail open or fail closed
|
||||
|
||||
## Gateway And Command Pipeline
|
||||
|
||||
### Stage: `gateway.starting`, `gateway.started`, `gateway.stopping`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- lifecycle telemetry
|
||||
- startup and shutdown side effects
|
||||
|
||||
### Stage: `command.received`, `command.completed`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- command audit
|
||||
- command lifecycle integration
|
||||
- operator-visible side effects
|
||||
- preserve source-surface metadata for chat commands, native commands, and host CLI invocations when those flows are bridged into canonical command events
|
||||
|
||||
Bridge requirement:
|
||||
|
||||
- the current internal hook bus in `src/hooks/internal-hooks.ts:13`
|
||||
- and the current agent event stream in `src/infra/agent-events.ts:3`
|
||||
|
||||
must be mapped deliberately into canonical families during migration.
|
||||
|
||||
Acceptable end states are:
|
||||
|
||||
- they become compatibility sources that emit canonical events
|
||||
- or they are fully retired after parity is reached
|
||||
|
||||
An undocumented permanent fourth event system is not acceptable.
|
||||
|
||||
## Ingress Pipeline
|
||||
|
||||
### Stage 1: `ingress.received`
|
||||
|
||||
Input:
|
||||
|
||||
- raw adapter payload
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- telemetry
|
||||
- raw audit
|
||||
- diagnostics
|
||||
|
||||
### Stage 2: `ingress.normalized`
|
||||
|
||||
Input:
|
||||
|
||||
- normalized inbound envelope from `adapter.runtime.decodeIngress`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- add normalized metadata
|
||||
- enrich source/account context
|
||||
- attach pre-routing annotations
|
||||
|
||||
This stage must not choose a route.
|
||||
|
||||
### Stage 3: `routing.resolving`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `augmenter`
|
||||
- `resolver`
|
||||
- `veto`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- route lookup
|
||||
- ownership checks
|
||||
- subagent delivery target resolution
|
||||
- policy application before route finalization
|
||||
|
||||
Merge rules:
|
||||
|
||||
- `resolver` outputs produce candidate route decisions
|
||||
- highest-precedence valid decision wins
|
||||
- `veto` may cancel route selection
|
||||
|
||||
### Stage 4: `routing.resolved`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- emit resolved route metadata
|
||||
- enrich downstream session context
|
||||
|
||||
### Stage 5: `session.starting`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- bind session context
|
||||
- attach memory lookup keys
|
||||
- prepare session-scoped metadata
|
||||
|
||||
### Stage 6: `session.started`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- fire lifecycle observers
|
||||
|
||||
### Stage 7: `agent.starting`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- last pre-run annotations
|
||||
|
||||
## Prompt And Model Pipeline
|
||||
|
||||
### Stage: `agent.model.resolving`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Merge rules:
|
||||
|
||||
- first defined model override wins
|
||||
- first defined provider override wins
|
||||
|
||||
This mirrors current precedence in `src/plugins/hooks.ts:117`.
|
||||
|
||||
### Stage: `agent.prompt.building`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Merge rules:
|
||||
|
||||
- static system guidance composes in declared order
|
||||
- ephemeral prompt additions compose in declared order
|
||||
- direct system prompt replacement is allowed only for explicitly trusted mutators
|
||||
|
||||
This replaces the ambiguous overlap between `before_prompt_build` and legacy `before_agent_start` in `src/plugins/types.ts:422`.
|
||||
|
||||
### Stage: `agent.llm.input`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- provider-call audit
|
||||
- input usage and prompt metadata capture
|
||||
|
||||
### Stage: `agent.llm.output`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- provider response audit
|
||||
- usage capture
|
||||
- output enrichment
|
||||
|
||||
## Tool Pipeline
|
||||
|
||||
### Stage: `agent.tool.calling`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
- `veto`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- tool policy checks
|
||||
- argument normalization
|
||||
- tool-call audit
|
||||
|
||||
### Stage: `agent.tool.called`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- result indexing
|
||||
- memory capture
|
||||
- diagnostics
|
||||
|
||||
### Stage: `agent.completed`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- end-of-run capture
|
||||
- automatic memory storage
|
||||
- metrics
|
||||
|
||||
## Persistence Pipeline
|
||||
|
||||
### Stage: `transcript.tool-result.persisting`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `mutator`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sync-sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- mutate the tool-result message that will be appended to transcripts
|
||||
|
||||
Rules:
|
||||
|
||||
- async handlers are invalid
|
||||
- handlers run in deterministic priority order
|
||||
- each handler sees the previous handler's output
|
||||
|
||||
This is the explicit replacement for today's sync-only `tool_result_persist` hook in `src/plugins/hooks.ts:465`.
|
||||
|
||||
### Stage: `transcript.message.writing`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `mutator`
|
||||
- `veto`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sync-sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- final transcript message mutation
|
||||
- transcript write suppression when explicitly requested
|
||||
|
||||
Rules:
|
||||
|
||||
- async handlers are invalid
|
||||
- successful veto decisions are terminal
|
||||
- mutation happens before the final write
|
||||
|
||||
This is the explicit replacement for today's sync-only `before_message_write` hook in `src/plugins/hooks.ts:528`.
|
||||
|
||||
## Compaction And Reset Pipeline
|
||||
|
||||
Canonical stages:
|
||||
|
||||
- `compaction.before`
|
||||
- `compaction.after`
|
||||
- `session.resetting`
|
||||
|
||||
## Egress Pipeline
|
||||
|
||||
### Stage 1: `egress.preparing`
|
||||
|
||||
Input:
|
||||
|
||||
- normalized outbound envelope
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
- `mutator`
|
||||
- `veto`
|
||||
- `resolver`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- choose provider or account when not explicit
|
||||
- attach send metadata
|
||||
- enforce ownership or safety policy
|
||||
|
||||
This stage replaces today’s mixed send hooks and route checks.
|
||||
|
||||
### Stage 2: `egress.sending`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `parallel`
|
||||
|
||||
Purpose:
|
||||
|
||||
- telemetry and audit before transport send
|
||||
|
||||
### Stage 3: `egress.sent`, `egress.cancelled`, `egress.failed`
|
||||
|
||||
Allowed handler classes:
|
||||
|
||||
- `observer`
|
||||
- `augmenter`
|
||||
|
||||
Execution mode:
|
||||
|
||||
- `sequential`
|
||||
|
||||
Purpose:
|
||||
|
||||
- post-send side effects
|
||||
- delivery-state indexing
|
||||
|
||||
## Interaction Pipeline
|
||||
|
||||
Interaction events should not be routed through message hooks.
|
||||
|
||||
Canonical stages:
|
||||
|
||||
- `interaction.received`
|
||||
- `interaction.resolved`
|
||||
- `interaction.completed`
|
||||
|
||||
These handle slash commands, button presses, modal submissions, and similar surfaces.
|
||||
|
||||
## Subagent Pipeline
|
||||
|
||||
The current hook set already proves this needs explicit treatment:
|
||||
|
||||
- `subagent_spawning`
|
||||
- `subagent_delivery_target`
|
||||
- `subagent_spawned`
|
||||
- `subagent_ended`
|
||||
|
||||
The canonical form should be:
|
||||
|
||||
- `subagent.spawning`
|
||||
- `subagent.spawned`
|
||||
- `subagent.delivery.resolving`
|
||||
- `subagent.delivery.resolved`
|
||||
- `subagent.completed`
|
||||
|
||||
Resolver semantics:
|
||||
|
||||
- multiple candidates may be proposed
|
||||
- explicit target beats inferred target
|
||||
- otherwise highest-ranked valid candidate wins
|
||||
|
||||
## Merge Rules
|
||||
|
||||
### Observer
|
||||
|
||||
No merge output.
|
||||
|
||||
### Augmenter
|
||||
|
||||
Produces additive metadata only.
|
||||
|
||||
Conflicts merge by:
|
||||
|
||||
- key append for list-like fields
|
||||
- last-writer-wins only for fields explicitly marked replaceable
|
||||
|
||||
### Mutator
|
||||
|
||||
Produces typed patch objects.
|
||||
|
||||
Rules:
|
||||
|
||||
- patch schema is stage-specific
|
||||
- patches apply in deterministic order
|
||||
- later patches see earlier outputs
|
||||
|
||||
### Veto
|
||||
|
||||
Produces:
|
||||
|
||||
- `allow`
|
||||
- `cancel`
|
||||
|
||||
Rules:
|
||||
|
||||
- one `cancel` is terminal if the stage is fail-closed
|
||||
- fail-open stages may ignore veto errors but not successful veto decisions
|
||||
|
||||
### Resolver
|
||||
|
||||
Produces candidate selections.
|
||||
|
||||
Rules:
|
||||
|
||||
- explicit target selectors win
|
||||
- otherwise rank, policy, and deterministic tie-breakers apply
|
||||
|
||||
## Error Handling
|
||||
|
||||
Per-stage error policy must be explicit.
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
- telemetry and observer stages fail open
|
||||
- routing and send veto stages fail open unless the contribution declares `failClosed`
|
||||
- credential or auth mutation stages fail closed
|
||||
- backend selection stages fail closed when no valid provider remains
|
||||
- sync transcript stages fail open on handler failure but must still reject accidental async handlers
|
||||
|
||||
## Legacy Hook Mapping
|
||||
|
||||
Current hook names map approximately like this:
|
||||
|
||||
- `before_model_resolve` -> `agent.model.resolving`
|
||||
- `before_prompt_build` -> `agent.prompt.building`
|
||||
- `before_agent_start` -> split between `agent.model.resolving` and `agent.prompt.building`
|
||||
- `llm_input` -> `agent.llm.input`
|
||||
- `llm_output` -> `agent.llm.output`
|
||||
- `message_received` -> `ingress.normalized`
|
||||
- `message_sending` -> `egress.preparing`
|
||||
- `message_sent` -> `egress.sent`
|
||||
- `before_tool_call` -> `agent.tool.calling`
|
||||
- `after_tool_call` -> `agent.tool.called`
|
||||
- `tool_result_persist` -> `transcript.tool-result.persisting`
|
||||
- `before_message_write` -> `transcript.message.writing`
|
||||
- `before_compaction` -> `compaction.before`
|
||||
- `after_compaction` -> `compaction.after`
|
||||
- `before_reset` -> `session.resetting`
|
||||
- `gateway_start` -> `gateway.started`
|
||||
- `gateway_stop` -> `gateway.stopping`
|
||||
- `subagent_delivery_target` -> `subagent.delivery.resolving`
|
||||
|
||||
First pilot focus:
|
||||
|
||||
- `thread-ownership` should validate `message_received` and `message_sending` migration into canonical ingress and egress stages
|
||||
- `telegram` should validate that channel-path runtime behavior can participate in canonical events without reintroducing plugin-shaped kernel seams
|
||||
|
||||
## Immediate Implementation Work
|
||||
|
||||
1. Add canonical event and stage types to the kernel.
|
||||
2. Build a stage runner with explicit handler-class validation.
|
||||
3. Add typed patch and veto result contracts per stage, including sync-sequential stages.
|
||||
4. Bridge legacy plugin hooks, internal hooks, and agent events into canonical stages in the extension host only.
|
||||
5. Record the exact legacy-to-canonical mapping used by `thread-ownership`.
|
||||
6. Record the exact legacy-to-canonical mapping used by `telegram`.
|
||||
7. Refactor one channel and one non-channel extension through the new pipeline before broader migration.
|
||||
8. Decide and document the retirement plan for any legacy event bus that remains after parity is achieved.
|
||||
File diff suppressed because it is too large
Load Diff
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@@ -0,0 +1 @@
|
||||
docs.openclaw.ai
|
||||
@@ -1,11 +1,3 @@
|
||||
---
|
||||
title: "Auth Credential Semantics"
|
||||
summary: "Canonical credential eligibility and resolution semantics for auth profiles"
|
||||
read_when:
|
||||
- Working on auth profile resolution or credential routing
|
||||
- Debugging model auth failures or profile order
|
||||
---
|
||||
|
||||
# Auth Credential Semantics
|
||||
|
||||
This document defines the canonical credential eligibility and resolution semantics used across:
|
||||
|
||||
@@ -700,7 +700,7 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Nothing runs"
|
||||
### “Nothing runs”
|
||||
|
||||
- Check cron is enabled: `cron.enabled` and `OPENCLAW_SKIP_CRON`.
|
||||
- Check the Gateway is running continuously (cron runs inside the Gateway process).
|
||||
|
||||
@@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
|
||||
|
||||
Hooks can also be bundled inside plugins; see [Plugin hooks](/plugins/architecture#provider-runtime-hooks).
|
||||
Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks).
|
||||
|
||||
Common uses:
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ Every request must include the hook token. Prefer headers:
|
||||
- `Authorization: Bearer <token>` (recommended)
|
||||
- `x-openclaw-token: <token>`
|
||||
- Query-string tokens are rejected (`?token=...` returns `400`).
|
||||
- Treat `hooks.token` holders as full-trust callers for the hook ingress surface on that gateway. Hook payload content is still untrusted, but this is not a separate non-owner auth boundary.
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -206,7 +205,6 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
|
||||
|
||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||
- Use a dedicated hook token; do not reuse gateway auth tokens.
|
||||
- Prefer a dedicated hook agent with strict `tools.profile` and sandboxing so hook ingress has a narrower blast radius.
|
||||
- Repeated auth failures are rate-limited per client address to slow brute-force attempts.
|
||||
- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection.
|
||||
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
|
||||
|
||||
@@ -20,21 +20,11 @@ OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
brave: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
@@ -43,9 +33,6 @@ OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
}
|
||||
```
|
||||
|
||||
Provider-specific Brave search settings now live under `plugins.entries.brave.config.webSearch.*`.
|
||||
Legacy `tools.web.search.apiKey` still loads through the compatibility shim, but it is no longer the canonical config path.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|
||||
@@ -126,7 +126,7 @@ launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist
|
||||
|
||||
## Onboarding
|
||||
|
||||
BlueBubbles is available in interactive onboarding:
|
||||
BlueBubbles is available in the interactive setup wizard:
|
||||
|
||||
```
|
||||
openclaw onboard
|
||||
|
||||
@@ -96,10 +96,8 @@ You will need to create a new application with a bot, add the bot to your server
|
||||
Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent.
|
||||
|
||||
```bash
|
||||
export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN"
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
|
||||
openclaw config set channels.discord.enabled true --strict-json
|
||||
openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json
|
||||
openclaw config set channels.discord.enabled true --json
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
@@ -123,11 +121,7 @@ openclaw gateway
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
token: "YOUR_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -139,7 +133,7 @@ openclaw gateway
|
||||
DISCORD_BOT_TOKEN=...
|
||||
```
|
||||
|
||||
Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets).
|
||||
SecretRef values are also supported for `channels.discord.token` (env/file/exec providers). See [Secrets Management](/gateway/secrets).
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -174,7 +168,7 @@ openclaw pairing approve discord <CODE>
|
||||
|
||||
<Note>
|
||||
Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account.
|
||||
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. This applies to send and read/probe-style actions (for example read/search/fetch/thread/pins/permissions). Account policy/retry settings still come from the selected account in the active runtime snapshot.
|
||||
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot.
|
||||
</Note>
|
||||
|
||||
## Recommended: Set up a guild workspace
|
||||
|
||||
@@ -30,9 +30,9 @@ openclaw plugins install @openclaw/feishu
|
||||
|
||||
There are two ways to add the Feishu channel:
|
||||
|
||||
### Method 1: onboarding (recommended)
|
||||
### Method 1: onboarding wizard (recommended)
|
||||
|
||||
If you just installed OpenClaw, run onboarding:
|
||||
If you just installed OpenClaw, run the wizard:
|
||||
|
||||
```bash
|
||||
openclaw onboard
|
||||
@@ -711,7 +711,7 @@ Key options:
|
||||
- ✅ Images
|
||||
- ✅ Files
|
||||
- ✅ Audio
|
||||
- ✅ Video/media
|
||||
- ✅ Video
|
||||
- ✅ Stickers
|
||||
|
||||
### Send
|
||||
@@ -720,28 +720,4 @@ Key options:
|
||||
- ✅ Images
|
||||
- ✅ Files
|
||||
- ✅ Audio
|
||||
- ✅ Video/media
|
||||
- ✅ Interactive cards
|
||||
- ⚠️ Rich text (post-style formatting and cards, not arbitrary Feishu authoring features)
|
||||
|
||||
### Threads and replies
|
||||
|
||||
- ✅ Inline replies
|
||||
- ✅ Topic-thread replies where Feishu exposes `reply_in_thread`
|
||||
- ✅ Media replies stay thread-aware when replying to a thread/topic message
|
||||
|
||||
## Runtime action surface
|
||||
|
||||
Feishu currently exposes these runtime actions:
|
||||
|
||||
- `send`
|
||||
- `read`
|
||||
- `edit`
|
||||
- `thread-reply`
|
||||
- `pin`
|
||||
- `list-pins`
|
||||
- `unpin`
|
||||
- `member-info`
|
||||
- `channel-info`
|
||||
- `channel-list`
|
||||
- `react` and `reactions` when reactions are enabled in config
|
||||
- ⚠️ Rich text (partial support)
|
||||
|
||||
@@ -11,7 +11,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
|
||||
|
||||
Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback).
|
||||
|
||||
## Current implementation (2025-12-03)
|
||||
## What’s implemented (2025-12-03)
|
||||
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
|
||||
|
||||
@@ -7,8 +7,6 @@ read_when:
|
||||
- You are configuring IRC allowlists, group policy, or mention gating
|
||||
---
|
||||
|
||||
# IRC
|
||||
|
||||
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
|
||||
IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`.
|
||||
|
||||
|
||||
@@ -1,70 +1,83 @@
|
||||
---
|
||||
summary: "Matrix support status, setup, and configuration examples"
|
||||
summary: "Matrix support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Setting up Matrix in OpenClaw
|
||||
- Configuring Matrix E2EE and verification
|
||||
- Working on Matrix channel features
|
||||
title: "Matrix"
|
||||
---
|
||||
|
||||
# Matrix (plugin)
|
||||
|
||||
Matrix is the Matrix channel plugin for OpenClaw.
|
||||
It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE.
|
||||
Matrix is an open, decentralized messaging protocol. OpenClaw connects as a Matrix **user**
|
||||
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
|
||||
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
|
||||
but it requires E2EE to be enabled.
|
||||
|
||||
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
|
||||
polls (send + poll-start as text), location, and E2EE (with crypto support).
|
||||
|
||||
## Plugin required
|
||||
|
||||
Matrix is a plugin and is not bundled with core OpenClaw.
|
||||
Matrix ships as a plugin and is not bundled with the core install.
|
||||
|
||||
Install from npm:
|
||||
Install via CLI (npm registry):
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/matrix
|
||||
```
|
||||
|
||||
Install from a local checkout:
|
||||
Local checkout (when running from a git repo):
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/matrix
|
||||
```
|
||||
|
||||
See [Plugins](/tools/plugin) for plugin behavior and install rules.
|
||||
If you choose Matrix during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the plugin.
|
||||
2. Create a Matrix account on your homeserver.
|
||||
3. Configure `channels.matrix` with either:
|
||||
- `homeserver` + `accessToken`, or
|
||||
- `homeserver` + `userId` + `password`.
|
||||
4. Restart the gateway.
|
||||
5. Start a DM with the bot or invite it to a room.
|
||||
1. Install the Matrix plugin:
|
||||
- From npm: `openclaw plugins install @openclaw/matrix`
|
||||
- From a local checkout: `openclaw plugins install ./extensions/matrix`
|
||||
2. Create a Matrix account on a homeserver:
|
||||
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/)
|
||||
- Or host it yourself.
|
||||
3. Get an access token for the bot account:
|
||||
- Use the Matrix login API with `curl` at your home server:
|
||||
|
||||
Interactive setup paths:
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://matrix.example.org/_matrix/client/v3/login \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"type": "m.login.password",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": "your-user-name"
|
||||
},
|
||||
"password": "your-password"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
openclaw channels add
|
||||
openclaw configure --section channels
|
||||
```
|
||||
- Replace `matrix.example.org` with your homeserver URL.
|
||||
- Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same
|
||||
login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`,
|
||||
and reuses it on next start.
|
||||
|
||||
What the Matrix wizard actually asks for:
|
||||
4. Configure credentials:
|
||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
|
||||
- Or config: `channels.matrix.*`
|
||||
- If both are set, config takes precedence.
|
||||
- With access token: user ID is fetched automatically via `/whoami`.
|
||||
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
|
||||
5. Restart the gateway (or finish onboarding).
|
||||
6. Start a DM with the bot or invite it to a room from any Matrix client
|
||||
(Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE,
|
||||
so set `channels.matrix.encryption: true` and verify the device.
|
||||
|
||||
- homeserver URL
|
||||
- auth method: access token or password
|
||||
- user ID only when you choose password auth
|
||||
- optional device name
|
||||
- whether to enable E2EE
|
||||
- whether to configure Matrix room access now
|
||||
|
||||
Wizard behavior that matters:
|
||||
|
||||
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
|
||||
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
|
||||
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
|
||||
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
|
||||
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
|
||||
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
|
||||
|
||||
Minimal token-based setup:
|
||||
Minimal config (access token, user ID auto-fetched):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -72,14 +85,14 @@ Minimal token-based setup:
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_xxx",
|
||||
accessToken: "syt_***",
|
||||
dm: { policy: "pairing" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Password-based setup (token is cached after login):
|
||||
E2EE config (end to end encryption enabled):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -87,92 +100,7 @@ Password-based setup (token is cached after login):
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "replace-me", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Matrix stores cached credentials in `~/.openclaw/credentials/matrix/`.
|
||||
The default account uses `credentials.json`; named accounts use `credentials-<account>.json`.
|
||||
|
||||
Environment variable equivalents (used when the config key is not set):
|
||||
|
||||
- `MATRIX_HOMESERVER`
|
||||
- `MATRIX_ACCESS_TOKEN`
|
||||
- `MATRIX_USER_ID`
|
||||
- `MATRIX_PASSWORD`
|
||||
- `MATRIX_DEVICE_ID`
|
||||
- `MATRIX_DEVICE_NAME`
|
||||
|
||||
For non-default accounts, use account-scoped env vars:
|
||||
|
||||
- `MATRIX_<ACCOUNT_ID>_HOMESERVER`
|
||||
- `MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN`
|
||||
- `MATRIX_<ACCOUNT_ID>_USER_ID`
|
||||
- `MATRIX_<ACCOUNT_ID>_PASSWORD`
|
||||
- `MATRIX_<ACCOUNT_ID>_DEVICE_ID`
|
||||
- `MATRIX_<ACCOUNT_ID>_DEVICE_NAME`
|
||||
|
||||
Example for account `ops`:
|
||||
|
||||
- `MATRIX_OPS_HOMESERVER`
|
||||
- `MATRIX_OPS_ACCESS_TOKEN`
|
||||
|
||||
For normalized account ID `ops-bot`, use:
|
||||
|
||||
- `MATRIX_OPS_BOT_HOMESERVER`
|
||||
- `MATRIX_OPS_BOT_ACCESS_TOKEN`
|
||||
|
||||
The interactive wizard only offers the env-var shortcut when those auth env vars are already present and the selected account does not already have Matrix auth saved in config.
|
||||
|
||||
## Configuration example
|
||||
|
||||
This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_xxx",
|
||||
encryption: true,
|
||||
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
},
|
||||
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["@admin:example.org"],
|
||||
groups: {
|
||||
"!roomid:example.org": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["!roomid:example.org"],
|
||||
threadReplies: "inbound",
|
||||
replyToMode: "off",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## E2EE setup
|
||||
|
||||
Enable encryption:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_xxx",
|
||||
accessToken: "syt_***",
|
||||
encryption: true,
|
||||
dm: { policy: "pairing" },
|
||||
},
|
||||
@@ -180,371 +108,60 @@ Enable encryption:
|
||||
}
|
||||
```
|
||||
|
||||
Check verification status:
|
||||
## Encryption (E2EE)
|
||||
|
||||
```bash
|
||||
openclaw matrix verify status
|
||||
```
|
||||
End-to-end encryption is **supported** via the Rust crypto SDK.
|
||||
|
||||
Verbose status (full diagnostics):
|
||||
Enable with `channels.matrix.encryption: true`:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify status --verbose
|
||||
```
|
||||
- If the crypto module loads, encrypted rooms are decrypted automatically.
|
||||
- Outbound media is encrypted when sending to encrypted rooms.
|
||||
- On first connection, OpenClaw requests device verification from your other sessions.
|
||||
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
|
||||
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
||||
OpenClaw logs a warning.
|
||||
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
|
||||
allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run
|
||||
`pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with
|
||||
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
|
||||
|
||||
Include the stored recovery key in machine-readable output:
|
||||
Crypto state is stored per account + access token in
|
||||
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
|
||||
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
|
||||
If the access token (device) changes, a new store is created and the bot must be
|
||||
re-verified for encrypted rooms.
|
||||
|
||||
```bash
|
||||
openclaw matrix verify status --include-recovery-key --json
|
||||
```
|
||||
**Device verification:**
|
||||
When E2EE is enabled, the bot will request verification from your other sessions on startup.
|
||||
Open Element (or another client) and approve the verification request to establish trust.
|
||||
Once verified, the bot can decrypt messages in encrypted rooms.
|
||||
|
||||
Bootstrap cross-signing and verification state:
|
||||
## Multi-account
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap
|
||||
```
|
||||
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
Verbose bootstrap diagnostics:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap --verbose
|
||||
```
|
||||
|
||||
Force a fresh cross-signing identity reset before bootstrapping:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap --force-reset-cross-signing
|
||||
```
|
||||
|
||||
Verify this device with a recovery key:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify device "<your-recovery-key>"
|
||||
```
|
||||
|
||||
Verbose device verification details:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify device "<your-recovery-key>" --verbose
|
||||
```
|
||||
|
||||
Check room-key backup health:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup status
|
||||
```
|
||||
|
||||
Verbose backup health diagnostics:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup status --verbose
|
||||
```
|
||||
|
||||
Restore room keys from server backup:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup restore
|
||||
```
|
||||
|
||||
Verbose restore diagnostics:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup restore --verbose
|
||||
```
|
||||
|
||||
Delete the current server backup and create a fresh backup baseline:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
```
|
||||
|
||||
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
|
||||
Use `--json` for full machine-readable output when scripting.
|
||||
|
||||
In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account <id>`.
|
||||
If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly.
|
||||
Use `--account` whenever you want verification or device operations to target a named account explicitly:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify status --account assistant
|
||||
openclaw matrix verify backup restore --account assistant
|
||||
openclaw matrix devices list --account assistant
|
||||
```
|
||||
|
||||
When encryption is disabled or unavailable for a named account, Matrix warnings and verification errors point at that account's config key, for example `channels.matrix.accounts.assistant.encryption`.
|
||||
|
||||
### What "verified" means
|
||||
|
||||
OpenClaw treats this Matrix device as verified only when it is verified by your own cross-signing identity.
|
||||
In practice, `openclaw matrix verify status --verbose` exposes three trust signals:
|
||||
|
||||
- `Locally trusted`: this device is trusted by the current client only
|
||||
- `Cross-signing verified`: the SDK reports the device as verified through cross-signing
|
||||
- `Signed by owner`: the device is signed by your own self-signing key
|
||||
|
||||
`Verified by owner` becomes `yes` only when cross-signing verification or owner-signing is present.
|
||||
Local trust by itself is not enough for OpenClaw to treat the device as fully verified.
|
||||
|
||||
### What bootstrap does
|
||||
|
||||
`openclaw matrix verify bootstrap` is the repair and setup command for encrypted Matrix accounts.
|
||||
It does all of the following in order:
|
||||
|
||||
- bootstraps secret storage, reusing an existing recovery key when possible
|
||||
- bootstraps cross-signing and uploads missing public cross-signing keys
|
||||
- attempts to mark and cross-sign the current device
|
||||
- creates a new server-side room-key backup if one does not already exist
|
||||
|
||||
If the homeserver requires interactive auth to upload cross-signing keys, OpenClaw tries the upload without auth first, then with `m.login.dummy`, then with `m.login.password` when `channels.matrix.password` is configured.
|
||||
|
||||
Use `--force-reset-cross-signing` only when you intentionally want to discard the current cross-signing identity and create a new one.
|
||||
|
||||
If you intentionally want to discard the current room-key backup and start a new backup baseline for future messages, use `openclaw matrix verify backup reset --yes`.
|
||||
Do this only when you accept that unrecoverable old encrypted history will stay unavailable.
|
||||
|
||||
### Fresh backup baseline
|
||||
|
||||
If you want to keep future encrypted messages working and accept losing unrecoverable old history, run these commands in order:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
openclaw matrix verify backup status --verbose
|
||||
openclaw matrix verify status
|
||||
```
|
||||
|
||||
Add `--account <id>` to each command when you want to target a named Matrix account explicitly.
|
||||
|
||||
### Startup behavior
|
||||
|
||||
When `encryption: true`, Matrix defaults `startupVerification` to `"if-unverified"`.
|
||||
On startup, if this device is still unverified, Matrix will request self-verification in another Matrix client,
|
||||
skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts.
|
||||
Failed request attempts retry sooner than successful request creation by default.
|
||||
Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours`
|
||||
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 the current device is already owner-signed, OpenClaw preserves that identity instead of resetting it automatically.
|
||||
|
||||
Upgrading from the previous public Matrix plugin:
|
||||
|
||||
- OpenClaw automatically reuses the same Matrix account, access token, and device identity when possible.
|
||||
- Before any actionable Matrix migration changes run, OpenClaw creates or reuses a recovery snapshot under `~/Backups/openclaw-migrations/`.
|
||||
- If you use multiple Matrix accounts, set `channels.matrix.defaultAccount` before upgrading from the old flat-store layout so OpenClaw knows which account should receive that shared legacy state.
|
||||
- If the previous plugin stored a Matrix room-key backup decryption key locally, startup or `openclaw doctor --fix` will import it into the new recovery-key flow automatically.
|
||||
- If the Matrix access token changed after migration was prepared, startup now scans sibling token-hash storage roots for pending legacy restore state before giving up on the automatic backup restore.
|
||||
- If the Matrix access token changes later for the same account, homeserver, and user, OpenClaw now prefers reusing the most complete existing token-hash storage root instead of starting from an empty Matrix state directory.
|
||||
- On the next gateway start, backed-up room keys are restored automatically into the new crypto store.
|
||||
- If the old plugin had local-only room keys that were never backed up, OpenClaw will warn clearly. Those keys cannot be exported automatically from the previous rust crypto store, so some old encrypted history may remain unavailable until recovered manually.
|
||||
- See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages.
|
||||
|
||||
Encrypted runtime state is organized under per-account, per-user token-hash roots in
|
||||
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/`.
|
||||
That directory contains the sync store (`bot-storage.json`), crypto store (`crypto/`),
|
||||
recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapshot.json`),
|
||||
thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`)
|
||||
when those features are in use.
|
||||
When the token changes but the account identity stays the same, OpenClaw reuses the best existing
|
||||
root for that account/homeserver/user tuple so prior sync state, crypto state, thread bindings,
|
||||
and startup verification state remain visible.
|
||||
|
||||
### Node crypto store model
|
||||
|
||||
Matrix E2EE in this plugin uses the official `matrix-js-sdk` Rust crypto path in Node.
|
||||
That path expects IndexedDB-backed persistence when you want crypto state to survive restarts.
|
||||
|
||||
OpenClaw currently provides that in Node by:
|
||||
|
||||
- using `fake-indexeddb` as the IndexedDB API shim expected by the SDK
|
||||
- restoring the Rust crypto IndexedDB contents from `crypto-idb-snapshot.json` before `initRustCrypto`
|
||||
- persisting the updated IndexedDB contents back to `crypto-idb-snapshot.json` after init and during runtime
|
||||
|
||||
This is compatibility/storage plumbing, not a custom crypto implementation.
|
||||
The snapshot file is sensitive runtime state and is stored with restrictive file permissions.
|
||||
Under OpenClaw's security model, the gateway host and local OpenClaw state directory are already inside the trusted operator boundary, so this is primarily an operational durability concern rather than a separate remote trust boundary.
|
||||
|
||||
Planned improvement:
|
||||
|
||||
- add SecretRef support for persistent Matrix key material so recovery keys and related store-encryption secrets can be sourced from OpenClaw secrets providers instead of only local files
|
||||
|
||||
## Automatic verification notices
|
||||
|
||||
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
|
||||
That includes:
|
||||
|
||||
- verification request notices
|
||||
- verification ready notices (with explicit "Verify by emoji" guidance)
|
||||
- verification start and completion notices
|
||||
- SAS details (emoji and decimal) when available
|
||||
|
||||
Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw.
|
||||
When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side.
|
||||
You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification.
|
||||
|
||||
OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending.
|
||||
|
||||
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
|
||||
|
||||
### Device hygiene
|
||||
|
||||
Old OpenClaw-managed Matrix devices can accumulate on the account and make encrypted-room trust harder to reason about.
|
||||
List them with:
|
||||
|
||||
```bash
|
||||
openclaw matrix devices list
|
||||
```
|
||||
|
||||
Remove stale OpenClaw-managed devices with:
|
||||
|
||||
```bash
|
||||
openclaw matrix devices prune-stale
|
||||
```
|
||||
|
||||
### Direct Room Repair
|
||||
|
||||
If direct-message state gets out of sync, OpenClaw can end up with stale `m.direct` mappings that point at old solo rooms instead of the live DM. Inspect the current mapping for a peer with:
|
||||
|
||||
```bash
|
||||
openclaw matrix direct inspect --user-id @alice:example.org
|
||||
```
|
||||
|
||||
Repair it with:
|
||||
|
||||
```bash
|
||||
openclaw matrix direct repair --user-id @alice:example.org
|
||||
```
|
||||
|
||||
Repair keeps the Matrix-specific logic inside the plugin:
|
||||
|
||||
- it prefers a strict 1:1 DM that is already mapped in `m.direct`
|
||||
- otherwise it falls back to any currently joined strict 1:1 DM with that user
|
||||
- if no healthy DM exists, it creates a fresh direct room and rewrites `m.direct` to point at it
|
||||
|
||||
The repair flow does not delete old rooms automatically. It only picks the healthy DM and updates the mapping so new Matrix sends, verification notices, and other direct-message flows target the right room again.
|
||||
|
||||
## Threads
|
||||
|
||||
Matrix supports native Matrix threads for both automatic replies and message-tool sends.
|
||||
|
||||
- `threadReplies: "off"` keeps replies top-level.
|
||||
- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread.
|
||||
- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message.
|
||||
- Inbound threaded messages include the thread root message as extra agent context.
|
||||
- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided.
|
||||
- Runtime thread bindings are supported for Matrix. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs.
|
||||
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
|
||||
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
|
||||
|
||||
### Thread Binding Config
|
||||
|
||||
Matrix inherits global defaults from `session.threadBindings`, and also supports per-channel overrides:
|
||||
|
||||
- `threadBindings.enabled`
|
||||
- `threadBindings.idleHours`
|
||||
- `threadBindings.maxAgeHours`
|
||||
- `threadBindings.spawnSubagentSessions`
|
||||
- `threadBindings.spawnAcpSessions`
|
||||
|
||||
Matrix thread-bound spawn flags are opt-in:
|
||||
|
||||
- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads.
|
||||
- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads.
|
||||
|
||||
## Reactions
|
||||
|
||||
Matrix supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
|
||||
|
||||
- Outbound reaction tooling is gated by `channels["matrix"].actions.reactions`.
|
||||
- `react` adds a reaction to a specific Matrix event.
|
||||
- `reactions` lists the current reaction summary for a specific Matrix event.
|
||||
- `emoji=""` removes the bot account's own reactions on that event.
|
||||
- `remove: true` removes only the specified emoji reaction from the bot account.
|
||||
|
||||
Ack reactions use the standard OpenClaw resolution order:
|
||||
|
||||
- `channels["matrix"].accounts.<accountId>.ackReaction`
|
||||
- `channels["matrix"].ackReaction`
|
||||
- `messages.ackReaction`
|
||||
- agent identity emoji fallback
|
||||
|
||||
Ack reaction scope resolves in this order:
|
||||
|
||||
- `channels["matrix"].accounts.<accountId>.ackReactionScope`
|
||||
- `channels["matrix"].ackReactionScope`
|
||||
- `messages.ackReactionScope`
|
||||
|
||||
Reaction notification mode resolves in this order:
|
||||
|
||||
- `channels["matrix"].accounts.<accountId>.reactionNotifications`
|
||||
- `channels["matrix"].reactionNotifications`
|
||||
- default: `own`
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `reactionNotifications: "own"` forwards added `m.reaction` events when they target bot-authored Matrix messages.
|
||||
- `reactionNotifications: "off"` disables reaction system events.
|
||||
- Reaction removals are still not synthesized into system events because Matrix surfaces those as redactions, not as standalone `m.reaction` removals.
|
||||
|
||||
## DM and room policy example
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@admin:example.org"],
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["@admin:example.org"],
|
||||
groups: {
|
||||
"!roomid:example.org": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See [Groups](/channels/groups) for mention-gating and allowlist behavior.
|
||||
|
||||
Pairing example for Matrix DMs:
|
||||
|
||||
```bash
|
||||
openclaw pairing list matrix
|
||||
openclaw pairing approve matrix <CODE>
|
||||
```
|
||||
|
||||
If an unapproved Matrix user keeps messaging you before approval, OpenClaw reuses the same pending pairing code and may send a reminder reply again after a short cooldown instead of minting a new code.
|
||||
|
||||
See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layout.
|
||||
|
||||
## Multi-account example
|
||||
Each account runs as a separate Matrix user on any homeserver. Per-account config
|
||||
inherits from the top-level `channels.matrix` settings and can override any option
|
||||
(DM policy, groups, encryption, etc.).
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
defaultAccount: "assistant",
|
||||
dm: { policy: "pairing" },
|
||||
accounts: {
|
||||
assistant: {
|
||||
name: "Main assistant",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_assistant_xxx",
|
||||
accessToken: "syt_assistant_***",
|
||||
encryption: true,
|
||||
},
|
||||
alerts: {
|
||||
name: "Alerts bot",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_alerts_xxx",
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@ops:example.org"],
|
||||
},
|
||||
accessToken: "syt_alerts_***",
|
||||
dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -552,60 +169,135 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
|
||||
}
|
||||
```
|
||||
|
||||
Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them.
|
||||
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
|
||||
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.
|
||||
Notes:
|
||||
|
||||
## Target resolution
|
||||
- Account startup is serialized to avoid race conditions with concurrent module imports.
|
||||
- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account.
|
||||
- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account.
|
||||
- Use `bindings[].match.accountId` to route each account to a different agent.
|
||||
- Crypto state is stored per account + access token (separate key stores per account).
|
||||
|
||||
Matrix accepts these target forms anywhere OpenClaw asks you for a room or user target:
|
||||
## Routing model
|
||||
|
||||
- Users: `@user:server`, `user:@user:server`, or `matrix:user:@user:server`
|
||||
- Rooms: `!room:server`, `room:!room:server`, or `matrix:room:!room:server`
|
||||
- Aliases: `#alias:server`, `channel:#alias:server`, or `matrix:channel:#alias:server`
|
||||
- Replies always go back to Matrix.
|
||||
- DMs share the agent's main session; rooms map to group sessions.
|
||||
|
||||
Live directory lookup uses the logged-in Matrix account:
|
||||
## Access control (DMs)
|
||||
|
||||
- User lookups query the Matrix user directory on that homeserver.
|
||||
- Room lookups accept explicit room IDs and aliases directly, then fall back to searching joined room names for that account.
|
||||
- Joined-room name lookup is best-effort. If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
|
||||
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
|
||||
- Approve via:
|
||||
- `openclaw pairing list matrix`
|
||||
- `openclaw pairing approve matrix <CODE>`
|
||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||
- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match.
|
||||
- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs.
|
||||
|
||||
## Configuration reference
|
||||
## Rooms (groups)
|
||||
|
||||
- `enabled`: enable or disable the channel.
|
||||
- `name`: optional label for the account.
|
||||
- `defaultAccount`: preferred account ID when multiple Matrix accounts are configured.
|
||||
- `homeserver`: homeserver URL, for example `https://matrix.example.org`.
|
||||
- `userId`: full Matrix user ID, for example `@bot:example.org`.
|
||||
- `accessToken`: access token for token-based auth.
|
||||
- `password`: password for password-based login.
|
||||
- `deviceId`: explicit Matrix device ID.
|
||||
- `deviceName`: device display name for password login.
|
||||
- `avatarUrl`: stored self-avatar URL for profile sync and `set-profile` updates.
|
||||
- `initialSyncLimit`: startup sync event limit.
|
||||
- `encryption`: enable E2EE.
|
||||
- `allowlistOnly`: force allowlist-only behavior for DMs and rooms.
|
||||
- `groupPolicy`: `open`, `allowlist`, or `disabled`.
|
||||
- `groupAllowFrom`: allowlist of user IDs for room traffic.
|
||||
- `groupAllowFrom` entries should be full Matrix user IDs. Unresolved names are ignored at runtime.
|
||||
- `replyToMode`: `off`, `first`, or `all`.
|
||||
- `threadReplies`: `off`, `inbound`, or `always`.
|
||||
- `threadBindings`: per-channel overrides for thread-bound session routing and lifecycle.
|
||||
- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`).
|
||||
- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests.
|
||||
- `textChunkLimit`: outbound message chunk size.
|
||||
- `chunkMode`: `length` or `newline`.
|
||||
- `responsePrefix`: optional message prefix for outbound replies.
|
||||
- `ackReaction`: optional ack reaction override for this channel/account.
|
||||
- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`).
|
||||
- `reactionNotifications`: inbound reaction notification mode (`own`, `off`).
|
||||
- `mediaMaxMb`: outbound media size cap in MB.
|
||||
- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`). Default: `off`.
|
||||
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`. Alias entries are resolved to room IDs during invite handling; OpenClaw does not trust alias state claimed by the invited room.
|
||||
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`).
|
||||
- `dm.allowFrom` entries should be full Matrix user IDs unless you already resolved them through live directory lookup.
|
||||
- `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries.
|
||||
- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names.
|
||||
- `rooms`: legacy alias for `groups`.
|
||||
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `profile`, `memberInfo`, `channelInfo`, `verification`).
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set).
|
||||
- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!roomId:example.org": { allow: true },
|
||||
"#alias:example.org": { allow: true },
|
||||
},
|
||||
groupAllowFrom: ["@owner:example.org"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `requireMention: false` enables auto-reply in that room.
|
||||
- `groups."*"` can set defaults for mention gating across rooms.
|
||||
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs).
|
||||
- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs).
|
||||
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match.
|
||||
- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching.
|
||||
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
|
||||
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
|
||||
|
||||
## Threads
|
||||
|
||||
- Reply threading is supported.
|
||||
- `channels.matrix.threadReplies` controls whether replies stay in threads:
|
||||
- `off`, `inbound` (default), `always`
|
||||
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
|
||||
- `off` (default), `first`, `all`
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Feature | Status |
|
||||
| --------------- | ------------------------------------------------------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Rooms | ✅ Supported |
|
||||
| Threads | ✅ Supported |
|
||||
| Media | ✅ Supported |
|
||||
| E2EE | ✅ Supported (crypto module required) |
|
||||
| Reactions | ✅ Supported (send/read via tools) |
|
||||
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
|
||||
| Location | ✅ Supported (geo URI; altitude ignored) |
|
||||
| Native commands | ✅ Supported |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run this ladder first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Then confirm DM pairing state if needed:
|
||||
|
||||
```bash
|
||||
openclaw pairing list matrix
|
||||
```
|
||||
|
||||
Common failures:
|
||||
|
||||
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
|
||||
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
|
||||
- Encrypted rooms fail: crypto support or encryption settings mismatch.
|
||||
|
||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||
|
||||
## Configuration reference (Matrix)
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.matrix.enabled`: enable/disable channel startup.
|
||||
- `channels.matrix.homeserver`: homeserver URL.
|
||||
- `channels.matrix.userId`: Matrix user ID (optional with access token).
|
||||
- `channels.matrix.accessToken`: access token.
|
||||
- `channels.matrix.password`: password for login (token stored).
|
||||
- `channels.matrix.deviceName`: device display name.
|
||||
- `channels.matrix.encryption`: enable E2EE (default: false).
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs).
|
||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||
- `channels.matrix.groups`: group allowlist + per-room settings map.
|
||||
- `channels.matrix.rooms`: legacy group allowlist/config.
|
||||
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
|
||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
|
||||
- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings).
|
||||
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
|
||||
|
||||
@@ -28,7 +28,7 @@ Local checkout (when running from a git repo):
|
||||
openclaw plugins install ./extensions/mattermost
|
||||
```
|
||||
|
||||
If you choose Mattermost during setup and a git checkout is detected,
|
||||
If you choose Mattermost during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/tools/plugin)
|
||||
@@ -191,35 +191,6 @@ OpenClaw resolves them **user-first**:
|
||||
|
||||
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
|
||||
|
||||
## DM channel retry
|
||||
|
||||
When OpenClaw sends to a Mattermost DM target and needs to resolve the direct channel first, it
|
||||
retries transient direct-channel creation failures by default.
|
||||
|
||||
Use `channels.mattermost.dmChannelRetry` to tune that behavior globally for the Mattermost plugin,
|
||||
or `channels.mattermost.accounts.<id>.dmChannelRetry` for one account.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
dmChannelRetry: {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This applies only to DM channel creation (`/api/v4/channels/direct`), not every Mattermost API call.
|
||||
- Retries apply to transient failures such as rate limits, 5xx responses, and network or timeout errors.
|
||||
- 4xx client errors other than `429` are treated as permanent and are not retried.
|
||||
|
||||
## Reactions (message tool)
|
||||
|
||||
- Use `message action=react` with `channel=mattermost`.
|
||||
|
||||
@@ -33,7 +33,7 @@ Local checkout (when running from a git repo):
|
||||
openclaw plugins install ./extensions/msteams
|
||||
```
|
||||
|
||||
If you choose Teams during setup and a git checkout is detected,
|
||||
If you choose Teams during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
@@ -25,7 +25,7 @@ Local checkout (when running from a git repo):
|
||||
openclaw plugins install ./extensions/nextcloud-talk
|
||||
```
|
||||
|
||||
If you choose Nextcloud Talk during setup and a git checkout is detected,
|
||||
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/tools/plugin)
|
||||
@@ -43,7 +43,7 @@ Details: [Plugins](/tools/plugin)
|
||||
4. Configure OpenClaw:
|
||||
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`
|
||||
- Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only)
|
||||
5. Restart the gateway (or finish setup).
|
||||
5. Restart the gateway (or finish onboarding).
|
||||
|
||||
Minimal config:
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Nostr is a decentralized protocol for social networking. This channel enables Op
|
||||
|
||||
### Onboarding (recommended)
|
||||
|
||||
- Onboarding (`openclaw onboard`) and `openclaw channels add` list optional channel plugins.
|
||||
- The onboarding wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins.
|
||||
- Selecting Nostr prompts you to install the plugin on demand.
|
||||
|
||||
Install defaults:
|
||||
@@ -40,15 +40,6 @@ openclaw plugins install --link <path-to-openclaw>/extensions/nostr
|
||||
|
||||
Restart the Gateway after installing or enabling plugins.
|
||||
|
||||
### Non-interactive setup
|
||||
|
||||
```bash
|
||||
openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY"
|
||||
openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" --relay-urls "wss://relay.damus.io,wss://relay.primal.net"
|
||||
```
|
||||
|
||||
Use `--use-env` to keep `NOSTR_PRIVATE_KEY` in the environment instead of storing the key in config.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1. Generate a Nostr keypair (if needed):
|
||||
|
||||
@@ -27,17 +27,13 @@ Details: [Plugins](/tools/plugin)
|
||||
## Quick setup
|
||||
|
||||
1. Install and enable the Synology Chat plugin.
|
||||
- `openclaw onboard` now shows Synology Chat in the same channel setup list as `openclaw channels add`.
|
||||
- Non-interactive setup: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
|
||||
2. In Synology Chat integrations:
|
||||
- Create an incoming webhook and copy its URL.
|
||||
- Create an outgoing webhook with your secret token.
|
||||
3. Point the outgoing webhook URL to your OpenClaw gateway:
|
||||
- `https://gateway-host/webhook/synology` by default.
|
||||
- Or your custom `channels.synology-chat.webhookPath`.
|
||||
4. Finish setup in OpenClaw.
|
||||
- Guided: `openclaw onboard`
|
||||
- Direct: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
|
||||
4. Configure `channels.synology-chat` in OpenClaw.
|
||||
5. Restart gateway and send a DM to the Synology Chat bot.
|
||||
|
||||
Minimal config:
|
||||
|
||||
@@ -115,7 +115,7 @@ Token resolution order is account-aware. In practice, config values win over env
|
||||
|
||||
`channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized.
|
||||
`dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation.
|
||||
Onboarding accepts `@username` input and resolves it to numeric IDs.
|
||||
The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
|
||||
If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
|
||||
If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet).
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
### Bot does not respond to messages
|
||||
### Bot doesn't respond to messages
|
||||
|
||||
**Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove
|
||||
`allowFrom` and set `allowedRoles: ["all"]` to test.
|
||||
|
||||
@@ -9,21 +9,6 @@ title: "WhatsApp"
|
||||
|
||||
Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s).
|
||||
|
||||
## Install (on demand)
|
||||
|
||||
- Onboarding (`openclaw onboard`) and `openclaw channels add --channel whatsapp`
|
||||
prompt to install the WhatsApp plugin the first time you select it.
|
||||
- `openclaw channels login --channel whatsapp` also offers the install flow when
|
||||
the plugin is not present yet.
|
||||
- Dev channel + git checkout: defaults to the local plugin path.
|
||||
- Stable/Beta: defaults to the npm package `@openclaw/whatsapp`.
|
||||
|
||||
Manual install stays available:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/whatsapp
|
||||
```
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
Default DM policy is pairing for unknown senders.
|
||||
@@ -91,7 +76,7 @@ openclaw pairing approve whatsapp <CODE>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and setup flow are optimized for that setup, but personal-number setups are also supported.)
|
||||
OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and onboarding flow are optimized for that setup, but personal-number setups are also supported.)
|
||||
</Note>
|
||||
|
||||
## Deployment patterns
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user