Merge branch 'main' into codex/cortex-openclaw-integration

This commit is contained in:
Marc J Saint-jour 2026-03-19 20:46:51 -04:00 committed by GitHub
commit f76af1258a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1102 changed files with 78939 additions and 19416 deletions

View File

@ -0,0 +1,87 @@
---
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.

View File

@ -0,0 +1,58 @@
---
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.

View File

@ -0,0 +1,75 @@
---
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.

View File

@ -0,0 +1,74 @@
---
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.

View File

@ -0,0 +1,71 @@
---
name: openclaw-test-heap-leaks
description: Investigate `pnpm test` memory growth, Vitest worker OOMs, and suspicious RSS increases in OpenClaw using the `scripts/test-parallel.mjs` heap snapshot tooling. Use when Codex needs to reproduce test-lane memory growth, collect repeated `.heapsnapshot` files, compare snapshots from the same worker PID, distinguish transformed-module retention from real data leaks, and fix or reduce the impact by patching cleanup logic or isolating hotspot tests.
---
# OpenClaw Test Heap Leaks
Use this skill for test-memory investigations. Do not guess from RSS alone when heap snapshots are available.
## Workflow
1. Reproduce the failing shape first.
- Match the real entrypoint if possible. For Linux CI-style unit failures, start with:
- `pnpm canvas:a2ui:bundle && OPENCLAW_TEST_MEMORY_TRACE=1 OPENCLAW_TEST_HEAPSNAPSHOT_INTERVAL_MS=60000 OPENCLAW_TEST_HEAPSNAPSHOT_DIR=.tmp/heapsnap OPENCLAW_TEST_WORKERS=2 OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144 pnpm test`
- Keep `OPENCLAW_TEST_MEMORY_TRACE=1` enabled so the wrapper prints per-file RSS summaries alongside the snapshots.
- If the report is about a specific shard or worker budget, preserve that shape.
2. Wait for repeated snapshots before concluding anything.
- Take at least two intervals from the same lane.
- Compare snapshots from the same PID inside one lane directory such as `.tmp/heapsnap/unit-fast/`.
- Use `scripts/heapsnapshot-delta.mjs` to compare either two files directly or the earliest/latest pair per PID in one lane directory.
3. Classify the growth before choosing a fix.
- If growth is dominated by Vite/Vitest transformed source strings, `Module`, `system / Context`, bytecode, descriptor arrays, or property maps, treat it as retained module graph growth in long-lived workers.
- If growth is dominated by app objects, caches, buffers, server handles, timers, mock state, sqlite state, or similar runtime objects, treat it as a likely cleanup or lifecycle leak.
4. Fix the right layer.
- For retained transformed-module growth in shared workers:
- Move hotspot files out of `unit-fast` by updating `test/fixtures/test-parallel.behavior.json`.
- Prefer `singletonIsolated` for files that are safe alone but inflate shared worker heaps.
- If the file should already have been peeled out by timings but is absent from `test/fixtures/test-timings.unit.json`, call that out explicitly. Missing timings are a scheduling blind spot.
- For real leaks:
- Patch the implicated test or runtime cleanup path.
- Look for missing `afterEach`/`afterAll`, module-reset gaps, retained global state, unreleased DB handles, or listeners/timers that survive the file.
5. Verify with the most direct proof.
- Re-run the targeted lane or file with heap snapshots enabled if the suite still finishes in reasonable time.
- If snapshot overhead pushes tests over Vitest timeouts, fall back to the same lane without snapshots and confirm the RSS trend or OOM is reduced.
- For wrapper-only changes, at minimum verify the expected lanes start and the snapshot files are written.
## Heuristics
- Do not call everything a leak. In this repo, large `unit-fast` growth can be a worker-lifetime problem rather than an application object leak.
- `scripts/test-parallel.mjs` and `scripts/test-parallel-memory.mjs` are the primary control points for wrapper diagnostics.
- The lane names printed by `[test-parallel] start ...` and `[test-parallel][mem] summary ...` tell you where to focus.
- When one or two files account for most of the delta and they are missing from timings, reducing impact by isolating them is usually the first pragmatic fix.
- When the same retained object families grow across multiple intervals in the same worker PID, trust the snapshots over intuition.
## Snapshot Comparison
- Direct comparison:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs before.heapsnapshot after.heapsnapshot`
- Auto-select earliest/latest snapshots per PID within one lane:
- `node .agents/skills/openclaw-test-heap-leaks/scripts/heapsnapshot-delta.mjs --lane-dir .tmp/heapsnap/unit-fast`
- Useful flags:
- `--top 40`
- `--min-kb 32`
- `--pid 16133`
Read the top positive deltas first. Large positive growth in module-transform artifacts suggests lane isolation; large positive growth in runtime objects suggests a real leak.
## Output Expectations
When using this skill, report:
- The exact reproduce command.
- Which lane and PID were compared.
- The dominant retained object families from the snapshot delta.
- Whether the issue is a real leak or shared-worker retained module growth.
- The concrete fix or impact-reduction patch.
- What you verified, and what snapshot overhead prevented you from verifying.

View File

@ -0,0 +1,4 @@
interface:
display_name: "Test Heap Leaks"
short_description: "Investigate test OOMs with heap snapshots"
default_prompt: "Use $openclaw-test-heap-leaks to investigate test memory growth with heap snapshots and reduce its impact."

View File

@ -0,0 +1,265 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
function printUsage() {
console.error(
"Usage: node heapsnapshot-delta.mjs <before.heapsnapshot> <after.heapsnapshot> [--top N] [--min-kb N]",
);
console.error(
" or: node heapsnapshot-delta.mjs --lane-dir <dir> [--pid PID] [--top N] [--min-kb N]",
);
}
function fail(message) {
console.error(message);
process.exit(1);
}
function parseArgs(argv) {
const options = {
top: 30,
minKb: 64,
laneDir: null,
pid: null,
files: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--top") {
options.top = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
if (arg === "--min-kb") {
options.minKb = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
if (arg === "--lane-dir") {
options.laneDir = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === "--pid") {
options.pid = Number.parseInt(argv[index + 1] ?? "", 10);
index += 1;
continue;
}
options.files.push(arg);
}
if (!Number.isFinite(options.top) || options.top <= 0) {
fail("--top must be a positive integer");
}
if (!Number.isFinite(options.minKb) || options.minKb < 0) {
fail("--min-kb must be a non-negative integer");
}
if (options.pid !== null && (!Number.isInteger(options.pid) || options.pid <= 0)) {
fail("--pid must be a positive integer");
}
return options;
}
function parseHeapFilename(filePath) {
const base = path.basename(filePath);
const match = base.match(
/^Heap\.(?<stamp>\d{8}\.\d{6})\.(?<pid>\d+)\.0\.(?<seq>\d+)\.heapsnapshot$/u,
);
if (!match?.groups) {
return null;
}
return {
filePath,
pid: Number.parseInt(match.groups.pid, 10),
stamp: match.groups.stamp,
sequence: Number.parseInt(match.groups.seq, 10),
};
}
function resolvePair(options) {
if (options.laneDir) {
const entries = fs
.readdirSync(options.laneDir)
.map((name) => parseHeapFilename(path.join(options.laneDir, name)))
.filter((entry) => entry !== null)
.filter((entry) => options.pid === null || entry.pid === options.pid)
.toSorted((left, right) => {
if (left.pid !== right.pid) {
return left.pid - right.pid;
}
if (left.stamp !== right.stamp) {
return left.stamp.localeCompare(right.stamp);
}
return left.sequence - right.sequence;
});
if (entries.length === 0) {
fail(`No matching heap snapshots found in ${options.laneDir}`);
}
const groups = new Map();
for (const entry of entries) {
const group = groups.get(entry.pid) ?? [];
group.push(entry);
groups.set(entry.pid, group);
}
const candidates = Array.from(groups.values())
.map((group) => ({
pid: group[0].pid,
before: group[0],
after: group.at(-1),
count: group.length,
}))
.filter((entry) => entry.count >= 2);
if (candidates.length === 0) {
fail(`Need at least two snapshots for one PID in ${options.laneDir}`);
}
const chosen =
options.pid !== null
? (candidates.find((entry) => entry.pid === options.pid) ?? null)
: candidates.toSorted((left, right) => right.count - left.count || left.pid - right.pid)[0];
if (!chosen) {
fail(`No PID with at least two snapshots matched in ${options.laneDir}`);
}
return {
before: chosen.before.filePath,
after: chosen.after.filePath,
pid: chosen.pid,
snapshotCount: chosen.count,
};
}
if (options.files.length !== 2) {
printUsage();
process.exit(1);
}
return {
before: options.files[0],
after: options.files[1],
pid: null,
snapshotCount: 2,
};
}
function loadSummary(filePath) {
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
const meta = data.snapshot?.meta;
if (!meta) {
fail(`Invalid heap snapshot: ${filePath}`);
}
const nodeFieldCount = meta.node_fields.length;
const typeNames = meta.node_types[0];
const strings = data.strings;
const typeIndex = meta.node_fields.indexOf("type");
const nameIndex = meta.node_fields.indexOf("name");
const selfSizeIndex = meta.node_fields.indexOf("self_size");
const summary = new Map();
for (let offset = 0; offset < data.nodes.length; offset += nodeFieldCount) {
const type = typeNames[data.nodes[offset + typeIndex]];
const name = strings[data.nodes[offset + nameIndex]];
const selfSize = data.nodes[offset + selfSizeIndex];
const key = `${type}\t${name}`;
const current = summary.get(key) ?? {
type,
name,
selfSize: 0,
count: 0,
};
current.selfSize += selfSize;
current.count += 1;
summary.set(key, current);
}
return {
nodeCount: data.snapshot.node_count,
summary,
};
}
function formatBytes(bytes) {
if (Math.abs(bytes) >= 1024 ** 2) {
return `${(bytes / 1024 ** 2).toFixed(2)} MiB`;
}
if (Math.abs(bytes) >= 1024) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
return `${bytes} B`;
}
function formatDelta(bytes) {
return `${bytes >= 0 ? "+" : "-"}${formatBytes(Math.abs(bytes))}`;
}
function truncate(text, maxLength) {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}`;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const pair = resolvePair(options);
const before = loadSummary(pair.before);
const after = loadSummary(pair.after);
const minBytes = options.minKb * 1024;
const rows = [];
for (const [key, next] of after.summary) {
const previous = before.summary.get(key) ?? { selfSize: 0, count: 0 };
const sizeDelta = next.selfSize - previous.selfSize;
const countDelta = next.count - previous.count;
if (sizeDelta < minBytes) {
continue;
}
rows.push({
type: next.type,
name: next.name,
sizeDelta,
countDelta,
afterSize: next.selfSize,
afterCount: next.count,
});
}
rows.sort(
(left, right) => right.sizeDelta - left.sizeDelta || right.countDelta - left.countDelta,
);
console.log(`before: ${pair.before}`);
console.log(`after: ${pair.after}`);
if (pair.pid !== null) {
console.log(`pid: ${pair.pid} (${pair.snapshotCount} snapshots found)`);
}
console.log(
`nodes: ${before.nodeCount} -> ${after.nodeCount} (${after.nodeCount - before.nodeCount >= 0 ? "+" : ""}${after.nodeCount - before.nodeCount})`,
);
console.log(`filter: top=${options.top} min=${options.minKb} KiB`);
console.log("");
if (rows.length === 0) {
console.log("No entries exceeded the minimum delta.");
return;
}
for (const row of rows.slice(0, options.top)) {
console.log(
[
formatDelta(row.sizeDelta).padStart(11),
`count ${row.countDelta >= 0 ? "+" : ""}${row.countDelta}`.padStart(10),
row.type.padEnd(16),
truncate(row.name || "(empty)", 96),
].join(" "),
);
}
}
main();

View File

@ -1,7 +1,7 @@
.git
.worktrees
# Sensitive files docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
# Sensitive files scripts/docker/setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN
# into the project root; keep it out of the build context.
.env
.env.*

View File

@ -7,7 +7,8 @@ body:
- type: markdown
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
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`.
- type: dropdown
id: bug_type
attributes:
@ -23,35 +24,35 @@ body:
id: summary
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
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.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest deterministic repro path.
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`.
placeholder: |
1. Configure channel X.
2. Send message Y.
3. Run command Z.
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.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen if the bug does not exist.
placeholder: Agent posts a reply in the same thread.
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.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead, including user-visible errors.
placeholder: No reply is posted; gateway logs "reply target not found".
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.
validations:
required: true
- type: input
@ -92,12 +93,6 @@ 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:
@ -111,27 +106,28 @@ body:
id: logs
attributes:
label: Logs, screenshots, and evidence
description: Include redacted logs/screenshots/recordings that prove the behavior.
description: Include the redacted logs, screenshots, recordings, docs, or version comparisons that support the grounded answers above.
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.
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`.
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 <version>
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
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
- type: textarea
id: additional_information
attributes:
label: Additional information
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 ...
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.

3
.github/labeler.yml vendored
View File

@ -165,7 +165,10 @@
- "Dockerfile.*"
- "docker-compose.yml"
- "docker-setup.sh"
- "setup-podman.sh"
- ".dockerignore"
- "scripts/docker/setup.sh"
- "scripts/podman/setup.sh"
- "scripts/**/*docker*"
- "scripts/**/Dockerfile*"
- "scripts/sandbox-*.sh"

View File

@ -496,7 +496,9 @@ jobs:
run: pnpm test
- name: Verify npm pack under Node 22
run: pnpm release:check
run: |
node scripts/stage-bundled-plugin-runtime-deps.mjs
node --import tsx scripts/release-check.ts
skills-python:
needs: [docs-scope, changed-scope]

View File

@ -62,24 +62,65 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
# 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.
# This smoke validates that the build-arg path preinstalls the matrix
# runtime deps declared by the plugin and that matrix discovery stays
# healthy in the final runtime image.
- name: Build extension Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
context: .
file: ./Dockerfile
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel
OPENCLAW_EXTENSIONS=matrix
tags: openclaw-ext-smoke:local
load: true
push: false
provenance: false
- name: Smoke test Dockerfile with extension build arg
- name: Smoke test Dockerfile with matrix extension build arg
run: |
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version'
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
which openclaw &&
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
if (runtimeDeps.length === 0) {
throw new Error(
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
);
}
for (const dep of runtimeDeps) {
requireFromMatrix.resolve(dep);
}
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(\"; \"),
);
}
"
'
- name: Build installer smoke image
uses: useblacksmith/build-push-action@v2

3
.gitignore vendored
View File

@ -31,6 +31,7 @@ apps/android/.gradle/
apps/android/app/build/
apps/android/.cxx/
apps/android/.kotlin/
apps/android/benchmark/results/
# Bun build artifacts
*.bun-build
@ -100,8 +101,6 @@ USER.md
/local/
package-lock.json
.claude/
.agents/
.agents
.agent/
!.agent/
!.agent/workflows/

195
AGENTS.md
View File

@ -2,45 +2,8 @@
- 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: dont 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`).
@ -48,6 +11,7 @@
- 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/`
@ -106,15 +70,24 @@
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
- Preferred landing bar for pushes to `main`: `pnpm check` and `pnpm test`, with a green result when feasible.
- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default.
- 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`.
- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface.
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
- Do not use scoped tests as permission to ignore plausibly related failures.
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
- Formatting/linting via Oxlint and Oxfmt.
- 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.
@ -124,18 +97,18 @@
- 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 Channels (Naming)
## Release / Advisory Workflows
- 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).
- 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.
## 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.
@ -149,7 +122,9 @@
## Commit & Pull Request Guidelines
**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.
- 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`.
- `/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.
@ -158,105 +133,30 @@
- 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; use `docs/reference/RELEASING.md` for the public release policy.
- 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.
## 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
## Local Runtime / Platform Notes
- Vocabulary: "makeup" = "mac app".
- 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.
- Discord roundtrip smoke is opt-in. Pass `--discord-token-env <VAR> --discord-guild-id <guild> --discord-channel-id <channel>`; the harness will configure Discord in-guest, post a guest message, verify host-side visibility via the Discord REST API, post a fresh host-side message back into the channel, then verify `openclaw message read` sees it in-guest.
- Keep the Discord token in a host env var only. For Peters Mac Studio bot, fetch it into a temp env var from `~/.openclaw/openclaw.json` over SSH instead of hardcoding it in repo files/shell history.
- For Discord smoke on this snapshot: use `openclaw message send/read` via the installed wrapper, not `node openclaw.mjs message ...`; lazy `message` subcommands do not resolve the same way through the direct module entrypoint.
- For Discord guild allowlists: set `channels.discord.guilds` as one JSON object. Do not use dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated as array indexes.
- Avoid `prlctl enter` / expect for the Discord config phase; long lines get mangled. Use `prlctl exec --current-user /bin/sh -lc ...` with short commands or temp files.
- 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.
- Dont 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.
- 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.
- 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); dont 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.**
@ -271,6 +171,20 @@
- 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}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps 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.
@ -281,41 +195,12 @@
- 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 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.
- **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}`. Dont add extra quotes.
- launchd PATH is minimal; ensure the apps 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 tools escaping.
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## 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.

View File

@ -23,7 +23,8 @@ Docs: https://docs.openclaw.ai
- 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.
- 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 @lxk7280.
- 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.
@ -46,10 +47,12 @@ Docs: https://docs.openclaw.ai
### 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.
- 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.
@ -89,6 +92,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Onboarding/custom providers: store Azure OpenAI and Azure AI Foundry custom endpoints with the Responses API config shape, normalized `/openai/v1` base URLs, and Azure-safe defaults so TUI and agent runs work after setup. (#49543) Thanks @kunalk16.
- 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.
@ -114,6 +118,7 @@ Docs: https://docs.openclaw.ai
- 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/WS handshake: raise the default pre-auth handshake timeout to 10 seconds and add `OPENCLAW_HANDSHAKE_TIMEOUT_MS` as a runtime override so busy local gateways stop dropping healthy CLI connections at 3 seconds. (#49262) Thanks @fuller-stack-dev.
- 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.
@ -131,6 +136,10 @@ Docs: https://docs.openclaw.ai
- 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.
- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
### Fixes
@ -153,6 +162,12 @@ Docs: https://docs.openclaw.ai
- 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.
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus.
- Plugins/update: let `openclaw plugins update <npm-spec>` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo.
- Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo.
### Breaking
@ -164,6 +179,7 @@ Docs: https://docs.openclaw.ai
- 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.
## 2026.3.13
@ -214,6 +230,7 @@ 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.
@ -240,6 +257,7 @@ Docs: https://docs.openclaw.ai
- 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

View File

@ -146,6 +146,10 @@ 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.

View File

@ -49,7 +49,7 @@ Model note: while many providers/models are supported, for the best experience a
## Install (recommended)
Runtime: **Node 22**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
```bash
npm install -g openclaw@latest
@ -62,7 +62,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 22**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)

View File

@ -176,6 +176,45 @@ 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.

View File

@ -12,6 +12,7 @@
<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

View File

@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun setForeground(value: Boolean) {
foreground = value
runtimeRef.value?.setForeground(value)
val runtime =
if (value && prefs.onboardingCompleted.value) {
ensureRuntime()
} else {
runtimeRef.value
}
runtime?.setForeground(value)
}
fun setDisplayName(value: String) {

View File

@ -137,7 +137,8 @@ class NodeRuntime(
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
@ -160,7 +161,8 @@ class NodeRuntime(
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {
@ -566,43 +568,8 @@ class NodeRuntime(
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
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)
seedLastDiscoveredGateway(list)
autoConnectIfNeeded()
}
}
@ -627,11 +594,53 @@ class NodeRuntime(
fun setForeground(value: Boolean) {
_isForeground.value = value
if (!value) {
if (value) {
reconnectPreferredGatewayOnForeground()
} else {
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)
}

View File

@ -17,7 +17,8 @@ class ConnectionManager(
private val voiceWakeMode: () -> VoiceWakeMode,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@ -78,7 +79,8 @@ class ConnectionManager(
NodeRuntimeFlags(
cameraEnabled = cameraEnabled(),
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),

View File

@ -18,7 +18,8 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
val smsAvailable: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@ -29,7 +30,8 @@ enum class InvokeCommandAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
SendSmsAvailable,
ReadSmsAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@ -187,7 +189,11 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
availability = InvokeCommandAvailability.SendSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
@ -213,7 +219,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.Always -> true
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@ -228,7 +234,8 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.Always -> true
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@ -32,7 +32,8 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
@ -162,6 +163,7 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
@ -256,8 +258,17 @@ class InvokeDispatcher(
message = "PEDOMETER_UNAVAILABLE: step counter not available",
)
}
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
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()) {
null
} else {
GatewaySession.InvokeResult.error(

View File

@ -8,27 +8,85 @@ import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
class LocationHandler(
internal interface LocationDataSource {
fun hasFinePermission(context: Context): Boolean
fun hasCoarsePermission(context: Context): Boolean
suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload
}
private class DefaultLocationDataSource(
private val capture: LocationCaptureManager,
) : LocationDataSource {
override fun hasFinePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
override fun hasCoarsePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
override suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload =
capture.getLocation(
desiredProviders = desiredProviders,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,
isPrecise = isPrecise,
)
}
class LocationHandler private constructor(
private val appContext: Context,
private val location: LocationCaptureManager,
private val dataSource: LocationDataSource,
private val json: Json,
private val isForeground: () -> Boolean,
private val locationPreciseEnabled: () -> Boolean,
) {
fun hasFineLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
constructor(
appContext: Context,
location: LocationCaptureManager,
json: Json,
isForeground: () -> Boolean,
locationPreciseEnabled: () -> Boolean,
) : this(
appContext = appContext,
dataSource = DefaultLocationDataSource(location),
json = json,
isForeground = isForeground,
locationPreciseEnabled = locationPreciseEnabled,
)
fun hasCoarseLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
companion object {
internal fun forTesting(
appContext: Context,
dataSource: LocationDataSource,
json: Json = Json { ignoreUnknownKeys = true },
isForeground: () -> Boolean = { true },
locationPreciseEnabled: () -> Boolean = { true },
): LocationHandler =
LocationHandler(
appContext = appContext,
dataSource = dataSource,
json = json,
isForeground = isForeground,
locationPreciseEnabled = locationPreciseEnabled,
)
}
@ -39,7 +97,7 @@ class LocationHandler(
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
@ -49,9 +107,9 @@ class LocationHandler(
val preciseEnabled = locationPreciseEnabled()
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
"coarse" -> "coarse"
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
}
val providers =
when (accuracy) {
@ -61,7 +119,7 @@ class LocationHandler(
}
try {
val payload =
location.getLocation(
dataSource.fetchLocation(
desiredProviders = providers,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,

View File

@ -16,4 +16,16 @@ 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)
}
}
}

View File

@ -3,19 +3,27 @@ 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.encodeToString
import kotlinx.serialization.Serializable
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) {
@ -30,6 +38,30 @@ 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,
@ -44,12 +76,30 @@ 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 {
@ -88,6 +138,52 @@ 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>,
@ -112,6 +208,25 @@ 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 {
@ -121,10 +236,28 @@ 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
}
@ -208,6 +341,20 @@ 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,
@ -227,4 +374,240 @@ 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
}
}

View File

@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
Search("sms.search"),
;
companion object {

View File

@ -1,7 +1,7 @@
package ai.openclaw.app.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -20,6 +20,7 @@ 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
@ -49,6 +50,7 @@ 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
@ -60,6 +62,7 @@ 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()
@ -134,7 +137,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
}
val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway"
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
val statusLabel = gatewayStatusForDisplay(statusText)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
@ -279,6 +283,46 @@ 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),

View File

@ -0,0 +1,77 @@
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()
}

View File

@ -9,6 +9,7 @@ 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
@ -60,6 +61,7 @@ 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
@ -287,7 +289,11 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
var enableSms by
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
mutableStateOf(
smsAvailable &&
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS)
)
}
var enableCallLog by
rememberSaveable {
@ -336,7 +342,9 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
!motionPermissionRequired ||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
!smsAvailable ||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS))
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
@ -698,7 +706,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
requestPermissionToggle(
PermissionToggle.Sms,
checked,
listOf(Manifest.permission.SEND_SMS),
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
)
}
},
@ -1437,9 +1445,11 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "SMS",
subtitle = "Send text messages via the gateway",
subtitle = "Send and search text messages via the gateway",
checked = enableSms,
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
granted =
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS),
onCheckedChange = onSmsChange,
)
}
@ -1511,6 +1521,12 @@ 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)
@ -1523,7 +1539,7 @@ private fun FinalStep(
SummaryCard(
icon = Icons.Default.Cloud,
label = "Gateway",
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
value = gatewayAddress,
accentColor = Color(0xFF7C5AC7),
)
SummaryCard(
@ -1607,7 +1623,7 @@ private fun FinalStep(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingWarningSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
border = BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
) {
Column(
modifier = Modifier.padding(14.dp),
@ -1632,13 +1648,66 @@ private fun FinalStep(
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
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,
)
}
}
CommandBlock("openclaw devices list")
CommandBlock("openclaw devices approve <requestId>")
Text("Then tap Connect again.", 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)
}
}
}
}

View File

@ -247,12 +247,16 @@ 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.RequestPermission()) { granted ->
smsPermissionGranted = granted
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
viewModel.refreshGatewayConnection()
}
@ -287,6 +291,8 @@ 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
}
}
@ -507,7 +513,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
colors = listItemColors,
headlineContent = { Text("SMS", style = mobileHeadline) },
supportingContent = {
Text("Send SMS from this device.", style = mobileCallout)
Text("Send and search SMS from this device.", style = mobileCallout)
},
trailingContent = {
Button(
@ -515,7 +521,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
}
},
colors = settingsPrimaryButtonColors(),

View File

@ -64,6 +64,7 @@ class InvokeCommandRegistryTest {
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
OpenClawSmsCommand.Search.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@ -83,7 +84,8 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@ -108,7 +110,8 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
@ -125,7 +128,8 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
sendSmsAvailable = false,
readSmsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
@ -137,10 +141,43 @@ 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,
smsAvailable: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@ -149,7 +186,8 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,

View File

@ -0,0 +1,88 @@
package ai.openclaw.app.node
import android.content.Context
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LocationHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = false,
coarseGranted = false,
),
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleLocationGet_requiresForegroundBeforeLocationPermission() =
runTest {
val handler =
LocationHandler.forTesting(
appContext = appContext(),
dataSource =
FakeLocationDataSource(
fineGranted = true,
coarseGranted = true,
),
isForeground = { false },
)
val result = handler.handleLocationGet(null)
assertFalse(result.ok)
assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code)
}
@Test
fun hasFineLocationPermission_reflectsDataSource() {
val denied =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true),
)
assertFalse(denied.hasFineLocationPermission())
assertTrue(denied.hasCoarseLocationPermission())
val granted =
LocationHandler.forTesting(
appContext = appContext(),
dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false),
)
assertTrue(granted.hasFineLocationPermission())
assertFalse(granted.hasCoarseLocationPermission())
}
}
private class FakeLocationDataSource(
private val fineGranted: Boolean,
private val coarseGranted: Boolean,
) : LocationDataSource {
override fun hasFinePermission(context: Context): Boolean = fineGranted
override fun hasCoarsePermission(context: Context): Boolean = coarseGranted
override suspend fun fetchLocation(
desiredProviders: List<String>,
maxAgeMs: Long?,
timeoutMs: Long,
isPrecise: Boolean,
): LocationCaptureManager.Payload {
throw IllegalStateException(
"LocationHandlerTest: fetchLocation must not run in this scenario",
)
}
}

View File

@ -88,4 +88,95 @@ 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)
}
}

View File

@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

View File

@ -0,0 +1,430 @@
#!/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"

View File

@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowAlwaysPatterns: [String]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
command: command,
rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
rawCommand: allowlistRawCommand,
cwd: cwd,
env: env)
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env)
let allowlistMatches = security == .allowlist
@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowAlwaysPatterns: allowAlwaysPatterns,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,

View File

@ -378,7 +378,7 @@ private enum ExecHostExecutor {
let context = await self.buildContext(
request: request,
command: validatedRequest.command,
rawCommand: validatedRequest.displayCommand)
rawCommand: validatedRequest.evaluationRawCommand)
switch ExecHostRequestEvaluator.evaluate(
context: context,
@ -476,13 +476,7 @@ private enum ExecHostExecutor {
{
guard decision == .allowAlways, context.security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in context.allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: candidate)
else {
continue
}
for pattern in context.allowAlwaysPatterns {
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
}

View File

@ -52,6 +52,23 @@ struct ExecCommandResolution {
return [resolution]
}
static func resolveAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?) -> [String]
{
var patterns: [String] = []
var seen = Set<String>()
self.collectAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env,
depth: 0,
patterns: &patterns,
seen: &seen)
return patterns
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
@ -101,6 +118,115 @@ struct ExecCommandResolution {
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func collectAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
depth: Int,
patterns: inout [String],
seen: inout Set<String>)
{
guard depth < 3, !command.isEmpty else {
return
}
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
ExecCommandToken.basenameLower(token0) == "env",
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
!envUnwrapped.isEmpty
{
self.collectAllowAlwaysPatterns(
command: envUnwrapped,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
self.collectAllowAlwaysPatterns(
command: shellMultiplexer,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
return
}
for segment in segments {
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else {
continue
}
self.collectAllowAlwaysPatterns(
command: tokens,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
}
return
}
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
seen.insert(pattern).inserted
else {
return
}
patterns.append(pattern)
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return nil
}
let wrapper = ExecCommandToken.basenameLower(token0)
guard wrapper == "busybox" || wrapper == "toybox" else {
return nil
}
var appletIndex = 1
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
return nil
}
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !applet.isEmpty else {
return nil
}
let normalizedApplet = ExecCommandToken.basenameLower(applet)
let shellWrappers = Set([
"ash",
"bash",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
guard shellWrappers.contains(normalizedApplet) else {
return nil
}
return Array(argv[appletIndex...])
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

View File

@ -12,14 +12,24 @@ enum ExecCommandToken {
enum ExecEnvInvocationUnwrapper {
static let maxWrapperDepth = 4
struct UnwrapResult {
let command: [String]
let usesModifiers: Bool
}
private static func isEnvAssignment(_ token: String) -> Bool {
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
return token.range(of: pattern, options: .regularExpression) != nil
}
static func unwrap(_ command: [String]) -> [String]? {
self.unwrapWithMetadata(command)?.command
}
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
var idx = 1
var expectsOptionValue = false
var usesModifiers = false
while idx < command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
}
if expectsOptionValue {
expectsOptionValue = false
usesModifiers = true
idx += 1
continue
}
@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
break
}
if self.isEnvAssignment(token) {
usesModifiers = true
idx += 1
continue
}
@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
}
@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
lower.hasPrefix("--ignore-signal=") ||
lower.hasPrefix("--block-signal=")
{
usesModifiers = true
idx += 1
continue
}
@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
}
break
}
guard idx < command.count else { return nil }
return Array(command[idx...])
guard !expectsOptionValue, idx < command.count else { return nil }
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
}
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
break
}
current = unwrapped
if unwrapped.usesModifiers {
break
}
current = unwrapped.command
depth += 1
}
return current

View File

@ -3,6 +3,7 @@ import Foundation
struct ExecHostValidatedRequest {
let command: [String]
let displayCommand: String
let evaluationRawCommand: String?
}
enum ExecHostPolicyDecision {
@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
rawCommand: request.rawCommand)
switch validatedCommand {
case let .ok(resolved):
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
return .success(ExecHostValidatedRequest(
command: command,
displayCommand: resolved.displayCommand,
evaluationRawCommand: resolved.evaluationRawCommand))
case let .invalid(message):
return .failure(
ExecHostError(

View File

@ -3,6 +3,7 @@ import Foundation
enum ExecSystemRunCommandValidator {
struct ResolvedCommand {
let displayCommand: String
let evaluationRawCommand: String?
}
enum ValidationResult {
@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
let formattedArgv = ExecCommandFormatter.displayString(for: command)
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
ExecCommandFormatter.displayString(for: command)
nil
}
if let raw = normalizedRaw, raw != inferred {
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
}
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
return .ok(ResolvedCommand(
displayCommand: formattedArgv,
evaluationRawCommand: self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: previewCommand)))
}
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
let normalizedRaw = self.normalizeRaw(rawCommand)
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
nil
}
return self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: previewCommand)
}
private static func normalizeRaw(_ rawCommand: String?) -> String? {
@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
return trimmed.isEmpty ? nil : trimmed
}
private static func allowlistEvaluationRawCommand(
normalizedRaw: String?,
shellIsWrapper: Bool,
previewCommand: String?) -> String?
{
guard shellIsWrapper else {
return normalizedRaw
}
guard let normalizedRaw else {
return nil
}
return normalizedRaw == previewCommand ? normalizedRaw : nil
}
private static func normalizeExecutableToken(_ token: String) -> String {
let base = ExecCommandToken.basenameLower(token)
if base.hasSuffix(".exe") {

View File

@ -507,8 +507,7 @@ actor MacNodeRuntime {
persistAllowlist: persistAllowlist,
security: evaluation.security,
agentId: evaluation.agentId,
command: command,
allowlistResolutions: evaluation.allowlistResolutions)
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
await self.emitExecEvent(
@ -795,15 +794,11 @@ extension MacNodeRuntime {
persistAllowlist: Bool,
security: ExecSecurity,
agentId: String?,
command: [String],
allowlistResolutions: [ExecCommandResolution])
allowAlwaysPatterns: [String])
{
guard persistAllowlist, security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
continue
}
for pattern in allowAlwaysPatterns {
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}

View File

@ -1330,6 +1330,124 @@ 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?

View File

@ -45,7 +45,7 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try makeExecutableForTests(at: scriptPath)

View File

@ -240,7 +240,7 @@ struct ExecAllowlistTests {
#expect(resolutions[0].executableName == "touch")
}
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@ -248,11 +248,11 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
#expect(resolutions[0].executableName == "touch")
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@ -260,8 +260,33 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: rawCommand,
cwd: nil,
envOverrides: ["PATH": "/usr/bin:/bin"],
agentId: nil)
#expect(evaluation.displayCommand == rawCommand)
#expect(evaluation.allowlistResolutions.count == 1)
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
}
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(patterns == ["/usr/bin/printf"])
}
@Test func `match all requires every segment to match`() {

View File

@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
try await self.withTempStateDir { _ in
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstWriteDate = try Self.modificationDate(at: url)
let firstIdentity = try Self.fileIdentity(at: url)
try await Task.sleep(nanoseconds: 1_100_000_000)
_ = ExecApprovalsStore.ensureFile()
let secondWriteDate = try Self.modificationDate(at: url)
let secondIdentity = try Self.fileIdentity(at: url)
#expect(firstWriteDate == secondWriteDate)
#expect(firstIdentity == secondIdentity)
}
}
@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private static func modificationDate(at url: URL) throws -> Date {
private static func fileIdentity(at url: URL) throws -> Int {
let attributes = try FileManager().attributesOfItem(atPath: url.path)
guard let date = attributes[.modificationDate] as? Date else {
struct MissingDateError: Error {}
throw MissingDateError()
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
struct MissingIdentifierError: Error {}
throw MissingIdentifierError()
}
return date
return identifier
}
}

View File

@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
env: [:],
resolution: nil,
allowlistResolutions: [],
allowAlwaysPatterns: [],
allowlistMatches: [],
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: nil,

View File

@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
}
}
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
switch result {
case let .ok(resolved):
#expect(resolved.displayCommand == rawCommand)
#expect(resolved.evaluationRawCommand == nil)
case let .invalid(message):
Issue.record("unexpected invalid result: \(message)")
}
}
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
let fixtureURL = try self.findContractFixtureURL()
let data = try Data(contentsOf: fixtureURL)

View File

@ -1330,6 +1330,124 @@ 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?

View File

@ -16,7 +16,7 @@ services:
## Uncomment the lines below to enable sandbox isolation
## (agents.defaults.sandbox). Requires Docker CLI in the image
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
## docker-setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
## scripts/docker/setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
## Set DOCKER_GID to the host's docker group GID (run: stat -c '%g' /var/run/docker.sock).
# - /var/run/docker.sock:/var/run/docker.sock
# group_add:

View File

@ -2,615 +2,11 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
SANDBOX_ENABLED=""
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
TIMEZONE="${OPENCLAW_TZ:-}"
SCRIPT_PATH="$ROOT_DIR/scripts/docker/setup.sh"
fail() {
echo "ERROR: $*" >&2
exit 1
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing dependency: $1" >&2
exit 1
fi
}
is_truthy_value() {
local raw="${1:-}"
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
case "$raw" in
1 | true | yes | on) return 0 ;;
*) return 1 ;;
esac
}
read_config_gateway_token() {
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
if [[ ! -f "$config_path" ]]; then
return 0
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$config_path" <<'PY'
import json
import sys
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except Exception:
raise SystemExit(0)
gateway = cfg.get("gateway")
if not isinstance(gateway, dict):
raise SystemExit(0)
auth = gateway.get("auth")
if not isinstance(auth, dict):
raise SystemExit(0)
token = auth.get("token")
if isinstance(token, str):
token = token.strip()
if token:
print(token)
PY
return 0
fi
if command -v node >/dev/null 2>&1; then
node - "$config_path" <<'NODE'
const fs = require("node:fs");
const configPath = process.argv[2];
try {
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
const token = cfg?.gateway?.auth?.token;
if (typeof token === "string" && token.trim().length > 0) {
process.stdout.write(token.trim());
}
} catch {
// Keep docker-setup resilient when config parsing fails.
}
NODE
fi
}
read_env_gateway_token() {
local env_path="$1"
local line=""
local token=""
if [[ ! -f "$env_path" ]]; then
return 0
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
if [[ "$line" == OPENCLAW_GATEWAY_TOKEN=* ]]; then
token="${line#OPENCLAW_GATEWAY_TOKEN=}"
fi
done <"$env_path"
if [[ -n "$token" ]]; then
printf '%s' "$token"
fi
}
ensure_control_ui_allowed_origins() {
if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then
return 0
fi
local allowed_origin_json
local current_allowed_origins
allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")"
current_allowed_origins="$(
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
config get gateway.controlUi.allowedOrigins 2>/dev/null || true
)"
current_allowed_origins="${current_allowed_origins//$'\r'/}"
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
return 0
fi
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
}
sync_gateway_mode_and_bind() {
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
config set gateway.mode local >/dev/null
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null
echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup."
}
contains_disallowed_chars() {
local value="$1"
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
}
is_valid_timezone() {
local value="$1"
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
}
validate_mount_path_value() {
local label="$1"
local value="$2"
if [[ -z "$value" ]]; then
fail "$label cannot be empty."
fi
if contains_disallowed_chars "$value"; then
fail "$label contains unsupported control characters."
fi
if [[ "$value" =~ [[:space:]] ]]; then
fail "$label cannot contain whitespace."
fi
}
validate_named_volume() {
local value="$1"
if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume."
fi
}
validate_mount_spec() {
local mount="$1"
if contains_disallowed_chars "$mount"; then
fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters."
fi
# Keep mount specs strict to avoid YAML structure injection.
# Expected format: source:target[:options]
if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then
fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces."
fi
}
require_cmd docker
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose not available (try: docker compose version)" >&2
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "Docker setup script not found at $SCRIPT_PATH" >&2
exit 1
fi
if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then
DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}"
fi
if [[ -z "$DOCKER_SOCKET_PATH" ]]; then
DOCKER_SOCKET_PATH="/var/run/docker.sock"
fi
if is_truthy_value "$RAW_SANDBOX_SETTING"; then
SANDBOX_ENABLED="1"
fi
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR"
validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR"
if [[ -n "$HOME_VOLUME_NAME" ]]; then
if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then
validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME"
else
validate_named_volume "$HOME_VOLUME_NAME"
fi
fi
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
fi
if [[ -n "$SANDBOX_ENABLED" ]]; then
validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
fi
if [[ -n "$TIMEZONE" ]]; then
if contains_disallowed_chars "$TIMEZONE"; then
fail "OPENCLAW_TZ contains unsupported control characters."
fi
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
fi
if ! is_valid_timezone "$TIMEZONE"; then
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
fi
fi
mkdir -p "$OPENCLAW_CONFIG_DIR"
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows
# where the container (even as root) cannot create new host subdirectories.
mkdir -p "$OPENCLAW_CONFIG_DIR/identity"
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent"
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions"
export OPENCLAW_CONFIG_DIR
export OPENCLAW_WORKSPACE_DIR
export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}"
export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}"
export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}"
export OPENCLAW_IMAGE="$IMAGE_NAME"
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
export OPENCLAW_EXTENSIONS="${OPENCLAW_EXTENSIONS:-}"
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
export OPENCLAW_SANDBOX="$SANDBOX_ENABLED"
export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH"
export OPENCLAW_TZ="$TIMEZONE"
# Detect Docker socket GID for sandbox group_add.
DOCKER_GID=""
if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
fi
export DOCKER_GID
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
else
DOTENV_GATEWAY_TOKEN="$(read_env_gateway_token "$ROOT_DIR/.env" || true)"
if [[ -n "$DOTENV_GATEWAY_TOKEN" ]]; then
OPENCLAW_GATEWAY_TOKEN="$DOTENV_GATEWAY_TOKEN"
echo "Reusing gateway token from $ROOT_DIR/.env"
elif command -v openssl >/dev/null 2>&1; then
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
else
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
)"
fi
fi
fi
export OPENCLAW_GATEWAY_TOKEN
COMPOSE_FILES=("$COMPOSE_FILE")
COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local mount
local gateway_home_mount
local gateway_config_mount
local gateway_workspace_mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
services:
openclaw-gateway:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
gateway_home_mount="${home_volume}:/home/node"
gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw"
gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace"
validate_mount_spec "$gateway_home_mount"
validate_mount_spec "$gateway_config_mount"
validate_mount_spec "$gateway_workspace_mount"
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "$@"; do
validate_mount_spec "$mount"
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
openclaw-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "$@"; do
validate_mount_spec "$mount"
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
validate_named_volume "$home_volume"
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
volumes:
${home_volume}:
YAML
fi
}
# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
# Docker socket mount is deferred until sandbox prerequisites are verified.
if [[ -n "$SANDBOX_ENABLED" ]]; then
if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
export OPENCLAW_INSTALL_DOCKER_CLI=1
fi
fi
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
for mount in "${mounts[@]}"; do
mount="${mount#"${mount%%[![:space:]]*}"}"
mount="${mount%"${mount##*[![:space:]]}"}"
if [[ -n "$mount" ]]; then
VALID_MOUNTS+=("$mount")
fi
done
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
else
write_extra_compose "$HOME_VOLUME_NAME"
fi
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
# Keep a base compose arg set without sandbox overlay so rollback paths can
# force a known-safe gateway service definition (no docker.sock mount).
BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
done
ENV_FILE="$ROOT_DIR/.env"
upsert_env() {
local file="$1"
shift
local -a keys=("$@")
local tmp
tmp="$(mktemp)"
# Use a delimited string instead of an associative array so the script
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
local seen=" "
if [[ -f "$file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
local key="${line%%=*}"
local replaced=false
for k in "${keys[@]}"; do
if [[ "$key" == "$k" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
seen="$seen$k "
replaced=true
break
fi
done
if [[ "$replaced" == false ]]; then
printf '%s\n' "$line" >>"$tmp"
fi
done <"$file"
fi
for k in "${keys[@]}"; do
if [[ "$seen" != *" $k "* ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
fi
done
mv "$tmp" "$file"
}
upsert_env "$ENV_FILE" \
OPENCLAW_CONFIG_DIR \
OPENCLAW_WORKSPACE_DIR \
OPENCLAW_GATEWAY_PORT \
OPENCLAW_BRIDGE_PORT \
OPENCLAW_GATEWAY_BIND \
OPENCLAW_GATEWAY_TOKEN \
OPENCLAW_IMAGE \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES \
OPENCLAW_EXTENSIONS \
OPENCLAW_SANDBOX \
OPENCLAW_DOCKER_SOCKET \
DOCKER_GID \
OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
OPENCLAW_TZ
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"
docker build \
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
--build-arg "OPENCLAW_EXTENSIONS=${OPENCLAW_EXTENSIONS}" \
--build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
else
echo "==> Pulling Docker image: $IMAGE_NAME"
if ! docker pull "$IMAGE_NAME"; then
echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
exit 1
fi
fi
# Ensure bind-mounted data directories are writable by the container's `node`
# user (uid 1000). Host-created dirs inherit the host user's uid which may
# differ, causing EACCES when the container tries to mkdir/write.
# Running a brief root container to chown is the portable Docker idiom --
# it works regardless of the host uid and doesn't require host-side root.
echo ""
echo "==> Fixing data-directory permissions"
# Use -xdev to restrict chown to the config-dir mount only — without it,
# the recursive chown would cross into the workspace bind mount and rewrite
# ownership of all user project files on Linux hosts.
# After fixing the config dir, only the OpenClaw metadata subdirectory
# (.openclaw/) inside the workspace gets chowned, not the user's project files.
docker compose "${COMPOSE_ARGS[@]}" run --rm --user root --entrypoint sh openclaw-cli -c \
'find /home/node/.openclaw -xdev -exec chown node:node {} +; \
[ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true'
echo ""
echo "==> Onboarding (interactive)"
echo "Docker setup pins Gateway mode to local."
echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)."
echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND"
echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN"
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
echo "Install Gateway daemon: No (managed by Docker Compose)"
echo ""
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --mode local --no-install-daemon
echo ""
echo "==> Docker gateway defaults"
sync_gateway_mode_and_bind
echo ""
echo "==> Control UI origin allowlist"
ensure_control_ui_allowed_origins
echo ""
echo "==> Provider setup (optional)"
echo "WhatsApp (QR):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login"
echo "Telegram (bot token):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token <token>"
echo "Discord (bot token):"
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
echo "Docs: https://docs.openclaw.ai/channels"
echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
if [[ -n "$SANDBOX_ENABLED" ]]; then
echo ""
echo "==> Sandbox setup"
# Build sandbox image if Dockerfile.sandbox exists.
if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
docker build \
-t "openclaw-sandbox:bookworm-slim" \
-f "$ROOT_DIR/Dockerfile.sandbox" \
"$ROOT_DIR"
else
echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
echo " Sandbox config will be applied but no sandbox image will be built." >&2
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
fi
# Defense-in-depth: verify Docker CLI in the running image before enabling
# sandbox. This avoids claiming sandbox is enabled when the image cannot
# launch sandbox containers.
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
echo "WARNING: Docker CLI not found inside the container image." >&2
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
fi
# Apply sandbox config only if prerequisites are met.
if [[ -n "$SANDBOX_ENABLED" ]]; then
# Mount Docker socket via a dedicated compose overlay. This overlay is
# created only after sandbox prerequisites pass, so the socket is never
# exposed when sandbox cannot actually run.
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
services:
openclaw-gateway:
volumes:
- ${DOCKER_SOCKET_PATH}:/var/run/docker.sock
YAML
if [[ -n "${DOCKER_GID:-}" ]]; then
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
group_add:
- "${DOCKER_GID}"
YAML
fi
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
echo "==> Sandbox: added Docker socket mount"
else
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
SANDBOX_ENABLED=""
fi
fi
if [[ -n "$SANDBOX_ENABLED" ]]; then
# Enable sandbox in OpenClaw config.
sandbox_config_ok=true
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2
sandbox_config_ok=false
fi
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
config set agents.defaults.sandbox.scope "agent" >/dev/null; then
echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2
sandbox_config_ok=false
fi
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
sandbox_config_ok=false
fi
if [[ "$sandbox_config_ok" == true ]]; then
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
else
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
config set agents.defaults.sandbox.mode "off" >/dev/null; then
echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
else
echo "Sandbox mode rolled back to off due to partial sandbox config failure."
fi
if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; then
rm -f "$SANDBOX_COMPOSE_FILE"
fi
# Ensure gateway service definition is reset without sandbox overlay mount.
docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
fi
else
# Keep reruns deterministic: if sandbox is not active for this run, reset
# persisted sandbox mode so future execs do not require docker.sock by stale
# config alone.
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
config set agents.defaults.sandbox.mode "off" >/dev/null; then
echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
fi
if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
fi
fi
echo ""
echo "Gateway running with host port mapping."
echo "Access from tailnet devices via the host's tailnet IP."
echo "Config: $OPENCLAW_CONFIG_DIR"
echo "Workspace: $OPENCLAW_WORKSPACE_DIR"
echo "Token: $OPENCLAW_GATEWAY_TOKEN"
echo ""
echo "Commands:"
echo " ${COMPOSE_HINT} logs -f openclaw-gateway"
echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\""
exec "$SCRIPT_PATH" "$@"

View File

@ -1046,4 +1046,4 @@ node -e "import('./path/to/handler.ts').then(console.log)"
- [CLI Reference: hooks](/cli/hooks)
- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled)
- [Webhook Hooks](/automation/webhook)
- [Configuration](/gateway/configuration#hooks)
- [Configuration](/gateway/configuration-reference#hooks)

View File

@ -116,7 +116,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w
Related:
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration-reference#agents-defaults-sandbox)
- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts)

View File

@ -1,6 +1,5 @@
---
title: IRC
description: Connect OpenClaw to IRC channels and direct messages.
summary: "IRC plugin setup, access controls, and troubleshooting"
read_when:
- You want to connect OpenClaw to IRC channels or DMs
@ -17,18 +16,18 @@ IRC ships as an extension plugin, but it is configured in the main config under
1. Enable IRC config in `~/.openclaw/openclaw.json`.
2. Set at least:
```json
```json5
{
"channels": {
"irc": {
"enabled": true,
"host": "irc.libera.chat",
"port": 6697,
"tls": true,
"nick": "openclaw-bot",
"channels": ["#openclaw"]
}
}
channels: {
irc: {
enabled: true,
host: "irc.libera.chat",
port: 6697,
tls: true,
nick: "openclaw-bot",
channels: ["#openclaw"],
},
},
}
```
@ -75,7 +74,7 @@ If you see logs like:
Example (allow anyone in `#tuirc-dev` to talk to the bot):
```json5
```json55
{
channels: {
irc: {
@ -96,7 +95,7 @@ That means you may see logs like `drop channel … (missing-mention)` unless the
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
```json5
```json55
{
channels: {
irc: {
@ -114,7 +113,7 @@ To make the bot reply in an IRC channel **without needing a mention**, disable m
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
```json5
```json55
{
channels: {
irc: {
@ -134,7 +133,7 @@ To reduce risk, restrict tools for that channel.
### Same tools for everyone in the channel
```json5
```json55
{
channels: {
irc: {
@ -155,7 +154,7 @@ To reduce risk, restrict tools for that channel.
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
```json5
```json55
{
channels: {
irc: {
@ -190,32 +189,32 @@ For more on group access vs mention-gating (and how they interact), see: [/chann
To identify with NickServ after connect:
```json
```json5
{
"channels": {
"irc": {
"nickserv": {
"enabled": true,
"service": "NickServ",
"password": "your-nickserv-password"
}
}
}
channels: {
irc: {
nickserv: {
enabled: true,
service: "NickServ",
password: "your-nickserv-password",
},
},
},
}
```
Optional one-time registration on connect:
```json
```json5
{
"channels": {
"irc": {
"nickserv": {
"register": true,
"registerEmail": "bot@example.com"
}
}
}
channels: {
irc: {
nickserv: {
register: true,
registerEmail: "bot@example.com",
},
},
},
}
```

View File

@ -1,83 +1,70 @@
---
summary: "Matrix support status, capabilities, and configuration"
summary: "Matrix support status, setup, and configuration examples"
read_when:
- Working on Matrix channel features
- Setting up Matrix in OpenClaw
- Configuring Matrix E2EE and verification
title: "Matrix"
---
# Matrix (plugin)
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).
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.
## Plugin required
Matrix ships as a plugin and is not bundled with the core install.
Matrix is a plugin and is not bundled with core OpenClaw.
Install via CLI (npm registry):
Install from npm:
```bash
openclaw plugins install @openclaw/matrix
```
Local checkout (when running from a git repo):
Install from a local checkout:
```bash
openclaw plugins install ./extensions/matrix
```
If you choose Matrix during setup and a git checkout is detected,
OpenClaw will offer the local install path automatically.
Details: [Plugins](/tools/plugin)
See [Plugins](/tools/plugin) for plugin behavior and install rules.
## Setup
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:
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.
```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"
}'
```
Interactive setup paths:
- 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.
```bash
openclaw channels add
openclaw configure --section channels
```
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 setup).
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.
What the Matrix wizard actually asks for:
Minimal config (access token, user ID auto-fetched):
- 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:
```json5
{
@ -85,14 +72,14 @@ Minimal config (access token, user ID auto-fetched):
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_***",
accessToken: "syt_xxx",
dm: { policy: "pairing" },
},
},
}
```
E2EE config (end to end encryption enabled):
Password-based setup (token is cached after login):
```json5
{
@ -100,7 +87,92 @@ E2EE config (end to end encryption enabled):
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_***",
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",
encryption: true,
dm: { policy: "pairing" },
},
@ -108,60 +180,374 @@ E2EE config (end to end encryption enabled):
}
```
## Encryption (E2EE)
Check verification status:
End-to-end encryption is **supported** via the Rust crypto SDK.
```bash
openclaw matrix verify status
```
Enable with `channels.matrix.encryption: true`:
Verbose status (full diagnostics):
- 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`.
```bash
openclaw matrix verify status --verbose
```
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.
Include the stored recovery key in machine-readable output:
**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.
```bash
openclaw matrix verify status --include-recovery-key --json
```
## Multi-account
Bootstrap cross-signing and verification state:
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.
```bash
openclaw matrix verify bootstrap
```
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.).
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [Configuration reference](/gateway/configuration-reference#multi-account-all-channels) 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 strict DM verification 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.
For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side.
For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally.
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
```json5
{
channels: {
matrix: {
enabled: true,
defaultAccount: "assistant",
dm: { policy: "pairing" },
accounts: {
assistant: {
name: "Main assistant",
homeserver: "https://matrix.example.org",
accessToken: "syt_assistant_***",
accessToken: "syt_assistant_xxx",
encryption: true,
},
alerts: {
name: "Alerts bot",
homeserver: "https://matrix.example.org",
accessToken: "syt_alerts_***",
dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
accessToken: "syt_alerts_xxx",
dm: {
policy: "allowlist",
allowFrom: ["@ops:example.org"],
},
},
},
},
@ -169,135 +555,60 @@ inherits from the top-level `channels.matrix` settings and can override any opti
}
```
Notes:
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.
- 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).
## Target resolution
## Routing model
Matrix accepts these target forms anywhere OpenClaw asks you for a room or user target:
- Replies always go back to Matrix.
- DMs share the agent's main session; rooms map to group sessions.
- 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`
## Access control (DMs)
Live directory lookup uses the logged-in Matrix account:
- 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.
- 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.
## Rooms (groups)
## Configuration reference
- 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).
- `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`).

View File

@ -260,15 +260,17 @@ This is often easier than hand-editing JSON manifests.
4. **Configure OpenClaw**
```json
```json5
{
"msteams": {
"enabled": true,
"appId": "<APP_ID>",
"appPassword": "<APP_PASSWORD>",
"tenantId": "<TENANT_ID>",
"webhook": { "port": 3978, "path": "/api/messages" }
}
channels: {
msteams: {
enabled: true,
appId: "<APP_ID>",
appPassword: "<APP_PASSWORD>",
tenantId: "<TENANT_ID>",
webhook: { port: 3978, path: "/api/messages" },
},
},
}
```
@ -312,49 +314,49 @@ These are the **existing resourceSpecific permissions** in our Teams app manifes
Minimal, valid example with the required fields. Replace IDs and URLs.
```json
```json5
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
"manifestVersion": "1.23",
"version": "1.0.0",
"id": "00000000-0000-0000-0000-000000000000",
"name": { "short": "OpenClaw" },
"developer": {
"name": "Your Org",
"websiteUrl": "https://example.com",
"privacyUrl": "https://example.com/privacy",
"termsOfUseUrl": "https://example.com/terms"
$schema: "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
manifestVersion: "1.23",
version: "1.0.0",
id: "00000000-0000-0000-0000-000000000000",
name: { short: "OpenClaw" },
developer: {
name: "Your Org",
websiteUrl: "https://example.com",
privacyUrl: "https://example.com/privacy",
termsOfUseUrl: "https://example.com/terms",
},
"description": { "short": "OpenClaw in Teams", "full": "OpenClaw in Teams" },
"icons": { "outline": "outline.png", "color": "color.png" },
"accentColor": "#5B6DEF",
"bots": [
description: { short: "OpenClaw in Teams", full: "OpenClaw in Teams" },
icons: { outline: "outline.png", color: "color.png" },
accentColor: "#5B6DEF",
bots: [
{
"botId": "11111111-1111-1111-1111-111111111111",
"scopes": ["personal", "team", "groupChat"],
"isNotificationOnly": false,
"supportsCalling": false,
"supportsVideo": false,
"supportsFiles": true
}
botId: "11111111-1111-1111-1111-111111111111",
scopes: ["personal", "team", "groupChat"],
isNotificationOnly: false,
supportsCalling: false,
supportsVideo: false,
supportsFiles: true,
},
],
"webApplicationInfo": {
"id": "11111111-1111-1111-1111-111111111111"
webApplicationInfo: {
id: "11111111-1111-1111-1111-111111111111",
},
authorization: {
permissions: {
resourceSpecific: [
{ name: "ChannelMessage.Read.Group", type: "Application" },
{ name: "ChannelMessage.Send.Group", type: "Application" },
{ name: "Member.Read.Group", type: "Application" },
{ name: "Owner.Read.Group", type: "Application" },
{ name: "ChannelSettings.Read.Group", type: "Application" },
{ name: "TeamMember.Read.Group", type: "Application" },
{ name: "TeamSettings.Read.Group", type: "Application" },
{ name: "ChatMessage.Read.Chat", type: "Application" },
],
},
},
"authorization": {
"permissions": {
"resourceSpecific": [
{ "name": "ChannelMessage.Read.Group", "type": "Application" },
{ "name": "ChannelMessage.Send.Group", "type": "Application" },
{ "name": "Member.Read.Group", "type": "Application" },
{ "name": "Owner.Read.Group", "type": "Application" },
{ "name": "ChannelSettings.Read.Group", "type": "Application" },
{ "name": "TeamMember.Read.Group", "type": "Application" },
{ "name": "TeamSettings.Read.Group", "type": "Application" },
{ "name": "ChatMessage.Read.Chat", "type": "Application" }
]
}
}
}
```
@ -500,20 +502,22 @@ Teams recently introduced two channel UI styles over the same underlying data mo
**Solution:** Configure `replyStyle` per-channel based on how the channel is set up:
```json
```json5
{
"msteams": {
"replyStyle": "thread",
"teams": {
"19:abc...@thread.tacv2": {
"channels": {
"19:xyz...@thread.tacv2": {
"replyStyle": "top-level"
}
}
}
}
}
channels: {
msteams: {
replyStyle: "thread",
teams: {
"19:abc...@thread.tacv2": {
channels: {
"19:xyz...@thread.tacv2": {
replyStyle: "top-level",
},
},
},
},
},
},
}
```
@ -616,16 +620,16 @@ The `card` parameter accepts an Adaptive Card JSON object. When `card` is provid
**Agent tool:**
```json
```json5
{
"action": "send",
"channel": "msteams",
"target": "user:<id>",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [{ "type": "TextBlock", "text": "Hello!" }]
}
action: "send",
channel: "msteams",
target: "user:<id>",
card: {
type: "AdaptiveCard",
version: "1.5",
body: [{ type: "TextBlock", text: "Hello!" }],
},
}
```
@ -669,25 +673,25 @@ openclaw message send --channel msteams --target "conversation:19:abc...@thread.
**Agent tool examples:**
```json
```json5
{
"action": "send",
"channel": "msteams",
"target": "user:John Smith",
"message": "Hello!"
action: "send",
channel: "msteams",
target: "user:John Smith",
message: "Hello!",
}
```
```json
```json5
{
"action": "send",
"channel": "msteams",
"target": "conversation:19:abc...@thread.tacv2",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [{ "type": "TextBlock", "text": "Hello" }]
}
action: "send",
channel: "msteams",
target: "conversation:19:abc...@thread.tacv2",
card: {
type: "AdaptiveCard",
version: "1.5",
body: [{ type: "TextBlock", text: "Hello" }],
},
}
```

View File

@ -60,13 +60,13 @@ nak key generate
2. Add to config:
```json
```json5
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}"
}
}
channels: {
nostr: {
privateKey: "${NOSTR_PRIVATE_KEY}",
},
},
}
```
@ -96,23 +96,23 @@ Profile data is published as a NIP-01 `kind:0` event. You can manage it from the
Example:
```json
```json5
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"profile": {
"name": "openclaw",
"displayName": "OpenClaw",
"about": "Personal assistant DM bot",
"picture": "https://example.com/avatar.png",
"banner": "https://example.com/banner.png",
"website": "https://example.com",
"nip05": "openclaw@example.com",
"lud16": "openclaw@example.com"
}
}
}
channels: {
nostr: {
privateKey: "${NOSTR_PRIVATE_KEY}",
profile: {
name: "openclaw",
displayName: "OpenClaw",
about: "Personal assistant DM bot",
picture: "https://example.com/avatar.png",
banner: "https://example.com/banner.png",
website: "https://example.com",
nip05: "openclaw@example.com",
lud16: "openclaw@example.com",
},
},
},
}
```
@ -132,15 +132,15 @@ Notes:
### Allowlist example
```json
```json5
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": ["npub1abc...", "npub1xyz..."]
}
}
channels: {
nostr: {
privateKey: "${NOSTR_PRIVATE_KEY}",
dmPolicy: "allowlist",
allowFrom: ["npub1abc...", "npub1xyz..."],
},
},
}
```
@ -155,14 +155,14 @@ Accepted formats:
Defaults: `relay.damus.io` and `nos.lol`.
```json
```json5
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"]
}
}
channels: {
nostr: {
privateKey: "${NOSTR_PRIVATE_KEY}",
relays: ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"],
},
},
}
```
@ -191,14 +191,14 @@ Tips:
docker run -p 7777:7777 ghcr.io/hoytech/strfry
```
```json
```json5
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["ws://localhost:7777"]
}
}
channels: {
nostr: {
privateKey: "${NOSTR_PRIVATE_KEY}",
relays: ["ws://localhost:7777"],
},
},
}
```

View File

@ -67,7 +67,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram).
3. On your phone, open the OpenClaw iOS app → Settings → Gateway.
4. Paste the setup code and connect.
5. Back in Telegram: `/pair approve`
5. Back in Telegram: `/pair pending` (review request IDs, role, and scopes), then approve.
The setup code is a base64-encoded JSON payload that contains:
@ -84,6 +84,10 @@ openclaw devices approve <requestId>
openclaw devices reject <requestId>
```
If the same device retries with different auth details (for example different
role/scopes/public key), the previous pending request is superseded and a new
`requestId` is created.
### Node pairing state storage
Stored under `~/.openclaw/devices/`:

View File

@ -99,7 +99,7 @@ Example:
}
```
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern.
## Setup path B: register dedicated bot number (SMS, Linux)

View File

@ -346,7 +346,13 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
1. `/pair` generates setup code
2. paste code in iOS app
3. `/pair approve` approves latest pending request
3. `/pair pending` lists pending requests (including role/scopes)
4. approve the request:
- `/pair approve <requestId>` for explicit approval
- `/pair approve` when there is only one pending request
- `/pair approve latest` for most recent
If a device retries with changed auth details (for example role/scopes/public key), the previous pending request is superseded and the new request uses a different `requestId`. Re-run `/pair pending` before approving.
More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios).

View File

@ -38,7 +38,7 @@ Healthy baseline:
| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. |
| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. |
Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
Full troubleshooting: [/channels/whatsapp#troubleshooting](/channels/whatsapp#troubleshooting)
## Telegram
@ -90,7 +90,7 @@ Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubles
Full troubleshooting:
- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc)
- [/channels/imessage#troubleshooting](/channels/imessage#troubleshooting)
- [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting)
## Signal

View File

@ -123,7 +123,7 @@ Prefer `allowFrom` for a hard allowlist. Use `allowedRoles` instead if you want
**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent.
Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/) (Convert your Twitch username to ID)
Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) (Convert your Twitch username to ID)
## Token refresh (optional)

View File

@ -9,6 +9,21 @@ 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.

View File

@ -1,6 +1,5 @@
---
title: CI Pipeline
description: How the OpenClaw CI pipeline works
summary: "CI job graph, scope gates, and local command equivalents"
read_when:
- You need to understand why a CI job did or did not run

View File

@ -21,6 +21,9 @@ openclaw devices list
openclaw devices list --json
```
Pending request output includes the requested role and scopes so approvals can
be reviewed before you approve.
### `openclaw devices remove <deviceId>`
Remove one paired device entry.
@ -45,6 +48,11 @@ openclaw devices clear --yes --pending --json
Approve a pending device pairing request. If `requestId` is omitted, OpenClaw
automatically approves the most recent pending request.
Note: if a device retries pairing with changed auth details (role/scopes/public
key), OpenClaw supersedes the previous pending entry and issues a new
`requestId`. Run `openclaw devices list` right before approval to use the
current ID.
```
openclaw devices approve
openclaw devices approve <requestId>

View File

@ -111,6 +111,10 @@ openclaw devices list
openclaw devices approve <requestId>
```
If the node retries pairing with changed auth details (role/scopes/public key),
the previous pending request is superseded and a new `requestId` is created.
Run `openclaw devices list` again before approval.
The node host stores its node id, token, display name, and gateway connection info in
`~/.openclaw/node.json`.

View File

@ -138,14 +138,24 @@ state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/<id>`). Use
### Update
```bash
openclaw plugins update <id>
openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id> --dry-run
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call@beta
```
Updates apply to tracked installs in `plugins.installs`, currently npm and
marketplace installs.
When you pass a plugin id, OpenClaw reuses the recorded install spec for that
plugin. That means previously stored dist-tags such as `@beta` and exact pinned
versions continue to be used on later `update <id>` runs.
For npm installs, you can also pass an explicit npm package spec with a dist-tag
or exact version. OpenClaw resolves that package name back to the tracked plugin
record, updates that installed plugin, and records the new npm spec for future
id-based updates.
When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use
global `--yes` to bypass prompts in CI/non-interactive runs.

View File

@ -46,7 +46,7 @@ JSON examples:
"activeMinutes": null,
"sessions": [
{ "agentId": "main", "key": "agent:main:main", "model": "gpt-5" },
{ "agentId": "work", "key": "agent:work:main", "model": "claude-opus-4-5" }
{ "agentId": "work", "key": "agent:work:main", "model": "claude-opus-4-6" }
]
}
```

View File

@ -1,13 +1,13 @@
---
summary: "Agent runtime (embedded pi-mono), workspace contract, and session bootstrap"
summary: "Agent runtime, workspace contract, and session bootstrap"
read_when:
- Changing agent runtime, workspace bootstrap, or session behavior
title: "Agent Runtime"
---
# Agent Runtime 🤖
# Agent Runtime
OpenClaw runs a single embedded agent runtime derived from **pi-mono**.
OpenClaw runs a single embedded agent runtime.
## Workspace (required)
@ -63,12 +63,11 @@ OpenClaw loads skills from three locations (workspace wins on name conflict):
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
## pi-mono integration
## Runtime boundaries
OpenClaw reuses pieces of the pi-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**.
- No pi-coding agent runtime.
- No `~/.pi/agent` or `<workspace>/.pi` settings are consulted.
The embedded agent runtime is built on the Pi agent core (models, tools, and
prompt pipeline). Session management, discovery, tool wiring, and channel
delivery are OpenClaw-owned layers on top of that core.
## Sessions
@ -77,7 +76,7 @@ Session transcripts are stored as JSONL at:
- `~/.openclaw/agents/<agentId>/sessions/<SessionId>.jsonl`
The session ID is stable and chosen by OpenClaw.
Legacy Pi/Tau session folders are **not** read.
Legacy session folders from other tools are not read.
## Steering while streaming

View File

@ -7,8 +7,6 @@ title: "Gateway Architecture"
# Gateway architecture
Last updated: 2026-01-22
## Overview
- A single longlived **Gateway** owns all messaging surfaces (WhatsApp via

View File

@ -31,7 +31,7 @@ You can optionally specify a different model for compaction summarization via `a
"agents": {
"defaults": {
"compaction": {
"model": "openrouter/anthropic/claude-sonnet-4-5"
"model": "openrouter/anthropic/claude-sonnet-4-6"
}
}
}

View File

@ -32,24 +32,42 @@ title: "Features"
## Full list
- WhatsApp integration via WhatsApp Web (Baileys)
- Telegram bot support (grammY)
- Discord bot support (channels.discord.js)
- Mattermost bot support (plugin)
- iMessage integration via local imsg CLI (macOS)
- Agent bridge for Pi in RPC mode with tool streaming
- Streaming and chunking for long responses
- Multi-agent routing for isolated sessions per workspace or sender
- Subscription auth for Anthropic and OpenAI via OAuth
- Sessions: direct chats collapse into shared `main`; groups are isolated
- Group chat support with mention based activation
- Media support for images, audio, and documents
- Optional voice note transcription hook
- WebChat and macOS menu bar app
- iOS node with pairing, Canvas, camera, screen recording, location, and voice features
- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera, plus device, notifications, contacts/calendar, motion, photos, and SMS commands
**Channels:**
<Note>
Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only
coding agent path.
</Note>
- WhatsApp, Telegram, Discord, iMessage (built-in)
- Mattermost, Matrix, MS Teams, Nostr, and more (plugins)
- Group chat support with mention-based activation
- DM safety with allowlists and pairing
**Agent:**
- Embedded agent runtime with tool streaming
- Multi-agent routing with isolated sessions per workspace or sender
- Sessions: direct chats collapse into shared `main`; groups are isolated
- Streaming and chunking for long responses
**Auth and providers:**
- 35+ model providers (Anthropic, OpenAI, Google, and more)
- Subscription auth via OAuth (e.g. OpenAI Codex)
- Custom and self-hosted provider support (vLLM, SGLang, Ollama, and any OpenAI-compatible or Anthropic-compatible endpoint)
**Media:**
- Images, audio, video, and documents in and out
- Voice note transcription
- Text-to-speech with multiple providers
**Apps and interfaces:**
- WebChat and browser Control UI
- macOS menu bar companion app
- iOS node with pairing, Canvas, camera, screen recording, location, and voice
- Android node with pairing, chat, voice, Canvas, camera, and device commands
**Tools and automation:**
- Browser automation, exec, sandboxing
- Web search (Brave, Perplexity, Gemini, Grok, Kimi, Firecrawl)
- Cron jobs and heartbeat scheduling
- Skills, plugins, and workflow pipelines (Lobster)

View File

@ -34,8 +34,8 @@ These files live under the workspace (`agents.defaults.workspace`, default
OpenClaw exposes two agent-facing tools for these Markdown files:
- `memory_search` semantic recall over indexed snippets.
- `memory_get` targeted read of a specific Markdown file/line range.
- `memory_search` -- semantic recall over indexed snippets.
- `memory_get` -- targeted read of a specific Markdown file/line range.
`memory_get` now **degrades gracefully when a file doesn't exist** (for example,
today's daily log before the first write). Both the builtin manager and the QMD
@ -94,709 +94,15 @@ For the full compaction lifecycle, see
## Vector memory search
OpenClaw can build a small vector index over `MEMORY.md` and `memory/*.md` so
semantic queries can find related notes even when wording differs.
Defaults:
- Enabled by default.
- Watches memory files for changes (debounced).
- Configure memory search under `agents.defaults.memorySearch` (not top-level
`memorySearch`).
- Uses remote embeddings by default. If `memorySearch.provider` is not set, OpenClaw auto-selects:
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
3. `gemini` if a Gemini key can be resolved.
4. `voyage` if a Voyage key can be resolved.
5. `mistral` if a Mistral key can be resolved.
6. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
Remote embeddings **require** an API key for the embedding provider. OpenClaw
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
variables. Codex OAuth only covers chat/completions and does **not** satisfy
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
`models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or
`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or
`models.providers.mistral.apiKey`. Ollama typically does not require a real API
key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by
local policy).
When using a custom OpenAI-compatible endpoint,
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
### QMD backend (experimental)
Set `memory.backend = "qmd"` to swap the built-in SQLite indexer for
[QMD](https://github.com/tobi/qmd): a local-first search sidecar that combines
BM25 + vectors + reranking. Markdown stays the source of truth; OpenClaw shells
out to QMD for retrieval. Key points:
**Prereqs**
- Disabled by default. Opt in per-config (`memory.backend = "qmd"`).
- Install the QMD CLI separately (`bun install -g https://github.com/tobi/qmd` or grab
a release) and make sure the `qmd` binary is on the gateways `PATH`.
- QMD needs an SQLite build that allows extensions (`brew install sqlite` on
macOS).
- QMD runs fully locally via Bun + `node-llama-cpp` and auto-downloads GGUF
models from HuggingFace on first use (no separate Ollama daemon required).
- The gateway runs QMD in a self-contained XDG home under
`~/.openclaw/agents/<agentId>/qmd/` by setting `XDG_CONFIG_HOME` and
`XDG_CACHE_HOME`.
- OS support: macOS and Linux work out of the box once Bun + SQLite are
installed. Windows is best supported via WSL2.
**How the sidecar runs**
- The gateway writes a self-contained QMD home under
`~/.openclaw/agents/<agentId>/qmd/` (config + cache + sqlite DB).
- Collections are created via `qmd collection add` from `memory.qmd.paths`
(plus default workspace memory files), then `qmd update` + `qmd embed` run
on boot and on a configurable interval (`memory.qmd.update.interval`,
default 5m).
- The gateway now initializes the QMD manager on startup, so periodic update
timers are armed even before the first `memory_search` call.
- Boot refresh now runs in the background by default so chat startup is not
blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
blocking behavior.
- Searches run via `memory.qmd.searchMode` (default `qmd search --json`; also
supports `vsearch` and `query`). If the selected mode rejects flags on your
QMD build, OpenClaw retries with `qmd query`. If QMD fails or the binary is
missing, OpenClaw automatically falls back to the builtin SQLite manager so
memory tools keep working.
- OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is
controlled by QMD itself.
- **First search may be slow**: QMD may download local GGUF models (reranker/query
expansion) on the first `qmd query` run.
- OpenClaw sets `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` automatically when it runs QMD.
- If you want to pre-download models manually (and warm the same index OpenClaw
uses), run a one-off query with the agents XDG dirs.
OpenClaws QMD state lives under your **state dir** (defaults to `~/.openclaw`).
You can point `qmd` at the exact same index by exporting the same XDG vars
OpenClaw uses:
```bash
# Pick the same state dir OpenClaw uses
STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
export XDG_CONFIG_HOME="$STATE_DIR/agents/main/qmd/xdg-config"
export XDG_CACHE_HOME="$STATE_DIR/agents/main/qmd/xdg-cache"
# (Optional) force an index refresh + embeddings
qmd update
qmd embed
# Warm up / trigger first-time model downloads
qmd query "test" -c memory-root --json >/dev/null 2>&1
```
**Config surface (`memory.qmd.*`)**
- `command` (default `qmd`): override the executable path.
- `searchMode` (default `search`): pick which QMD command backs
`memory_search` (`search`, `vsearch`, `query`).
- `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`.
- `paths[]`: add extra directories/files (`path`, optional `pattern`, optional
stable `name`).
- `sessions`: opt into session JSONL indexing (`enabled`, `retentionDays`,
`exportDir`).
- `update`: controls refresh cadence and maintenance execution:
(`interval`, `debounceMs`, `onBoot`, `waitForBootSync`, `embedInterval`,
`commandTimeoutMs`, `updateTimeoutMs`, `embedTimeoutMs`).
- `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`,
`maxInjectedChars`, `timeoutMs`).
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD
hits in groups/channels.
- `match.keyPrefix` matches the **normalized** session key (lowercased, with any
leading `agent:<id>:` stripped). Example: `discord:channel:`.
- `match.rawKeyPrefix` matches the **raw** session key (lowercased), including
`agent:<id>:`. Example: `agent:main:discord:`.
- Legacy: `match.keyPrefix: "agent:..."` is still treated as a raw-key prefix,
but prefer `rawKeyPrefix` for clarity.
- When `scope` denies a search, OpenClaw logs a warning with the derived
`channel`/`chatType` so empty results are easier to debug.
- Snippets sourced outside the workspace show up as
`qmd/<collection>/<relative-path>` in `memory_search` results; `memory_get`
understands that prefix and reads from the configured QMD collection root.
- When `memory.qmd.sessions.enabled = true`, OpenClaw exports sanitized session
transcripts (User/Assistant turns) into a dedicated QMD collection under
`~/.openclaw/agents/<id>/qmd/sessions/`, so `memory_search` can recall recent
conversations without touching the builtin SQLite index.
- `memory_search` snippets now include a `Source: <path#line>` footer when
`memory.citations` is `auto`/`on`; set `memory.citations = "off"` to keep
the path metadata internal (the agent still receives the path for
`memory_get`, but the snippet text omits the footer and the system prompt
warns the agent not to cite it).
**Example**
```json5
memory: {
backend: "qmd",
citations: "auto",
qmd: {
includeDefaultMemory: true,
update: { interval: "5m", debounceMs: 15000 },
limits: { maxResults: 6, timeoutMs: 4000 },
scope: {
default: "deny",
rules: [
{ action: "allow", match: { chatType: "direct" } },
// Normalized session-key prefix (strips `agent:<id>:`).
{ action: "deny", match: { keyPrefix: "discord:channel:" } },
// Raw session-key prefix (includes `agent:<id>:`).
{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
]
},
paths: [
{ name: "docs", path: "~/notes", pattern: "**/*.md" }
]
}
}
```
**Citations & fallback**
- `memory.citations` applies regardless of backend (`auto`/`on`/`off`).
- When `qmd` runs, we tag `status().backend = "qmd"` so diagnostics show which
engine served the results. If the QMD subprocess exits or JSON output cant be
parsed, the search manager logs a warning and returns the builtin provider
(existing Markdown embeddings) until QMD recovers.
### Additional memory paths
If you want to index Markdown files outside the default workspace layout, add
explicit paths:
```json5
agents: {
defaults: {
memorySearch: {
extraPaths: ["../team-docs", "/srv/shared-notes/overview.md"]
}
}
}
```
Notes:
- Paths can be absolute or workspace-relative.
- Directories are scanned recursively for `.md` files.
- By default, only Markdown files are indexed.
- If `memorySearch.multimodal.enabled = true`, OpenClaw also indexes supported image/audio files under `extraPaths` only. Default memory roots (`MEMORY.md`, `memory.md`, `memory/**/*.md`) stay Markdown-only.
- Symlinks are ignored (files or directories).
### Multimodal memory files (Gemini image + audio)
OpenClaw can index image and audio files from `memorySearch.extraPaths` when using Gemini embedding 2:
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-2-preview",
extraPaths: ["assets/reference", "voice-notes"],
multimodal: {
enabled: true,
modalities: ["image", "audio"], // or ["all"]
maxFileBytes: 10000000
},
remote: {
apiKey: "YOUR_GEMINI_API_KEY"
}
}
}
}
```
Notes:
- Multimodal memory is currently supported only for `gemini-embedding-2-preview`.
- Multimodal indexing applies only to files discovered through `memorySearch.extraPaths`.
- Supported modalities in this phase: image and audio.
- `memorySearch.fallback` must stay `"none"` while multimodal memory is enabled.
- Matching image/audio file bytes are uploaded to the configured Gemini embedding endpoint during indexing.
- Supported image extensions: `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.heic`, `.heif`.
- Supported audio extensions: `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.flac`.
- Search queries remain text, but Gemini can compare those text queries against indexed image/audio embeddings.
- `memory_get` still reads Markdown only; binary files are searchable but not returned as raw file contents.
### Gemini embeddings (native)
Set the provider to `gemini` to use the Gemini embeddings API directly:
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001",
remote: {
apiKey: "YOUR_GEMINI_API_KEY"
}
}
}
}
```
Notes:
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
- `remote.headers` lets you add extra headers if needed.
- Default model: `gemini-embedding-001`.
- `gemini-embedding-2-preview` is also supported: 8192 token limit and configurable dimensions (768 / 1536 / 3072, default 3072).
#### Gemini Embedding 2 (preview)
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-2-preview",
outputDimensionality: 3072, // optional: 768, 1536, or 3072 (default)
remote: {
apiKey: "YOUR_GEMINI_API_KEY"
}
}
}
}
```
> **⚠️ Re-index required:** Switching from `gemini-embedding-001` (768 dimensions)
> to `gemini-embedding-2-preview` (3072 dimensions) changes the vector size. The same is true if you
> change `outputDimensionality` between 768, 1536, and 3072.
> OpenClaw will automatically reindex when it detects a model or dimension change.
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
you can use the `remote` configuration with the OpenAI provider:
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
headers: { "X-Custom-Header": "value" }
}
}
}
}
```
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`.
Fallbacks:
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini + Voyage):
- Disabled by default. Set `agents.defaults.memorySearch.remote.batch.enabled = true` to enable for large-corpus indexing (OpenAI, Gemini, and Voyage).
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
Why OpenAI batch is fast + cheap:
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
- OpenAI offers discounted pricing for Batch API workloads, so large indexing runs are usually cheaper than sending the same requests synchronously.
- See the OpenAI Batch API docs and pricing for details:
- [https://platform.openai.com/docs/api-reference/batch](https://platform.openai.com/docs/api-reference/batch)
- [https://platform.openai.com/pricing](https://platform.openai.com/pricing)
Config example:
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
fallback: "openai",
remote: {
batch: { enabled: true, concurrency: 2 }
},
sync: { watch: true }
}
}
}
```
Tools:
- `memory_search` — returns snippets with file + line ranges.
- `memory_get` — read memory file content by path.
Local mode:
- Set `agents.defaults.memorySearch.provider = "local"`.
- Provide `agents.defaults.memorySearch.local.modelPath` (GGUF or `hf:` URI).
- Optional: set `agents.defaults.memorySearch.fallback = "none"` to avoid remote fallback.
### How the memory tools work
- `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned.
- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.
- Both tools are enabled only when `memorySearch.enabled` resolves true for the agent.
### What gets indexed (and when)
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
- Index storage: per-agent SQLite at `~/.openclaw/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, OpenClaw automatically resets and reindexes the entire store.
### Hybrid search (BM25 + vector)
When enabled, OpenClaw combines:
- **Vector similarity** (semantic match, wording can differ)
- **BM25 keyword relevance** (exact tokens like IDs, env vars, code symbols)
If full-text search is unavailable on your platform, OpenClaw falls back to vector-only search.
#### Why hybrid?
Vector search is great at “this means the same thing”:
- “Mac Studio gateway host” vs “the machine running the gateway”
- “debounce file updates” vs “avoid indexing on every write”
But it can be weak at exact, high-signal tokens:
- IDs (`a828e60`, `b3b9895a…`)
- code symbols (`memorySearch.query.hybrid`)
- error strings ("sqlite-vec unavailable")
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
good results for both "natural language" queries and "needle in a haystack" queries.
#### How we merge results (the current design)
Implementation sketch:
1. Retrieve a candidate pool from both sides:
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
2. Convert BM25 rank into a 0..1-ish score:
- `textScore = 1 / (1 + max(0, bm25Rank))`
3. Union candidates by chunk id and compute a weighted score:
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
Notes:
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
- If FTS5 can't be created, we keep vector-only search (no hard failure).
This isn't "IR-theory perfect", but it's simple, fast, and tends to improve recall/precision on real notes.
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
(min/max or z-score) before mixing.
#### Post-processing pipeline
After merging vector and keyword scores, two optional post-processing stages
refine the result list before it reaches the agent:
```
Vector + Keyword → Weighted Merge → Temporal Decay → Sort → MMR → Top-K Results
```
Both stages are **off by default** and can be enabled independently.
#### MMR re-ranking (diversity)
When hybrid search returns results, multiple chunks may contain similar or overlapping content.
For example, searching for "home network setup" might return five nearly identical snippets
from different daily notes that all mention the same router configuration.
**MMR (Maximal Marginal Relevance)** re-ranks the results to balance relevance with diversity,
ensuring the top results cover different aspects of the query instead of repeating the same information.
How it works:
1. Results are scored by their original relevance (vector + BM25 weighted score).
2. MMR iteratively selects results that maximize: × relevance (1λ) × max_similarity_to_selected`.
3. Similarity between results is measured using Jaccard text similarity on tokenized content.
The `lambda` parameter controls the trade-off:
- `lambda = 1.0` → pure relevance (no diversity penalty)
- `lambda = 0.0` → maximum diversity (ignores relevance)
- Default: `0.7` (balanced, slight relevance bias)
**Example — query: "home network setup"**
Given these memory files:
```
memory/2026-02-10.md → "Configured Omada router, set VLAN 10 for IoT devices"
memory/2026-02-08.md → "Configured Omada router, moved IoT to VLAN 10"
memory/2026-02-05.md → "Set up AdGuard DNS on 192.168.10.2"
memory/network.md → "Router: Omada ER605, AdGuard: 192.168.10.2, VLAN 10: IoT"
```
Without MMR — top 3 results:
```
1. memory/2026-02-10.md (score: 0.92) ← router + VLAN
2. memory/2026-02-08.md (score: 0.89) ← router + VLAN (near-duplicate!)
3. memory/network.md (score: 0.85) ← reference doc
```
With MMR (λ=0.7) — top 3 results:
```
1. memory/2026-02-10.md (score: 0.92) ← router + VLAN
2. memory/network.md (score: 0.85) ← reference doc (diverse!)
3. memory/2026-02-05.md (score: 0.78) ← AdGuard DNS (diverse!)
```
The near-duplicate from Feb 8 drops out, and the agent gets three distinct pieces of information.
**When to enable:** If you notice `memory_search` returning redundant or near-duplicate snippets,
especially with daily notes that often repeat similar information across days.
#### Temporal decay (recency boost)
Agents with daily notes accumulate hundreds of dated files over time. Without decay,
a well-worded note from six months ago can outrank yesterday's update on the same topic.
**Temporal decay** applies an exponential multiplier to scores based on the age of each result,
so recent memories naturally rank higher while old ones fade:
```
decayedScore = score × e^(-λ × ageInDays)
```
where `λ = ln(2) / halfLifeDays`.
With the default half-life of 30 days:
- Today's notes: **100%** of original score
- 7 days ago: **~84%**
- 30 days ago: **50%**
- 90 days ago: **12.5%**
- 180 days ago: **~1.6%**
**Evergreen files are never decayed:**
- `MEMORY.md` (root memory file)
- Non-dated files in `memory/` (e.g., `memory/projects.md`, `memory/network.md`)
- These contain durable reference information that should always rank normally.
**Dated daily files** (`memory/YYYY-MM-DD.md`) use the date extracted from the filename.
Other sources (e.g., session transcripts) fall back to file modification time (`mtime`).
**Example — query: "what's Rod's work schedule?"**
Given these memory files (today is Feb 10):
```
memory/2025-09-15.md → "Rod works Mon-Fri, standup at 10am, pairing at 2pm" (148 days old)
memory/2026-02-10.md → "Rod has standup at 14:15, 1:1 with Zeb at 14:45" (today)
memory/2026-02-03.md → "Rod started new team, standup moved to 14:15" (7 days old)
```
Without decay:
```
1. memory/2025-09-15.md (score: 0.91) ← best semantic match, but stale!
2. memory/2026-02-10.md (score: 0.82)
3. memory/2026-02-03.md (score: 0.80)
```
With decay (halfLife=30):
```
1. memory/2026-02-10.md (score: 0.82 × 1.00 = 0.82) ← today, no decay
2. memory/2026-02-03.md (score: 0.80 × 0.85 = 0.68) ← 7 days, mild decay
3. memory/2025-09-15.md (score: 0.91 × 0.03 = 0.03) ← 148 days, nearly gone
```
The stale September note drops to the bottom despite having the best raw semantic match.
**When to enable:** If your agent has months of daily notes and you find that old,
stale information outranks recent context. A half-life of 30 days works well for
daily-note-heavy workflows; increase it (e.g., 90 days) if you reference older notes frequently.
#### Configuration
Both features are configured under `memorySearch.query.hybrid`:
```json5
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
enabled: true,
vectorWeight: 0.7,
textWeight: 0.3,
candidateMultiplier: 4,
// Diversity: reduce redundant results
mmr: {
enabled: true, // default: false
lambda: 0.7 // 0 = max diversity, 1 = max relevance
},
// Recency: boost newer memories
temporalDecay: {
enabled: true, // default: false
halfLifeDays: 30 // score halves every 30 days
}
}
}
}
}
}
```
You can enable either feature independently:
- **MMR only** — useful when you have many similar notes but age doesn't matter.
- **Temporal decay only** — useful when recency matters but your results are already diverse.
- **Both** — recommended for agents with large, long-running daily note histories.
### Embedding cache
OpenClaw can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
Config:
```json5
agents: {
defaults: {
memorySearch: {
cache: {
enabled: true,
maxEntries: 50000
}
}
}
}
```
### Session memory search (experimental)
You can optionally index **session transcripts** and surface them via `memory_search`.
This is gated behind an experimental flag.
```json5
agents: {
defaults: {
memorySearch: {
experimental: { sessionMemory: true },
sources: ["memory", "sessions"]
}
}
}
```
Notes:
- Session indexing is **opt-in** (off by default).
- Session updates are debounced and **indexed asynchronously** once they cross delta thresholds (best-effort).
- `memory_search` never blocks on indexing; results can be slightly stale until background sync finishes.
- Results still include snippets only; `memory_get` remains limited to memory files.
- Session indexing is isolated per agent (only that agents session logs are indexed).
- Session logs live on disk (`~/.openclaw/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
Delta thresholds (defaults shown):
```json5
agents: {
defaults: {
memorySearch: {
sync: {
sessions: {
deltaBytes: 100000, // ~100 KB
deltaMessages: 50 // JSONL lines
}
}
}
}
}
```
### SQLite vector acceleration (sqlite-vec)
When the sqlite-vec extension is available, OpenClaw stores embeddings in a
SQLite virtual table (`vec0`) and performs vector distance queries in the
database. This keeps search fast without loading every embedding into JS.
Configuration (optional):
```json5
agents: {
defaults: {
memorySearch: {
store: {
vector: {
enabled: true,
extensionPath: "/path/to/sqlite-vec"
}
}
}
}
}
```
Notes:
- `enabled` defaults to true; when disabled, search falls back to in-process
cosine similarity over stored embeddings.
- If the sqlite-vec extension is missing or fails to load, OpenClaw logs the
error and continues with the JS fallback (no vector table).
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
or non-standard install locations).
### Local embedding auto-download
- Default local embedding model: `hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB).
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
### Custom OpenAI-compatible endpoint example
```json5
agents: {
defaults: {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://api.example.com/v1/",
apiKey: "YOUR_REMOTE_API_KEY",
headers: {
"X-Organization": "org-id",
"X-Project": "project-id"
}
}
}
}
}
```
Notes:
- `remote.*` takes precedence over `models.providers.openai.*`.
- `remote.headers` merge with OpenAI headers; remote wins on key conflicts. Omit `remote.headers` to use the OpenAI defaults.
semantic queries can find related notes even when wording differs. Hybrid search
(BM25 + vector) is available for combining semantic matching with exact keyword
lookups.
Memory search supports multiple embedding providers (OpenAI, Gemini, Voyage,
Mistral, Ollama, and local GGUF models), an optional QMD sidecar backend for
advanced retrieval, and post-processing features like MMR diversity re-ranking
and temporal decay.
For the full configuration reference -- including embedding provider setup, QMD
backend, hybrid search tuning, multimodal memory, and all config knobs -- see
[Memory configuration reference](/reference/memory-config).

View File

@ -151,4 +151,4 @@ Outbound message formatting is centralized in `messages`:
- `messages.responsePrefix`, `channels.<channel>.responsePrefix`, and `channels.<channel>.accounts.<id>.responsePrefix` (outbound prefix cascade), plus `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix)
- Reply threading via `replyToMode` and per-channel defaults
Details: [Configuration](/gateway/configuration#messages) and channel docs.
Details: [Configuration](/gateway/configuration-reference#messages) and channel docs.

View File

@ -255,7 +255,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
### Other bundled provider plugins
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
- Example model: `openrouter/anthropic/claude-sonnet-4-5`
- Example model: `openrouter/anthropic/claude-sonnet-4-6`
- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`)
- Example model: `kilocode/anthropic/claude-opus-4.6`
- MiniMax: `minimax` (`MINIMAX_API_KEY`)

View File

@ -58,7 +58,7 @@ Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
to `zai/*`.
Provider configuration examples (including OpenCode) live in
[/gateway/configuration](/gateway/configuration#opencode).
[/providers/opencode](/providers/opencode).
## "Model is not allowed" (and why replies stop)
@ -82,9 +82,9 @@ Example allowlist config:
```json5
{
agent: {
model: { primary: "anthropic/claude-sonnet-4-5" },
model: { primary: "anthropic/claude-sonnet-4-6" },
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
},
},

View File

@ -388,7 +388,7 @@ Split by channel: route WhatsApp to a fast everyday agent and Telegram to an Opu
id: "chat",
name: "Everyday",
workspace: "~/.openclaw/workspace-chat",
model: "anthropic/claude-sonnet-4-5",
model: "anthropic/claude-sonnet-4-6",
},
{
id: "opus",
@ -422,7 +422,7 @@ Keep WhatsApp on the fast agent, but route one DM to Opus:
id: "chat",
name: "Everyday",
workspace: "~/.openclaw/workspace-chat",
model: "anthropic/claude-sonnet-4-5",
model: "anthropic/claude-sonnet-4-6",
},
{
id: "opus",
@ -501,7 +501,7 @@ Notes:
## Per-Agent Sandbox and Tool Configuration
Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions:
Each agent can have its own sandbox and tool restrictions:
```js
{

View File

@ -50,7 +50,7 @@ Legacy import-only file (still supported, but not the main store):
- `~/.openclaw/credentials/oauth.json` (imported into `auth-profiles.json` on first use)
All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys)
All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration-reference#auth-storage)
For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets).

View File

@ -75,6 +75,25 @@ Behavior:
- Returns messages array in the raw transcript format.
- When given a `sessionId`, OpenClaw resolves it to the corresponding session key (missing ids error).
## Gateway session history and live transcript APIs
Control UI and gateway clients can use the lower level history and live transcript surfaces directly.
HTTP:
- `GET /sessions/{sessionKey}/history`
- Query params: `limit`, `cursor`, `includeTools=1`, `follow=1`
- Unknown sessions return HTTP `404` with `error.type = "not_found"`
- `follow=1` upgrades the response to an SSE stream of transcript updates for that session
WebSocket:
- `sessions.subscribe` subscribes to all session lifecycle and transcript events visible to the client
- `sessions.messages.subscribe { key }` subscribes only to `session.message` events for one session
- `sessions.messages.unsubscribe { key }` removes that targeted transcript subscription
- `session.message` carries appended transcript messages plus live usage metadata when available
- `sessions.changed` emits `phase: "message"` for transcript appends so session lists can refresh counters and previews
## sessions_send
Send a message into another session.

View File

@ -43,10 +43,39 @@
"label": "Releases",
"href": "https://github.com/openclaw/openclaw/releases",
"icon": "package"
},
{
"label": "Discord",
"href": "https://discord.com/invite/clawd",
"icon": "discord"
}
]
},
"redirects": [
{
"source": "/platforms/oracle",
"destination": "/install/oracle"
},
{
"source": "/platforms/digitalocean",
"destination": "/install/digitalocean"
},
{
"source": "/platforms/raspberry-pi",
"destination": "/install/raspberry-pi"
},
{
"source": "/brave-search",
"destination": "/tools/brave-search"
},
{
"source": "/perplexity",
"destination": "/tools/perplexity-search"
},
{
"source": "/tts",
"destination": "/tools/tts"
},
{
"source": "/messages",
"destination": "/concepts/messages"
@ -767,6 +796,14 @@
"source": "/gcp",
"destination": "/install/gcp"
},
{
"source": "/azure",
"destination": "/install/azure"
},
{
"source": "/install/azure/azure",
"destination": "/install/azure"
},
{
"source": "/platforms/fly",
"destination": "/install/fly"
@ -779,6 +816,10 @@
"source": "/platforms/gcp",
"destination": "/install/gcp"
},
{
"source": "/platforms/azure",
"destination": "/install/azure"
},
{
"source": "/platforms/macos-vm",
"destination": "/install/macos-vm"
@ -812,17 +853,9 @@
{
"tab": "Get started",
"groups": [
{
"group": "Home",
"pages": ["index"]
},
{
"group": "Overview",
"pages": ["start/showcase"]
},
{
"group": "Core concepts",
"pages": ["concepts/features"]
"pages": ["index", "start/showcase", "concepts/features"]
},
{
"group": "First steps",
@ -848,40 +881,46 @@
"groups": [
{
"group": "Install overview",
"pages": ["install/index", "install/installer"]
"pages": ["install/index", "install/installer", "install/node"]
},
{
"group": "Other install methods",
"group": "Containers",
"pages": [
"install/docker",
"install/podman",
"install/nix",
"install/ansible",
"install/bun"
"install/bun",
"install/docker",
"install/nix",
"install/podman"
]
},
{
"group": "Hosting",
"pages": [
"install/azure",
"install/digitalocean",
"install/docker-vm-runtime",
"install/exe-dev",
"install/fly",
"install/gcp",
"install/hetzner",
"install/kubernetes",
"vps",
"install/macos-vm",
"install/northflank",
"install/oracle",
"install/railway",
"install/raspberry-pi",
"install/render"
]
},
{
"group": "Maintenance",
"pages": ["install/updating", "install/migrating", "install/uninstall"]
},
{
"group": "Hosting and deployment",
"pages": [
"vps",
"install/kubernetes",
"install/fly",
"install/hetzner",
"install/gcp",
"install/macos-vm",
"install/exe-dev",
"install/railway",
"install/render",
"install/northflank"
"install/updating",
"install/migrating",
"install/uninstall",
"install/development-channels"
]
},
{
"group": "Advanced",
"pages": ["install/development-channels"]
}
]
},
@ -938,7 +977,6 @@
{
"group": "Fundamentals",
"pages": [
"pi",
"concepts/architecture",
"concepts/agent",
"concepts/agent-loop",
@ -946,13 +984,10 @@
"concepts/context",
"concepts/context-engine",
"concepts/agent-workspace",
"concepts/oauth"
"concepts/oauth",
"start/bootstrapping"
]
},
{
"group": "Bootstrapping",
"pages": ["start/bootstrapping"]
},
{
"group": "Sessions and memory",
"pages": [
@ -989,7 +1024,7 @@
"group": "Built-in tools",
"pages": [
"tools/apply-patch",
"brave-search",
"tools/brave-search",
"tools/btw",
"tools/diffs",
"tools/elevated",
@ -1000,7 +1035,7 @@
"tools/lobster",
"tools/loop-detection",
"tools/pdf",
"perplexity",
"tools/perplexity-search",
"tools/reactions",
"tools/thinking",
"tools/web"
@ -1011,7 +1046,8 @@
"pages": [
"tools/browser",
"tools/browser-login",
"tools/browser-linux-troubleshooting"
"tools/browser-linux-troubleshooting",
"tools/browser-wsl2-windows-remote-cdp-troubleshooting"
]
},
{
@ -1031,7 +1067,8 @@
"tools/skills",
"tools/skills-config",
"tools/clawhub",
"tools/plugin"
"tools/plugin",
"prose"
]
},
{
@ -1045,8 +1082,7 @@
"plugins/zalouser",
"plugins/manifest",
"plugins/agent-tools",
"tools/capability-cookbook",
"prose"
"tools/capability-cookbook"
]
},
{
@ -1074,7 +1110,7 @@
"nodes/talk",
"nodes/voicewake",
"nodes/location-command",
"tts"
"tools/tts"
]
}
]
@ -1087,12 +1123,8 @@
"pages": ["providers/index", "providers/models"]
},
{
"group": "Model concepts",
"pages": ["concepts/models"]
},
{
"group": "Configuration",
"pages": ["concepts/model-providers", "concepts/model-failover"]
"group": "Concepts and configuration",
"pages": ["concepts/models", "concepts/model-providers", "concepts/model-failover"]
},
{
"group": "Providers",
@ -1104,6 +1136,7 @@
"providers/deepgram",
"providers/github-copilot",
"providers/google",
"providers/groq",
"providers/huggingface",
"providers/kilocode",
"providers/litellm",
@ -1146,10 +1179,7 @@
"platforms/linux",
"platforms/windows",
"platforms/android",
"platforms/ios",
"platforms/digitalocean",
"platforms/oracle",
"platforms/raspberry-pi"
"platforms/ios"
]
},
{
@ -1198,6 +1228,7 @@
"gateway/heartbeat",
"gateway/doctor",
"gateway/logging",
"logging",
"gateway/gateway-lock",
"gateway/background-process",
"gateway/multiple-gateways",
@ -1228,6 +1259,7 @@
{
"group": "Networking and discovery",
"pages": [
"network",
"gateway/network-model",
"gateway/pairing",
"gateway/discovery",
@ -1261,51 +1293,76 @@
"group": "CLI commands",
"pages": [
"cli/index",
"cli/acp",
"cli/agent",
"cli/agents",
"cli/approvals",
"cli/browser",
"cli/channels",
"cli/clawbot",
"cli/completion",
"cli/config",
"cli/configure",
"cli/cron",
"cli/daemon",
"cli/dashboard",
"cli/devices",
"cli/directory",
"cli/dns",
"cli/docs",
"cli/doctor",
"cli/gateway",
"cli/health",
"cli/hooks",
"cli/logs",
"cli/memory",
"cli/message",
"cli/models",
"cli/node",
"cli/nodes",
"cli/onboard",
"cli/pairing",
"cli/plugins",
"cli/qr",
"cli/reset",
"cli/sandbox",
"cli/secrets",
"cli/security",
"cli/sessions",
"cli/setup",
"cli/skills",
"cli/status",
"cli/system",
"cli/tui",
"cli/uninstall",
"cli/update",
"cli/voicecall",
"cli/webhooks"
{
"group": "Gateway and service",
"pages": [
"cli/backup",
"cli/daemon",
"cli/doctor",
"cli/gateway",
"cli/health",
"cli/logs",
"cli/onboard",
"cli/reset",
"cli/secrets",
"cli/security",
"cli/setup",
"cli/status",
"cli/uninstall",
"cli/update"
]
},
{
"group": "Agents and sessions",
"pages": [
"cli/agent",
"cli/agents",
"cli/hooks",
"cli/memory",
"cli/message",
"cli/models",
"cli/sessions",
"cli/system"
]
},
{
"group": "Channels and messaging",
"pages": [
"cli/channels",
"cli/devices",
"cli/directory",
"cli/pairing",
"cli/qr",
"cli/voicecall"
]
},
{
"group": "Tools and execution",
"pages": [
"cli/approvals",
"cli/browser",
"cli/cron",
"cli/node",
"cli/nodes",
"cli/sandbox"
]
},
{
"group": "Configuration",
"pages": ["cli/config", "cli/configure", "cli/webhooks"]
},
{
"group": "Plugins and skills",
"pages": ["cli/plugins", "cli/skills"]
},
{
"group": "Interfaces",
"pages": ["cli/dashboard", "cli/tui"]
},
{
"group": "Utility",
"pages": ["cli/acp", "cli/clawbot", "cli/completion", "cli/dns", "cli/docs"]
}
]
},
{
@ -1329,12 +1386,14 @@
{
"group": "Technical reference",
"pages": [
"pi",
"reference/wizard",
"reference/token-use",
"reference/secretref-credential-surface",
"reference/prompt-caching",
"reference/api-usage-costs",
"reference/transcript-hygiene",
"reference/memory-config",
"date-time"
]
},
@ -1380,10 +1439,6 @@
"diagnostics/flags"
]
},
{
"group": "Node runtime",
"pages": ["install/node"]
},
{
"group": "Compaction internals",
"pages": ["reference/session-management-compaction"]

View File

@ -114,8 +114,8 @@ The provider id becomes the left side of your model ref:
modelArg: "--model",
modelAliases: {
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-sonnet-4-5": "sonnet",
"claude-opus-4-6": "opus",
"claude-sonnet-4-6": "sonnet",
},
sessionArg: "--session",
sessionMode: "existing",

View File

@ -35,7 +35,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
},
agent: {
workspace: "~/.openclaw/workspace",
model: { primary: "anthropic/claude-sonnet-4-5" },
model: { primary: "anthropic/claude-sonnet-4-6" },
},
channels: {
whatsapp: {
@ -238,15 +238,15 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
workspace: "~/.openclaw/workspace",
userTimezone: "America/Chicago",
model: {
primary: "anthropic/claude-sonnet-4-5",
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
imageModel: {
primary: "openrouter/anthropic/claude-sonnet-4-5",
primary: "openrouter/anthropic/claude-sonnet-4-6",
},
models: {
"anthropic/claude-opus-4-6": { alias: "opus" },
"anthropic/claude-sonnet-4-5": { alias: "sonnet" },
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
"openai/gpt-5.2": { alias: "gpt" },
},
thinkingDefault: "low",
@ -271,7 +271,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
maxConcurrent: 3,
heartbeat: {
every: "30m",
model: "anthropic/claude-sonnet-4-5",
model: "anthropic/claude-sonnet-4-6",
target: "last",
directPolicy: "allow", // allow (default) | block
to: "+15555550123",
@ -520,7 +520,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
agent: {
workspace: "~/.openclaw/workspace",
model: {
primary: "anthropic/claude-sonnet-4-5",
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6"],
},
},

View File

@ -1,6 +1,5 @@
---
title: "Configuration Reference"
description: "Complete field-by-field reference for ~/.openclaw/openclaw.json"
summary: "Complete reference for every OpenClaw config key, defaults, and channel settings"
read_when:
- You need exact field-level config semantics or defaults
@ -1019,7 +1018,7 @@ Periodic heartbeat runs.
identifierPolicy: "strict", // strict | off | custom
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
model: "openrouter/anthropic/claude-sonnet-4-5", // optional compaction-only model override
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
memoryFlush: {
enabled: true,
softThresholdTokens: 6000,

View File

@ -112,11 +112,11 @@ When validation fails:
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-5",
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["openai/gpt-5.2"],
},
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"openai/gpt-5.2": { alias: "GPT" },
},
},
@ -251,7 +251,7 @@ When validation fails:
Build the image first: `scripts/sandbox-setup.sh`
See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/configuration-reference#sandbox) for all options.
See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/configuration-reference#agents-defaults-sandbox) for all options.
</Accordion>

View File

@ -69,11 +69,11 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-5",
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["lmstudio/minimax-m2.5-gs32", "anthropic/claude-opus-4-6"],
},
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"lmstudio/minimax-m2.5-gs32": { alias: "MiniMax Local" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
},

View File

@ -399,7 +399,7 @@ Security defaults:
Docker installs and the containerized gateway live here:
[Docker](/install/docker)
For Docker gateway deployments, `docker-setup.sh` can bootstrap sandbox config.
For Docker gateway deployments, `scripts/docker/setup.sh` can bootstrap sandbox config.
Set `OPENCLAW_SANDBOX=1` (or `true`/`yes`/`on`) to enable that path. You can
override socket location with `OPENCLAW_DOCKER_SOCKET`. Full setup and env
reference: [Docker](/install/docker#enable-agent-sandbox-for-docker-gateway-opt-in).
@ -463,7 +463,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
## Related docs
- [OpenShell](/gateway/openshell) -- managed sandbox backend setup, workspace modes, and config reference
- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox)
- [Sandbox Configuration](/gateway/configuration-reference#agents-defaults-sandbox)
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?"
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence
- [Security](/gateway/security)

View File

@ -5,7 +5,7 @@ read_when:
title: "Security"
---
# Security 🔒
# Security
> [!WARNING]
> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model).
@ -25,7 +25,7 @@ This page explains hardening **within that model**. It does not claim hostile mu
## Quick check: `openclaw security audit`
See also: [Formal Verification (Security Models)](/security/formal-verification/)
See also: [Formal Verification (Security Models)](/security/formal-verification)
Run this regularly (especially after changing config or exposing network surfaces):

View File

@ -1,4 +1,5 @@
---
title: "Trusted Proxy Auth"
summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)"
read_when:
- Running OpenClaw behind an identity-aware proxy

View File

@ -90,7 +90,7 @@ You can reference env vars directly in config string values using `${VAR_NAME}`
}
```
See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details.
See [Configuration: Env var substitution](/gateway/configuration-reference#env-var-substitution) for full details.
## Secret refs vs `${ENV}` strings

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ title: "Help"
If you want a quick “get unstuck” flow, start here:
- **Troubleshooting:** [Start here](/help/troubleshooting)
- **Install sanity (Node/npm/PATH):** [Install](/install#nodejs--npm-path-sanity)
- **Install sanity (Node/npm/PATH):** [Install](/install/node#troubleshooting)
- **Gateway issues:** [Gateway troubleshooting](/gateway/troubleshooting)
- **Logs:** [Logging](/logging) and [Gateway logging](/gateway/logging)
- **Repairs:** [Doctor](/gateway/doctor)
@ -19,3 +19,10 @@ If you want a quick “get unstuck” flow, start here:
If youre looking for conceptual questions (not “something broke”):
- [FAQ (concepts)](/help/faq)
## Environment and debugging
- **Environment variables:** [Where OpenClaw loads env vars and precedence](/help/environment)
- **Debugging:** [Watch mode, raw streams, and dev profile](/help/debugging)
- **Testing:** [Test suites, live tests, and Docker runners](/help/testing)
- **Scripts:** [Repository helper scripts](/help/scripts)

View File

@ -308,7 +308,7 @@ This is the “common models” run we expect to keep working:
- OpenAI (non-Codex): `openai/gpt-5.2` (optional: `openai/gpt-5.1`)
- OpenAI Codex: `openai-codex/gpt-5.4`
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-5`)
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-6`)
- Google (Gemini API): `google/gemini-3.1-pro-preview` and `google/gemini-3-flash-preview` (avoid older Gemini 2.x models)
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
- Z.AI (GLM): `zai/glm-4.7`
@ -322,7 +322,7 @@ Run gateway smoke with tools + image:
Pick at least one per provider family:
- OpenAI: `openai/gpt-5.2` (or `openai/gpt-5-mini`)
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-5`)
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-6`)
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3.1-pro-preview`)
- Z.AI (GLM): `zai/glm-4.7`
- MiniMax: `minimax/minimax-m2.5`

View File

@ -106,15 +106,19 @@ The Gateway is the single source of truth for sessions, routing, and channel con
openclaw onboard --install-daemon
```
</Step>
<Step title="Pair WhatsApp and start the Gateway">
<Step title="Chat">
Open the Control UI in your browser and send a message:
```bash
openclaw channels login
openclaw gateway --port 18789
openclaw dashboard
```
Or connect a channel ([Telegram](/channels/telegram) is fastest) and chat from your phone.
</Step>
</Steps>
Need the full install and dev setup? See [Quick start](/start/quickstart).
Need the full install and dev setup? See [Getting Started](/start/getting-started).
## Dashboard

View File

@ -9,7 +9,29 @@ title: "Ansible"
# Ansible Installation
The recommended way to deploy OpenClaw to production servers is via **[openclaw-ansible](https://github.com/openclaw/openclaw-ansible)** — an automated installer with security-first architecture.
Deploy OpenClaw to production servers with **[openclaw-ansible](https://github.com/openclaw/openclaw-ansible)** -- an automated installer with security-first architecture.
<Info>
The [openclaw-ansible](https://github.com/openclaw/openclaw-ansible) repo is the source of truth for Ansible deployment. This page is a quick overview.
</Info>
## Prerequisites
| Requirement | Details |
| ----------- | --------------------------------------------------------- |
| **OS** | Debian 11+ or Ubuntu 20.04+ |
| **Access** | Root or sudo privileges |
| **Network** | Internet connection for package installation |
| **Ansible** | 2.14+ (installed automatically by the quick-start script) |
## What You Get
- **Firewall-first security** -- UFW + Docker isolation (only SSH + Tailscale accessible)
- **Tailscale VPN** -- secure remote access without exposing services publicly
- **Docker** -- isolated sandbox containers, localhost-only bindings
- **Defense in depth** -- 4-layer security architecture
- **Systemd integration** -- auto-start on boot with hardening
- **One-command setup** -- complete deployment in minutes
## Quick Start
@ -19,55 +41,50 @@ One-command install:
curl -fsSL https://raw.githubusercontent.com/openclaw/openclaw-ansible/main/install.sh | bash
```
> **📦 Full guide: [github.com/openclaw/openclaw-ansible](https://github.com/openclaw/openclaw-ansible)**
>
> The openclaw-ansible repo is the source of truth for Ansible deployment. This page is a quick overview.
## What You Get
- 🔒 **Firewall-first security**: UFW + Docker isolation (only SSH + Tailscale accessible)
- 🔐 **Tailscale VPN**: Secure remote access without exposing services publicly
- 🐳 **Docker**: Isolated sandbox containers, localhost-only bindings
- 🛡️ **Defense in depth**: 4-layer security architecture
- 🚀 **One-command setup**: Complete deployment in minutes
- 🔧 **Systemd integration**: Auto-start on boot with hardening
## Requirements
- **OS**: Debian 11+ or Ubuntu 20.04+
- **Access**: Root or sudo privileges
- **Network**: Internet connection for package installation
- **Ansible**: 2.14+ (installed automatically by quick-start script)
## What Gets Installed
The Ansible playbook installs and configures:
1. **Tailscale** (mesh VPN for secure remote access)
2. **UFW firewall** (SSH + Tailscale ports only)
3. **Docker CE + Compose V2** (for agent sandboxes)
4. **Node.js 24 + pnpm** (runtime dependencies; Node 22 LTS, currently `22.16+`, remains supported for compatibility)
5. **OpenClaw** (host-based, not containerized)
6. **Systemd service** (auto-start with security hardening)
1. **Tailscale** -- mesh VPN for secure remote access
2. **UFW firewall** -- SSH + Tailscale ports only
3. **Docker CE + Compose V2** -- for agent sandboxes
4. **Node.js 24 + pnpm** -- runtime dependencies (Node 22 LTS, currently `22.16+`, remains supported)
5. **OpenClaw** -- host-based, not containerized
6. **Systemd service** -- auto-start with security hardening
Note: The gateway runs **directly on the host** (not in Docker), but agent sandboxes use Docker for isolation. See [Sandboxing](/gateway/sandboxing) for details.
<Note>
The gateway runs directly on the host (not in Docker), but agent sandboxes use Docker for isolation. See [Sandboxing](/gateway/sandboxing) for details.
</Note>
## Post-Install Setup
After installation completes, switch to the openclaw user:
<Steps>
<Step title="Switch to the openclaw user">
```bash
sudo -i -u openclaw
```
</Step>
<Step title="Run the onboarding wizard">
The post-install script guides you through configuring OpenClaw settings.
</Step>
<Step title="Connect messaging providers">
Log in to WhatsApp, Telegram, Discord, or Signal:
```bash
openclaw channels login
```
</Step>
<Step title="Verify the installation">
```bash
sudo systemctl status openclaw
sudo journalctl -u openclaw -f
```
</Step>
<Step title="Connect to Tailscale">
Join your VPN mesh for secure remote access.
</Step>
</Steps>
```bash
sudo -i -u openclaw
```
The post-install script will guide you through:
1. **Onboarding wizard**: Configure OpenClaw settings
2. **Provider login**: Connect WhatsApp/Telegram/Discord/Signal
3. **Gateway testing**: Verify the installation
4. **Tailscale setup**: Connect to your VPN mesh
### Quick commands
### Quick Commands
```bash
# Check service status
@ -86,115 +103,120 @@ openclaw channels login
## Security Architecture
### 4-Layer Defense
The deployment uses a 4-layer defense model:
1. **Firewall (UFW)**: Only SSH (22) + Tailscale (41641/udp) exposed publicly
2. **VPN (Tailscale)**: Gateway accessible only via VPN mesh
3. **Docker Isolation**: DOCKER-USER iptables chain prevents external port exposure
4. **Systemd Hardening**: NoNewPrivileges, PrivateTmp, unprivileged user
1. **Firewall (UFW)** -- only SSH (22) + Tailscale (41641/udp) exposed publicly
2. **VPN (Tailscale)** -- gateway accessible only via VPN mesh
3. **Docker isolation** -- DOCKER-USER iptables chain prevents external port exposure
4. **Systemd hardening** -- NoNewPrivileges, PrivateTmp, unprivileged user
### Verification
Test external attack surface:
To verify your external attack surface:
```bash
nmap -p- YOUR_SERVER_IP
```
Should show **only port 22** (SSH) open. All other services (gateway, Docker) are locked down.
Only port 22 (SSH) should be open. All other services (gateway, Docker) are locked down.
### Docker Availability
Docker is installed for **agent sandboxes** (isolated tool execution), not for running the gateway itself. The gateway binds to localhost only and is accessible via Tailscale VPN.
See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for sandbox configuration.
Docker is installed for agent sandboxes (isolated tool execution), not for running the gateway itself. See [Multi-Agent Sandbox and Tools](/tools/multi-agent-sandbox-tools) for sandbox configuration.
## Manual Installation
If you prefer manual control over the automation:
```bash
# 1. Install prerequisites
sudo apt update && sudo apt install -y ansible git
<Steps>
<Step title="Install prerequisites">
```bash
sudo apt update && sudo apt install -y ansible git
```
</Step>
<Step title="Clone the repository">
```bash
git clone https://github.com/openclaw/openclaw-ansible.git
cd openclaw-ansible
```
</Step>
<Step title="Install Ansible collections">
```bash
ansible-galaxy collection install -r requirements.yml
```
</Step>
<Step title="Run the playbook">
```bash
./run-playbook.sh
```
# 2. Clone repository
git clone https://github.com/openclaw/openclaw-ansible.git
cd openclaw-ansible
Alternatively, run directly and then manually execute the setup script afterward:
```bash
ansible-playbook playbook.yml --ask-become-pass
# Then run: /tmp/openclaw-setup.sh
```
# 3. Install Ansible collections
ansible-galaxy collection install -r requirements.yml
</Step>
</Steps>
# 4. Run playbook
./run-playbook.sh
# Or run directly (then manually execute /tmp/openclaw-setup.sh after)
# ansible-playbook playbook.yml --ask-become-pass
```
## Updating OpenClaw
## Updating
The Ansible installer sets up OpenClaw for manual updates. See [Updating](/install/updating) for the standard update flow.
To re-run the Ansible playbook (e.g., for configuration changes):
To re-run the Ansible playbook (for example, for configuration changes):
```bash
cd openclaw-ansible
./run-playbook.sh
```
Note: This is idempotent and safe to run multiple times.
This is idempotent and safe to run multiple times.
## Troubleshooting
### Firewall blocks my connection
<AccordionGroup>
<Accordion title="Firewall blocks my connection">
- Ensure you can access via Tailscale VPN first
- SSH access (port 22) is always allowed
- The gateway is only accessible via Tailscale by design
</Accordion>
<Accordion title="Service will not start">
```bash
# Check logs
sudo journalctl -u openclaw -n 100
If you're locked out:
# Verify permissions
sudo ls -la /opt/openclaw
- Ensure you can access via Tailscale VPN first
- SSH access (port 22) is always allowed
- The gateway is **only** accessible via Tailscale by design
# Test manual start
sudo -i -u openclaw
cd ~/openclaw
openclaw gateway run
```
### Service will not start
</Accordion>
<Accordion title="Docker sandbox issues">
```bash
# Verify Docker is running
sudo systemctl status docker
```bash
# Check logs
sudo journalctl -u openclaw -n 100
# Check sandbox image
sudo docker images | grep openclaw-sandbox
# Verify permissions
sudo ls -la /opt/openclaw
# Build sandbox image if missing
cd /opt/openclaw/openclaw
sudo -u openclaw ./scripts/sandbox-setup.sh
```
# Test manual start
sudo -i -u openclaw
cd ~/openclaw
pnpm start
```
### Docker sandbox issues
```bash
# Verify Docker is running
sudo systemctl status docker
# Check sandbox image
sudo docker images | grep openclaw-sandbox
# Build sandbox image if missing
cd /opt/openclaw/openclaw
sudo -u openclaw ./scripts/sandbox-setup.sh
```
### Provider login fails
Make sure you're running as the `openclaw` user:
```bash
sudo -i -u openclaw
openclaw channels login
```
</Accordion>
<Accordion title="Provider login fails">
Make sure you are running as the `openclaw` user:
```bash
sudo -i -u openclaw
openclaw channels login
```
</Accordion>
</AccordionGroup>
## Advanced Configuration
For detailed security architecture and troubleshooting:
For detailed security architecture and troubleshooting, see the openclaw-ansible repo:
- [Security Architecture](https://github.com/openclaw/openclaw-ansible/blob/main/docs/security.md)
- [Technical Details](https://github.com/openclaw/openclaw-ansible/blob/main/docs/architecture.md)
@ -202,7 +224,7 @@ For detailed security architecture and troubleshooting:
## Related
- [openclaw-ansible](https://github.com/openclaw/openclaw-ansible) full deployment guide
- [Docker](/install/docker) containerized gateway setup
- [Sandboxing](/gateway/sandboxing) agent sandbox configuration
- [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) — per-agent isolation
- [openclaw-ansible](https://github.com/openclaw/openclaw-ansible) -- full deployment guide
- [Docker](/install/docker) -- containerized gateway setup
- [Sandboxing](/gateway/sandboxing) -- agent sandbox configuration
- [Multi-Agent Sandbox and Tools](/tools/multi-agent-sandbox-tools) -- per-agent isolation

178
docs/install/azure.md Normal file
View File

@ -0,0 +1,178 @@
---
summary: "Run OpenClaw Gateway 24/7 on an Azure Linux VM with durable state"
read_when:
- You want OpenClaw running 24/7 on Azure with Network Security Group hardening
- You want a production-grade, always-on OpenClaw Gateway on your own Azure Linux VM
- You want secure administration with Azure Bastion SSH
- You want repeatable deployments with Azure Resource Manager templates
title: "Azure"
---
# OpenClaw on Azure Linux VM
This guide sets up an Azure Linux VM, applies Network Security Group (NSG) hardening, configures Azure Bastion (managed Azure SSH entry point), and installs OpenClaw.
## What youll do
- Deploy Azure compute and network resources with Azure Resource Manager (ARM) templates
- Apply Azure Network Security Group (NSG) rules so VM SSH is allowed only from Azure Bastion
- Use Azure Bastion for SSH access
- Install OpenClaw with the installer script
- Verify the Gateway
## Before you start
Youll need:
- An Azure subscription with permission to create compute and network resources
- Azure CLI installed (see [Azure CLI install steps](https://learn.microsoft.com/cli/azure/install-azure-cli) if needed)
<Steps>
<Step title="Sign in to Azure CLI">
```bash
az login # Sign in and select your Azure subscription
az extension add -n ssh # Extension required for Azure Bastion SSH management
```
</Step>
<Step title="Register required resource providers (one-time)">
```bash
az provider register --namespace Microsoft.Compute
az provider register --namespace Microsoft.Network
```
Verify Azure resource provider registration. Wait until both show `Registered`.
```bash
az provider show --namespace Microsoft.Compute --query registrationState -o tsv
az provider show --namespace Microsoft.Network --query registrationState -o tsv
```
</Step>
<Step title="Set deployment variables">
```bash
RG="rg-openclaw"
LOCATION="westus2"
TEMPLATE_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.json"
PARAMS_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.parameters.json"
```
</Step>
<Step title="Select SSH key">
Use your existing public key if you have one:
```bash
SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"
```
If you dont have an SSH key yet, run the following:
```bash
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519 -C "you@example.com"
SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"
```
</Step>
<Step title="Select VM size and OS disk size">
Set VM and disk sizing variables:
```bash
VM_SIZE="Standard_B2as_v2"
OS_DISK_SIZE_GB=64
```
Choose a VM size and OS disk size that are available in your Azure subscription/region and matches your workload:
- Start smaller for light usage and scale up later
- Use more vCPU/RAM/OS disk size for heavier automation, more channels, or larger model/tool workloads
- If a VM size is unavailable in your region or subscription quota, pick the closest available SKU
List VM sizes available in your target region:
```bash
az vm list-skus --location "${LOCATION}" --resource-type virtualMachines -o table
```
Check your current VM vCPU and OS disk size usage/quota:
```bash
az vm list-usage --location "${LOCATION}" -o table
```
</Step>
<Step title="Create the resource group">
```bash
az group create -n "${RG}" -l "${LOCATION}"
```
</Step>
<Step title="Deploy resources">
This command applies your selected SSH key, VM size, and OS disk size.
```bash
az deployment group create \
-g "${RG}" \
--template-uri "${TEMPLATE_URI}" \
--parameters "${PARAMS_URI}" \
--parameters location="${LOCATION}" \
--parameters vmSize="${VM_SIZE}" \
--parameters osDiskSizeGb="${OS_DISK_SIZE_GB}" \
--parameters sshPublicKey="${SSH_PUB_KEY}"
```
</Step>
<Step title="SSH into the VM through Azure Bastion">
```bash
RG="rg-openclaw"
VM_NAME="vm-openclaw"
BASTION_NAME="bas-openclaw"
ADMIN_USERNAME="openclaw"
VM_ID="$(az vm show -g "${RG}" -n "${VM_NAME}" --query id -o tsv)"
az network bastion ssh \
--name "${BASTION_NAME}" \
--resource-group "${RG}" \
--target-resource-id "${VM_ID}" \
--auth-type ssh-key \
--username "${ADMIN_USERNAME}" \
--ssh-key ~/.ssh/id_ed25519
```
</Step>
<Step title="Install OpenClaw (in the VM shell)">
```bash
curl -fsSL https://openclaw.ai/install.sh -o /tmp/openclaw-install.sh
bash /tmp/openclaw-install.sh
rm -f /tmp/openclaw-install.sh
openclaw --version
```
The installer script handles Node detection/installation and runs onboarding by default.
</Step>
<Step title="Verify the Gateway">
After onboarding completes:
```bash
openclaw gateway status
```
Most enterprise Azure teams already have GitHub Copilot licenses. If that is your case, we recommend choosing the GitHub Copilot provider in the OpenClaw onboarding wizard. See [GitHub Copilot provider](/providers/github-copilot).
The included ARM template uses Ubuntu image `version: "latest"` for convenience. If you need reproducible builds, pin a specific image version in `infra/azure/templates/azuredeploy.json` (you can list versions with `az vm image list --publisher Canonical --offer ubuntu-24_04-lts --sku server --all -o table`).
</Step>
</Steps>
## Next steps
- Set up messaging channels: [Channels](/channels)
- Pair local devices as nodes: [Nodes](/nodes)
- Configure the Gateway: [Gateway configuration](/gateway/configuration)
- For more details on OpenClaw Azure deployment with the GitHub Copilot model provider: [OpenClaw on Azure with GitHub Copilot](https://github.com/johnsonshi/openclaw-azure-github-copilot)

View File

@ -6,49 +6,45 @@ read_when:
title: "Bun (Experimental)"
---
# Bun (experimental)
# Bun (Experimental)
Goal: run this repo with **Bun** (optional, not recommended for WhatsApp/Telegram)
without diverging from pnpm workflows.
<Warning>
Bun is **not recommended for gateway runtime** (known issues with WhatsApp and Telegram). Use Node for production.
</Warning>
⚠️ **Not recommended for Gateway runtime** (WhatsApp/Telegram bugs). Use Node for production.
## Status
- Bun is an optional local runtime for running TypeScript directly (`bun run …`, `bun --watch …`).
- `pnpm` is the default for builds and remains fully supported (and used by some docs tooling).
- Bun cannot use `pnpm-lock.yaml` and will ignore it.
Bun is an optional local runtime for running TypeScript directly (`bun run ...`, `bun --watch ...`). The default package manager remains `pnpm`, which is fully supported and used by docs tooling. Bun cannot use `pnpm-lock.yaml` and will ignore it.
## Install
Default:
<Steps>
<Step title="Install dependencies">
```sh
bun install
```
```sh
bun install
```
`bun.lock` / `bun.lockb` are gitignored, so there is no repo churn. To skip lockfile writes entirely:
Note: `bun.lock`/`bun.lockb` are gitignored, so theres no repo churn either way. If you want _no lockfile writes_:
```sh
bun install --no-save
```
```sh
bun install --no-save
```
</Step>
<Step title="Build and test">
```sh
bun run build
bun run vitest run
```
</Step>
</Steps>
## Build / Test (Bun)
## Lifecycle Scripts
```sh
bun run build
bun run vitest run
```
Bun blocks dependency lifecycle scripts unless explicitly trusted. For this repo, the commonly blocked scripts are not required:
## Bun lifecycle scripts (blocked by default)
- `@whiskeysockets/baileys` `preinstall` -- checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`)
- `protobufjs` `postinstall` -- emits warnings about incompatible version schemes (no build artifacts)
Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
For this repo, the commonly blocked scripts are not required:
- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`).
- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts).
If you hit a real runtime issue that requires these scripts, trust them explicitly:
If you hit a runtime issue that requires these scripts, trust them explicitly:
```sh
bun pm trust @whiskeysockets/baileys protobufjs
@ -56,4 +52,4 @@ bun pm trust @whiskeysockets/baileys protobufjs
## Caveats
- Some scripts still hardcode pnpm (e.g. `docs:build`, `ui:*`, `protocol:check`). Run those via pnpm for now.
Some scripts still hardcode pnpm (for example `docs:build`, `ui:*`, `protocol:check`). Run those via pnpm for now.

View File

@ -1,77 +1,120 @@
---
summary: "Stable, beta, and dev channels: semantics, switching, and tagging"
summary: "Stable, beta, and dev channels: semantics, switching, pinning, and tagging"
read_when:
- You want to switch between stable/beta/dev
- You want to pin a specific version, tag, or SHA
- You are tagging or publishing prereleases
title: "Development Channels"
title: "Release Channels"
sidebarTitle: "Release Channels"
---
# Development channels
Last updated: 2026-01-21
OpenClaw ships three update channels:
- **stable**: npm dist-tag `latest`.
- **stable**: npm dist-tag `latest`. Recommended for most users.
- **beta**: npm dist-tag `beta` (builds under test).
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
The `main` branch is for experimentation and active development. It may contain
incomplete features or breaking changes. Do not use it for production gateways.
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
without changing the version number dist-tags are the source of truth for npm installs.
without changing the version number -- dist-tags are the source of truth for npm installs.
## Switching channels
Git checkout:
```bash
openclaw update --channel stable
openclaw update --channel beta
openclaw update --channel dev
```
- `stable`/`beta` check out the latest matching tag (often the same tag).
- `dev` switches to `main` and rebases on the upstream.
`--channel` persists your choice in config (`update.channel`) and aligns the
install method:
npm/pnpm global install:
- **`stable`/`beta`** (package installs): updates via the matching npm dist-tag.
- **`stable`/`beta`** (git installs): checks out the latest matching git tag.
- **`dev`**: ensures a git checkout (default `~/openclaw`, override with
`OPENCLAW_GIT_DIR`), switches to `main`, rebases on upstream, builds, and
installs the global CLI from that checkout.
Tip: if you want stable + dev in parallel, keep two clones and point your
gateway at the stable one.
## One-off version or tag targeting
Use `--tag` to target a specific dist-tag, version, or package spec for a single
update **without** changing your persisted channel:
```bash
openclaw update --channel stable
openclaw update --channel beta
openclaw update --channel dev
# Install a specific version
openclaw update --tag 2026.3.14
# Install from the beta dist-tag (one-off, does not persist)
openclaw update --tag beta
# Install from GitHub main branch (npm tarball)
openclaw update --tag main
# Install a specific npm package spec
openclaw update --tag openclaw@2026.3.12
```
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
Notes:
When you **explicitly** switch channels with `--channel`, OpenClaw also aligns
the install method:
- `--tag` applies to **package (npm) installs only**. Git installs ignore it.
- The tag is not persisted. Your next `openclaw update` uses your configured
channel as usual.
- Downgrade protection: if the target version is older than your current version,
OpenClaw prompts for confirmation (skip with `--yes`).
- `dev` ensures a git checkout (default `~/openclaw`, override with `OPENCLAW_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` installs from npm using the matching dist-tag.
## Dry run
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
Preview what `openclaw update` would do without making changes:
```bash
openclaw update --dry-run
openclaw update --channel beta --dry-run
openclaw update --tag 2026.3.14 --dry-run
openclaw update --dry-run --json
```
The dry run shows the effective channel, target version, planned actions, and
whether a downgrade confirmation would be required.
## Plugins and channels
When you switch channels with `openclaw update`, OpenClaw also syncs plugin sources:
When you switch channels with `openclaw update`, OpenClaw also syncs plugin
sources:
- `dev` prefers bundled plugins from the git checkout.
- `stable` and `beta` restore npm-installed plugin packages.
- npm-installed plugins are updated after the core update completes.
## Checking current status
```bash
openclaw update status
```
Shows the active channel, install kind (git or package), current version, and
source (config, git tag, git branch, or default).
## Tagging best practices
- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable, `vYYYY.M.D-beta.N` for beta).
- Tag releases you want git checkouts to land on (`vYYYY.M.D` for stable,
`vYYYY.M.D-beta.N` for beta).
- `vYYYY.M.D.beta.N` is also recognized for compatibility, but prefer `-beta.N`.
- Legacy `vYYYY.M.D-<patch>` tags are still recognized as stable (non-beta).
- Keep tags immutable: never move or reuse a tag.
- npm dist-tags remain the source of truth for npm installs:
- `latest` → stable
- `beta` → candidate build
- `dev` main snapshot (optional)
- `latest` -> stable
- `beta` -> candidate build
- `dev` -> main snapshot (optional)
## macOS app availability
Beta and dev builds may **not** include a macOS app release. Thats OK:
Beta and dev builds may **not** include a macOS app release. That is OK:
- The git tag and npm dist-tag can still be published.
- Call out “no macOS build for this beta” in release notes or changelog.
- Call out "no macOS build for this beta" in release notes or changelog.

View File

@ -0,0 +1,129 @@
---
summary: "Host OpenClaw on a DigitalOcean Droplet"
read_when:
- Setting up OpenClaw on DigitalOcean
- Looking for a simple paid VPS for OpenClaw
title: "DigitalOcean"
---
# DigitalOcean
Run a persistent OpenClaw Gateway on a DigitalOcean Droplet.
## Prerequisites
- DigitalOcean account ([signup](https://cloud.digitalocean.com/registrations/new))
- SSH key pair (or willingness to use password auth)
- About 20 minutes
## Setup
<Steps>
<Step title="Create a Droplet">
<Warning>
Use a clean base image (Ubuntu 24.04 LTS). Avoid third-party Marketplace 1-click images unless you have reviewed their startup scripts and firewall defaults.
</Warning>
1. Log into [DigitalOcean](https://cloud.digitalocean.com/).
2. Click **Create > Droplets**.
3. Choose:
- **Region:** Closest to you
- **Image:** Ubuntu 24.04 LTS
- **Size:** Basic, Regular, 1 vCPU / 1 GB RAM / 25 GB SSD
- **Authentication:** SSH key (recommended) or password
4. Click **Create Droplet** and note the IP address.
</Step>
<Step title="Connect and install">
```bash
ssh root@YOUR_DROPLET_IP
apt update && apt upgrade -y
# Install Node.js 24
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
# Install OpenClaw
curl -fsSL https://openclaw.ai/install.sh | bash
openclaw --version
```
</Step>
<Step title="Run onboarding">
```bash
openclaw onboard --install-daemon
```
The wizard walks you through model auth, channel setup, gateway token generation, and daemon installation (systemd).
</Step>
<Step title="Add swap (recommended for 1 GB Droplets)">
```bash
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
```
</Step>
<Step title="Verify the gateway">
```bash
openclaw status
systemctl --user status openclaw-gateway.service
journalctl --user -u openclaw-gateway.service -f
```
</Step>
<Step title="Access the Control UI">
The gateway binds to loopback by default. Pick one of these options.
**Option A: SSH tunnel (simplest)**
```bash
# From your local machine
ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
```
Then open `http://localhost:18789`.
**Option B: Tailscale Serve**
```bash
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
openclaw config set gateway.tailscale.mode serve
openclaw gateway restart
```
Then open `https://<magicdns>/` from any device on your tailnet.
**Option C: Tailnet bind (no Serve)**
```bash
openclaw config set gateway.bind tailnet
openclaw gateway restart
```
Then open `http://<tailscale-ip>:18789` (token required).
</Step>
</Steps>
## Troubleshooting
**Gateway will not start** -- Run `openclaw doctor --non-interactive` and check logs with `journalctl --user -u openclaw-gateway.service -n 50`.
**Port already in use** -- Run `lsof -i :18789` to find the process, then stop it.
**Out of memory** -- Verify swap is active with `free -h`. If still hitting OOM, use API-based models (Claude, GPT) rather than local models, or upgrade to a 2 GB Droplet.
## Next steps
- [Channels](/channels) -- connect Telegram, WhatsApp, Discord, and more
- [Gateway configuration](/gateway/configuration) -- all config options
- [Updating](/install/updating) -- keep OpenClaw up to date

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