Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

Submodule openclaw deleted from 0fe6cf06b2

View File

@@ -0,0 +1,30 @@
# detect-secrets exclusion patterns (regex)
#
# Note: detect-secrets does not read this file by default. If you want these
# applied, wire them into your scan command (e.g. translate to --exclude-files
# / --exclude-lines) or into a baseline's filters_used.
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
# Generated output and vendored assets.
pattern = (^|/)(dist|vendor)/
# Local config file with allowlist patterns.
pattern = (^|/)\.detect-secrets\.cfg$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
# UI label string for Anthropic auth mode.
pattern = case \.apiKeyEnv: "API key \(env var\)"
# CodingKeys mapping uses apiKey literal.
pattern = case apikey = "apiKey"
# Schema labels referencing password fields (not actual secrets).
pattern = "gateway\.remote\.password"
pattern = "gateway\.auth\.password"
# Schema label for talk API key (label text only).
pattern = "talk\.apiKey"
# checking for typeof is not something we care about.
pattern = === "string"
# specific optional-chaining password check that didn't match the line above.
pattern = typeof remote\?\.password === "string"

60
openclaw/.dockerignore Normal file
View File

@@ -0,0 +1,60 @@
.git
.worktrees
.bun-cache
.bun
.tmp
**/.tmp
.DS_Store
**/.DS_Store
*.png
*.jpg
*.jpeg
*.webp
*.gif
*.mp4
*.mov
*.wav
*.mp3
node_modules
**/node_modules
.pnpm-store
**/.pnpm-store
.turbo
**/.turbo
.cache
**/.cache
.next
**/.next
coverage
**/coverage
*.log
tmp
**/tmp
# build artifacts
dist
**/dist
apps/macos/.build
apps/ios/build
**/*.trace
# large app trees not needed for CLI build
apps/
assets/
Peekaboo/
Swabble/
Core/
Users/
vendor/
# Needed for building the Canvas A2UI bundle during Docker image builds.
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
!apps/shared/
!apps/shared/OpenClawKit/
!apps/shared/OpenClawKit/Tools/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
!vendor/a2ui/
!vendor/a2ui/renderers/
!vendor/a2ui/renderers/lit/
!vendor/a2ui/renderers/lit/**

80
openclaw/.env.example Normal file
View File

@@ -0,0 +1,80 @@
# OpenClaw .env example
#
# Quick start:
# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons).
# 2) Fill only the values you use.
# 3) Keep real secrets out of git.
#
# Env-source precedence for environment variables (highest -> lowest):
# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block.
# Existing non-empty process env vars are not overridden by dotenv/config env loading.
# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json)
# are resolved separately from env loading and often take precedence over env fallbacks.
# -----------------------------------------------------------------------------
# Gateway auth + paths
# -----------------------------------------------------------------------------
# Recommended if the gateway binds beyond loopback.
OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token
# Example generator: openssl rand -hex 32
# Optional alternative auth mode (use token OR password).
# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password
# Optional path overrides (defaults shown for reference).
# OPENCLAW_STATE_DIR=~/.openclaw
# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json
# OPENCLAW_HOME=~
# Optional: import missing keys from your login shell profile.
# OPENCLAW_LOAD_SHELL_ENV=1
# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000
# -----------------------------------------------------------------------------
# Model provider API keys (set at least one)
# -----------------------------------------------------------------------------
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=...
# OPENROUTER_API_KEY=sk-or-...
# OPENCLAW_LIVE_OPENAI_KEY=sk-...
# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-...
# OPENCLAW_LIVE_GEMINI_KEY=...
# OPENAI_API_KEY_1=...
# ANTHROPIC_API_KEY_1=...
# GEMINI_API_KEY_1=...
# GOOGLE_API_KEY=...
# OPENAI_API_KEYS=sk-1,sk-2
# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2
# GEMINI_API_KEYS=key-1,key-2
# Optional additional providers
# ZAI_API_KEY=...
# AI_GATEWAY_API_KEY=...
# MINIMAX_API_KEY=...
# SYNTHETIC_API_KEY=...
# -----------------------------------------------------------------------------
# Channels (only set what you enable)
# -----------------------------------------------------------------------------
# TELEGRAM_BOT_TOKEN=123456:ABCDEF...
# DISCORD_BOT_TOKEN=...
# SLACK_BOT_TOKEN=xoxb-...
# SLACK_APP_TOKEN=xapp-...
# Optional channel env fallbacks
# MATTERMOST_BOT_TOKEN=...
# MATTERMOST_URL=https://chat.example.com
# ZALO_BOT_TOKEN=...
# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:...
# -----------------------------------------------------------------------------
# Tools + voice/media (optional)
# -----------------------------------------------------------------------------
# BRAVE_API_KEY=...
# PERPLEXITY_API_KEY=pplx-...
# FIRECRAWL_API_KEY=...
# ELEVENLABS_API_KEY=...
# XI_API_KEY=... # alias for ElevenLabs
# DEEPGRAM_API_KEY=...

3
openclaw/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
* text=auto eol=lf
CLAUDE.md -text
src/gateway/server-methods/CLAUDE.md -text

1
openclaw/.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ["https://github.com/sponsors/steipete"]

View File

@@ -0,0 +1,95 @@
name: Bug report
description: Report a defect or unexpected behavior in OpenClaw.
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest deterministic repro path.
placeholder: |
1. Configure channel X.
2. Send message Y.
3. Run command Z.
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.
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".
validations:
required: true
- type: input
id: version
attributes:
label: OpenClaw version
description: Exact version/build tested.
placeholder: <version such as 2026.2.17>
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
description: OS and version where this occurs.
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
validations:
required: true
- type: input
id: install_method
attributes:
label: Install method
description: How OpenClaw was installed or launched.
placeholder: npm global / pnpm dev / docker / mac app
- type: textarea
id: logs
attributes:
label: Logs, screenshots, and evidence
description: Include redacted logs/screenshots/recordings that prove the behavior.
render: shell
- type: textarea
id: impact
attributes:
label: Impact and severity
description: |
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
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
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above.
placeholder: Regression started after upgrade from <previous-version>; temporary workaround is ...

View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Onboarding
url: https://discord.gg/clawd
about: "New to OpenClaw? Join Discord for setup guidance in #help."
- name: Support
url: https://discord.gg/clawd
about: "Get help from the OpenClaw community on Discord in #help."

View File

@@ -0,0 +1,70 @@
name: Feature request
description: Propose a new capability or product improvement.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Help us evaluate this request with concrete use cases and tradeoffs.
- type: textarea
id: summary
attributes:
label: Summary
description: One-line statement of the requested capability.
placeholder: Add per-channel default response prefix.
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What user pain this solves and why current behavior is insufficient.
placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: Proposed solution
description: Desired behavior/API/UX with as much specificity as possible.
placeholder: Support channels.<channel>.responsePrefix with default fallback and account-level override.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches considered and why they are weaker.
placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
- type: textarea
id: impact
attributes:
label: Impact
description: |
Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
Include:
- Affected users/systems/channels
- Severity (annoying, blocks workflow, etc.)
- Frequency (always/intermittent/edge case)
- Consequence (delays, errors, extra manual work, etc.)
placeholder: |
Affected: Multi-team shared channels
Severity: Medium
Frequency: Daily
Consequence: +20 minutes/day/operator and delayed alerts
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence/examples
description: Prior art, links, screenshots, logs, or metrics.
placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Extra context, constraints, or references not covered above.
placeholder: Must remain backward-compatible with existing config keys.

22
openclaw/.github/actionlint.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-16vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404-arm
# Ignore patterns for known issues
paths:
.github/workflows/**/*.yml:
ignore:
# Ignore shellcheck warnings (we run shellcheck separately)
- "shellcheck reported issue.+"
# Ignore intentional if: false for disabled jobs
- 'constant expression "false" in condition'
# actionlint's built-in runner label allowlist lags Blacksmith additions.
- 'label "blacksmith-16vcpu-[^"]+" is unknown\.'

View File

@@ -0,0 +1,53 @@
name: Detect docs-only changes
description: >
Outputs docs_only=true when all changed files are under docs/ or are
markdown (.md/.mdx). Fail-safe: if detection fails, outputs false (run
everything). Uses git diff — no API calls, no extra permissions needed.
outputs:
docs_only:
description: "'true' if all changes are docs/markdown, 'false' otherwise"
value: ${{ steps.check.outputs.docs_only }}
docs_changed:
description: "'true' if any changed file is under docs/ or is markdown"
value: ${{ steps.check.outputs.docs_changed }}
runs:
using: composite
steps:
- name: Detect docs-only changes
id: check
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
# Use the exact base SHA from the event payload — stable regardless
# of base branch movement (avoids origin/<ref> drift).
BASE="${{ github.event.pull_request.base.sha }}"
fi
# Fail-safe: if we can't diff, assume non-docs (run everything)
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Check if any changed file is a doc
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
if [ -n "$DOCS" ]; then
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
else
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
fi
# Check if all changed files are docs or markdown
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
if [ -z "$NON_DOCS" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping heavy jobs"
else
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi

View File

@@ -0,0 +1,98 @@
name: Setup Node environment
description: >
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
and run pnpm install. Requires actions/checkout to run first.
inputs:
node-version:
description: Node.js version to install.
required: false
default: "22.x"
pnpm-version:
description: pnpm version for corepack.
required: false
default: "10.23.0"
install-bun:
description: Whether to install Bun alongside Node.
required: false
default: "true"
frozen-lockfile:
description: Whether to use --frozen-lockfile for install.
required: false
default: "true"
runs:
using: composite
steps:
- name: Checkout submodules (retry)
shell: bash
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ inputs.node-version }}
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: ${{ inputs.pnpm-version }}
cache-key-suffix: "node22"
- name: Setup Bun
if: inputs.install-bun == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.9+cf6cdbbba"
- name: Runtime versions
shell: bash
run: |
node -v
npm -v
pnpm -v
if command -v bun &>/dev/null; then bun -v; fi
- name: Capture node path
shell: bash
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
- name: Install dependencies
shell: bash
env:
CI: "true"
FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }}
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
case "$FROZEN_LOCKFILE" in
true) LOCKFILE_FLAG="--frozen-lockfile" ;;
false) LOCKFILE_FLAG="" ;;
*)
echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)"
exit 2
;;
esac
install_args=(
install
--ignore-scripts=false
--config.engine-strict=false
--config.enable-pre-post-scripts=true
)
if [ -n "$LOCKFILE_FLAG" ]; then
install_args+=("$LOCKFILE_FLAG")
fi
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"

View File

@@ -0,0 +1,47 @@
name: Setup pnpm + store cache
description: Prepare pnpm via corepack and restore pnpm store cache.
inputs:
pnpm-version:
description: pnpm version to activate via corepack.
required: false
default: "10.23.0"
cache-key-suffix:
description: Suffix appended to the cache key.
required: false
default: "node22"
runs:
using: composite
steps:
- name: Setup pnpm (corepack retry)
shell: bash
env:
PNPM_VERSION: ${{ inputs.pnpm-version }}
run: |
set -euo pipefail
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
exit 2
fi
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-

126
openclaw/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
# Dependabot configuration
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
registries:
npm-npmjs:
type: npm-registry
url: https://registry.npmjs.org
replaces-base: true
updates:
# npm dependencies (root)
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
production:
dependency-type: production
update-types:
- minor
- patch
development:
dependency-type: development
update-types:
- minor
- patch
open-pull-requests-limit: 10
registries:
- npm-npmjs
# GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - macOS app
- package-ecosystem: swift
directory: /apps/macos
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - shared MoltbotKit
- package-ecosystem: swift
directory: /apps/shared/MoltbotKit
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - Swabble
- package-ecosystem: swift
directory: /Swabble
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Gradle - Android app
- package-ecosystem: gradle
directory: /apps/android
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
android-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Docker base images
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
docker-images:
patterns:
- "*"
open-pull-requests-limit: 5

View File

@@ -0,0 +1,64 @@
# OpenClaw Codebase Patterns
**Always reuse existing code - no redundancy!**
## Tech Stack
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
- **Language**: TypeScript (ESM, strict mode)
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
- **Tests**: Vitest with V8 coverage
- **CLI Framework**: Commander + clack/prompts
- **Build**: tsdown (outputs to `dist/`)
## Anti-Redundancy Rules
- Avoid files that just re-export from another file. Import directly from the original source.
- If a function already exists, import it - do NOT create a duplicate in another file.
- Before creating any formatter, utility, or helper, search for existing implementations first.
## Source of Truth Locations
### Formatting Utilities (`src/infra/`)
- **Time formatting**: `src\infra\format-time`
**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.**
### Terminal Output (`src/terminal/`)
- Tables: `src/terminal/table.ts` (`renderTable`)
- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.)
- Progress: `src/cli/progress.ts` (spinners, progress bars)
### CLI Patterns
- CLI option wiring: `src/cli/`
- Commands: `src/commands/`
- Dependency injection via `createDefaultDeps`
## Import Conventions
- Use `.js` extension for cross-package imports (ESM)
- Direct imports only - no re-export wrapper files
- Types: `import type { X }` for type-only imports
## Code Quality
- TypeScript (ESM), strict typing, avoid `any`
- Keep files under ~700 LOC - extract helpers when larger
- Colocated tests: `*.test.ts` next to source files
- Run `pnpm check` before commits (lint + format)
- Run `pnpm tsgo` for type checking
## Stack & Commands
- **Package manager**: pnpm (`pnpm install`)
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
- **Type-check**: `pnpm tsgo`
- **Lint/format**: `pnpm check`
- **Tests**: `pnpm test`
- **Build**: `pnpm build`
If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality.

258
openclaw/.github/labeler.yml vendored Normal file
View File

@@ -0,0 +1,258 @@
"channel: bluebubbles":
- changed-files:
- any-glob-to-any-file:
- "extensions/bluebubbles/**"
- "docs/channels/bluebubbles.md"
"channel: discord":
- changed-files:
- any-glob-to-any-file:
- "src/discord/**"
- "extensions/discord/**"
- "docs/channels/discord.md"
"channel: irc":
- changed-files:
- any-glob-to-any-file:
- "extensions/irc/**"
- "docs/channels/irc.md"
"channel: feishu":
- changed-files:
- any-glob-to-any-file:
- "src/feishu/**"
- "extensions/feishu/**"
- "docs/channels/feishu.md"
"channel: googlechat":
- changed-files:
- any-glob-to-any-file:
- "extensions/googlechat/**"
- "docs/channels/googlechat.md"
"channel: imessage":
- changed-files:
- any-glob-to-any-file:
- "src/imessage/**"
- "extensions/imessage/**"
- "docs/channels/imessage.md"
"channel: line":
- changed-files:
- any-glob-to-any-file:
- "extensions/line/**"
- "docs/channels/line.md"
"channel: matrix":
- changed-files:
- any-glob-to-any-file:
- "extensions/matrix/**"
- "docs/channels/matrix.md"
"channel: mattermost":
- changed-files:
- any-glob-to-any-file:
- "extensions/mattermost/**"
- "docs/channels/mattermost.md"
"channel: msteams":
- changed-files:
- any-glob-to-any-file:
- "extensions/msteams/**"
- "docs/channels/msteams.md"
"channel: nextcloud-talk":
- changed-files:
- any-glob-to-any-file:
- "extensions/nextcloud-talk/**"
- "docs/channels/nextcloud-talk.md"
"channel: nostr":
- changed-files:
- any-glob-to-any-file:
- "extensions/nostr/**"
- "docs/channels/nostr.md"
"channel: signal":
- changed-files:
- any-glob-to-any-file:
- "src/signal/**"
- "extensions/signal/**"
- "docs/channels/signal.md"
"channel: slack":
- changed-files:
- any-glob-to-any-file:
- "src/slack/**"
- "extensions/slack/**"
- "docs/channels/slack.md"
"channel: telegram":
- changed-files:
- any-glob-to-any-file:
- "src/telegram/**"
- "extensions/telegram/**"
- "docs/channels/telegram.md"
"channel: tlon":
- changed-files:
- any-glob-to-any-file:
- "extensions/tlon/**"
- "docs/channels/tlon.md"
"channel: twitch":
- changed-files:
- any-glob-to-any-file:
- "extensions/twitch/**"
- "docs/channels/twitch.md"
"channel: voice-call":
- changed-files:
- any-glob-to-any-file:
- "extensions/voice-call/**"
"channel: whatsapp-web":
- changed-files:
- any-glob-to-any-file:
- "src/web/**"
- "extensions/whatsapp/**"
- "docs/channels/whatsapp.md"
"channel: zalo":
- changed-files:
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:
- "extensions/zalouser/**"
- "docs/channels/zalouser.md"
"app: android":
- changed-files:
- any-glob-to-any-file:
- "apps/android/**"
- "docs/platforms/android.md"
"app: ios":
- changed-files:
- any-glob-to-any-file:
- "apps/ios/**"
- "docs/platforms/ios.md"
"app: macos":
- changed-files:
- any-glob-to-any-file:
- "apps/macos/**"
- "docs/platforms/macos.md"
- "docs/platforms/mac/**"
"app: web-ui":
- changed-files:
- any-glob-to-any-file:
- "ui/**"
- "src/gateway/control-ui.ts"
- "src/gateway/control-ui-shared.ts"
- "src/gateway/protocol/**"
- "src/gateway/server-methods/chat.ts"
- "src/infra/control-ui-assets.ts"
"gateway":
- changed-files:
- any-glob-to-any-file:
- "src/gateway/**"
- "src/daemon/**"
- "docs/gateway/**"
"docs":
- changed-files:
- any-glob-to-any-file:
- "docs/**"
- "docs.acp.md"
"cli":
- changed-files:
- any-glob-to-any-file:
- "src/cli/**"
"commands":
- changed-files:
- any-glob-to-any-file:
- "src/commands/**"
"scripts":
- changed-files:
- any-glob-to-any-file:
- "scripts/**"
"docker":
- changed-files:
- any-glob-to-any-file:
- "Dockerfile"
- "Dockerfile.*"
- "docker-compose.yml"
- "docker-setup.sh"
- ".dockerignore"
- "scripts/**/*docker*"
- "scripts/**/Dockerfile*"
- "scripts/sandbox-*.sh"
- "src/agents/sandbox*.ts"
- "src/commands/sandbox*.ts"
- "src/cli/sandbox-cli.ts"
- "src/docker-setup.test.ts"
- "src/config/**/*sandbox*"
- "docs/cli/sandbox.md"
- "docs/gateway/sandbox*.md"
- "docs/install/docker.md"
- "docs/multi-agent-sandbox-tools.md"
"agents":
- changed-files:
- any-glob-to-any-file:
- "src/agents/**"
"security":
- changed-files:
- any-glob-to-any-file:
- "docs/cli/security.md"
- "docs/gateway/security.md"
"extensions: copilot-proxy":
- changed-files:
- any-glob-to-any-file:
- "extensions/copilot-proxy/**"
"extensions: diagnostics-otel":
- changed-files:
- any-glob-to-any-file:
- "extensions/diagnostics-otel/**"
"extensions: google-antigravity-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/google-antigravity-auth/**"
"extensions: google-gemini-cli-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/google-gemini-cli-auth/**"
"extensions: llm-task":
- changed-files:
- any-glob-to-any-file:
- "extensions/llm-task/**"
"extensions: lobster":
- changed-files:
- any-glob-to-any-file:
- "extensions/lobster/**"
"extensions: memory-core":
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-core/**"
"extensions: memory-lancedb":
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-lancedb/**"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:
- "extensions/open-prose/**"
"extensions: qwen-portal-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/qwen-portal-auth/**"
"extensions: device-pair":
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
"extensions: acpx":
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: minimax-portal-auth":
- changed-files:
- any-glob-to-any-file:
- "extensions/minimax-portal-auth/**"
"extensions: phone-control":
- changed-files:
- any-glob-to-any-file:
- "extensions/phone-control/**"
"extensions: talk-voice":
- changed-files:
- any-glob-to-any-file:
- "extensions/talk-voice/**"

View File

@@ -0,0 +1,108 @@
## Summary
Describe the problem and fix in 25 bullets:
- Problem:
- Why it matters:
- What changed:
- What did NOT change (scope boundary):
## Change Type (select all)
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Docs
- [ ] Security hardening
- [ ] Chore/infra
## Scope (select all touched areas)
- [ ] Gateway / orchestration
- [ ] Skills / tool execution
- [ ] Auth / tokens
- [ ] Memory / storage
- [ ] Integrations
- [ ] API / contracts
- [ ] UI / DX
- [ ] CI/CD / infra
## Linked Issue/PR
- Closes #
- Related #
## User-visible / Behavior Changes
List user-visible changes (including defaults/config).
If none, write `None`.
## Security Impact (required)
- New permissions/capabilities? (`Yes/No`)
- Secrets/tokens handling changed? (`Yes/No`)
- New/changed network calls? (`Yes/No`)
- Command/tool execution surface changed? (`Yes/No`)
- Data access scope changed? (`Yes/No`)
- If any `Yes`, explain risk + mitigation:
## Repro + Verification
### Environment
- OS:
- Runtime/container:
- Model/provider:
- Integration/channel (if any):
- Relevant config (redacted):
### Steps
1.
2.
3.
### Expected
-
### Actual
-
## Evidence
Attach at least one:
- [ ] Failing test/log before + passing after
- [ ] Trace/log snippets
- [ ] Screenshot/recording
- [ ] Perf numbers (if relevant)
## Human Verification (required)
What you personally verified (not just CI), and how:
- Verified scenarios:
- Edge cases checked:
- What you did **not** verify:
## Compatibility / Migration
- Backward compatible? (`Yes/No`)
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`)
- If yes, exact upgrade steps:
## Failure Recovery (if this breaks)
- How to disable/revert this change quickly:
- Files/config to restore:
- Known bad symptoms reviewers should watch for:
## Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
- Risk:
- Mitigation:

View File

@@ -0,0 +1,347 @@
name: Auto response
on:
issues:
types: [opened, edited, labeled]
issue_comment:
types: [created]
pull_request_target:
types: [labeled]
permissions: {}
jobs:
auto-response:
permissions:
issues: write
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Handle labeled items
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
// Labels prefixed with "r:" are auto-response triggers.
const rules = [
{
label: "r: skill",
close: true,
message:
"Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. Were keeping the core lean on skills, so Im closing this out.",
},
{
label: "r: support",
close: true,
message:
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
},
{
label: "r: testflight",
close: true,
commentTriggers: ["testflight"],
message: "Not available, build from source.",
},
{
label: "r: third-party-extension",
close: true,
message:
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
},
{
label: "r: moltbook",
close: true,
lock: true,
lockReason: "off-topic",
commentTriggers: ["moltbook"],
message:
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
},
];
const maintainerTeam = "maintainer";
const pingWarningMessage =
"Please dont spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
const mentionRegex = /@([A-Za-z0-9-]+)/g;
const maintainerCache = new Map();
const normalizeLogin = (login) => login.toLowerCase();
const isMaintainer = async (login) => {
if (!login) {
return false;
}
const normalized = normalizeLogin(login);
if (maintainerCache.has(normalized)) {
return maintainerCache.get(normalized);
}
let isMember = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: maintainerTeam,
username: normalized,
});
isMember = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
maintainerCache.set(normalized, isMember);
return isMember;
};
const countMaintainerMentions = async (body, authorLogin) => {
if (!body) {
return 0;
}
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
return 0;
}
const haystack = body.toLowerCase();
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
if (haystack.includes(teamMention)) {
return 3;
}
const mentions = new Set();
for (const match of body.matchAll(mentionRegex)) {
mentions.add(normalizeLogin(match[1]));
}
if (normalizedAuthor) {
mentions.delete(normalizedAuthor);
}
let count = 0;
for (const login of mentions) {
if (await isMaintainer(login)) {
count += 1;
}
}
return count;
};
const triggerLabel = "trigger-response";
const target = context.payload.issue ?? context.payload.pull_request;
if (!target) {
return;
}
const labelSet = new Set(
(target.labels ?? [])
.map((label) => (typeof label === "string" ? label : label?.name))
.filter((name) => typeof name === "string"),
);
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const comment = context.payload.comment;
if (comment) {
const authorLogin = comment.user?.login ?? "";
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
return;
}
const commentBody = comment.body ?? "";
const responses = [];
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
if (mentionCount >= 3) {
responses.push(pingWarningMessage);
}
const commentHaystack = commentBody.toLowerCase();
const commentRule = rules.find((item) =>
(item.commentTriggers ?? []).some((trigger) =>
commentHaystack.includes(trigger),
),
);
if (commentRule) {
responses.push(commentRule.message);
}
if (responses.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
body: responses.join("\n\n"),
});
}
return;
}
if (issue) {
const action = context.payload.action;
if (action === "opened" || action === "edited") {
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
const authorLogin = issue.user?.login ?? "";
const mentionCount = await countMaintainerMentions(
issueText,
authorLogin,
);
if (mentionCount >= 3) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: pingWarningMessage,
});
}
}
}
const hasTriggerLabel = labelSet.has(triggerLabel);
if (hasTriggerLabel) {
labelSet.delete(triggerLabel);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
name: triggerLabel,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
const isLabelEvent = context.payload.action === "labeled";
if (!hasTriggerLabel && !isLabelEvent) {
return;
}
if (issue) {
const title = issue.title ?? "";
const body = issue.body ?? "";
const haystack = `${title}\n${body}`.toLowerCase();
const hasMoltbookLabel = labelSet.has("r: moltbook");
const hasTestflightLabel = labelSet.has("r: testflight");
const hasSecurityLabel = labelSet.has("security");
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["security"],
});
labelSet.add("security");
}
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["r: testflight"],
});
labelSet.add("r: testflight");
}
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ["r: moltbook"],
});
labelSet.add("r: moltbook");
}
}
const invalidLabel = "invalid";
const dirtyLabel = "dirty";
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
if (pullRequest) {
if (labelSet.has(dirtyLabel)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: noisyPrMessage,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
const labelCount = labelSet.size;
if (labelCount > 20) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: noisyPrMessage,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
if (labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
}
if (issue && labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
state_reason: "not_planned",
});
return;
}
const rule = rules.find((item) => labelSet.has(item.label));
if (!rule) {
return;
}
const issueNumber = target.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: rule.message,
});
if (rule.close) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: "closed",
});
}
if (rule.lock) {
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
lock_reason: rule.lockReason ?? "resolved",
});
}

819
openclaw/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,819 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Lint and format always run. Fail-safe: if detection fails, run everything.
docs-scope:
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-changes
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
# Push to main keeps broad coverage.
changed-scope:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
run_android: ${{ steps.scope.outputs.run_android }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
- name: Detect changed scopes
id: scope
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
# Fail-safe: run broad checks if detection fails.
echo "run_node=true" >> "$GITHUB_OUTPUT"
echo "run_macos=true" >> "$GITHUB_OUTPUT"
echo "run_android=true" >> "$GITHUB_OUTPUT"
exit 0
fi
run_node=false
run_macos=false
run_android=false
has_non_docs=false
has_non_native_non_docs=false
while IFS= read -r path; do
[ -z "$path" ] && continue
case "$path" in
docs/*|*.md|*.mdx)
continue
;;
*)
has_non_docs=true
;;
esac
case "$path" in
# Generated protocol models are already covered by protocol:check and
# should not force the full native macOS lane.
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
;;
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
run_macos=true
;;
esac
case "$path" in
apps/android/*|apps/shared/*)
run_android=true
;;
esac
case "$path" in
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
run_node=true
;;
esac
case "$path" in
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
;;
*)
has_non_native_non_docs=true
;;
esac
done <<< "$CHANGED"
# If there are non-doc files outside native app trees, keep Node checks enabled.
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
run_node=true
fi
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Build dist
run: pnpm build
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist-build
path: dist/
retention-days: 1
# Validate npm pack contents after build (only on push to main, not PRs).
release-check:
needs: [docs-scope, build-artifacts]
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist-build
path: dist/
- name: Check release contents
run: pnpm release:check
checks:
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: test
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
steps:
- name: Skip bun lane on push
if: github.event_name == 'push' && matrix.runtime == 'bun'
run: echo "Skipping bun test lane on push events."
- name: Checkout
if: github.event_name != 'push' || matrix.runtime != 'bun'
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
if: matrix.runtime != 'bun' || github.event_name != 'push'
uses: ./.github/actions/setup-node-env
with:
install-bun: "${{ matrix.runtime == 'bun' }}"
- name: Configure vitest JSON reports
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
- name: Configure Node test resources
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
run: |
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: matrix.runtime != 'bun' || github.event_name != 'push'
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
# Types, lint, and format check.
check:
name: "check"
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check types and lint and oxfmt
run: pnpm check
- name: Enforce safe external URL opening policy
run: pnpm lint:ui:no-raw-window-open
# Report-only dead-code scans. Runs after scope detection and stores machine-readable
# results as artifacts for later triage before we enable hard gates.
# Temporarily disabled in CI while we process initial findings.
deadcode:
name: dead-code report
needs: [docs-scope, changed-scope]
# if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
if: false
runs-on: blacksmith-16vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- tool: knip
command: pnpm deadcode:report:ci:knip
- tool: ts-prune
command: pnpm deadcode:report:ci:ts-prune
- tool: ts-unused-exports
command: pnpm deadcode:report:ci:ts-unused
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run ${{ matrix.tool }} dead-code scan
run: ${{ matrix.command }}
- name: Upload dead-code results
uses: actions/upload-artifact@v4
with:
name: dead-code-${{ matrix.tool }}-${{ github.run_id }}
path: .artifacts/deadcode
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_changed == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check docs
run: pnpm check:docs
skills-python:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python tooling
run: |
python -m pip install --upgrade pip
python -m pip install pytest ruff pyyaml
- name: Lint Python skill scripts
run: python -m ruff check skills
- name: Test skill Python scripts
run: python -m pytest -q skills
secrets:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pre-commit
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml')
if [ "${#workflow_files[@]}" -eq 0 ]; then
echo "No workflow changes detected; skipping zizmor."
exit 0
fi
pre-commit run zizmor --files "${workflow_files[@]}"
- name: Audit production dependencies
run: pre-commit run --all-files pnpm-audit-prod
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-windows-2025
env:
NODE_OPTIONS: --max-old-space-size=4096
# Keep total concurrency predictable on the 16 vCPU runner:
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
OPENCLAW_TEST_WORKERS: 2
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: lint
shard_index: 0
shard_count: 1
command: pnpm lint
- runtime: node
task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: protocol
shard_index: 0
shard_count: 1
command: pnpm protocol:check
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Try to exclude workspace from Windows Defender (best-effort)
shell: pwsh
run: |
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
if (-not $cmd) {
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
exit 0
}
try {
# Defender sometimes intercepts process spawning (vitest workers). If this fails
# (eg hardened images), keep going and rely on worker limiting above.
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
Write-Host "Defender exclusions applied."
} catch {
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Download dist artifact (lint lane)
if: matrix.task == 'lint'
uses: actions/download-artifact@v4
with:
name: dist-build
path: dist/
- name: Verify dist artifact (lint lane)
if: matrix.task == 'lint'
run: |
set -euo pipefail
test -s dist/index.js
test -s dist/plugin-sdk/index.js
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Install dependencies
env:
CI: true
run: |
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Configure test shard (Windows)
if: matrix.task == 'test'
run: |
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
- name: Configure vitest JSON reports
if: matrix.task == 'test'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: matrix.task == 'test'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: matrix.task == 'test'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
needs: [docs-scope, changed-scope, check]
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: pnpm test
# --- Xcode/Swift setup ---
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: brew install xcodegen swiftlint swiftformat
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Swift lint
run: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: ~/Library/Caches/org.swift.swiftpm
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
restore-keys: |
${{ runner.os }}-swiftpm-
- name: Swift build (release)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Swift test
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen
run: brew install xcodegen
- name: Install SwiftLint / SwiftFormat
run: brew install swiftlint swiftformat
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Generate iOS project
run: |
cd apps/ios
xcodegen generate
- name: iOS tests
run: |
set -euo pipefail
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
DEST_ID="$(
python3 - <<'PY'
import json
import subprocess
import sys
import uuid
def sh(args: list[str]) -> str:
return subprocess.check_output(args, text=True).strip()
# Prefer an already-created iPhone simulator if it exists.
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
candidates: list[tuple[str, str]] = []
for runtime, devs in (devices.get("devices") or {}).items():
for dev in devs or []:
if not dev.get("isAvailable"):
continue
name = str(dev.get("name") or "")
udid = str(dev.get("udid") or "")
if not udid or not name.startswith("iPhone"):
continue
candidates.append((name, udid))
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
if candidates:
print(candidates[0][1])
sys.exit(0)
# Otherwise, create one from the newest available iOS runtime.
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
if not ios:
print("No available iOS runtimes found.", file=sys.stderr)
sys.exit(1)
def version_key(rt: dict) -> tuple[int, ...]:
parts: list[int] = []
for p in str(rt.get("version") or "0").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
return tuple(parts)
ios.sort(key=version_key, reverse=True)
runtime = ios[0]
runtime_id = str(runtime.get("identifier") or "")
if not runtime_id:
print("Missing iOS runtime identifier.", file=sys.stderr)
sys.exit(1)
supported = runtime.get("supportedDeviceTypes") or []
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
if not iphones:
print("No iPhone device types for iOS runtime.", file=sys.stderr)
sys.exit(1)
iphones.sort(
key=lambda dt: (
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
str(dt.get("name") or ""),
)
)
device_type_id = str(iphones[0].get("identifier") or "")
if not device_type_id:
print("Missing iPhone device type identifier.", file=sys.stderr)
sys.exit(1)
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
if not udid:
print("Failed to create iPhone simulator.", file=sys.stderr)
sys.exit(1)
print(udid)
PY
)"
echo "Using iOS Simulator id: $DEST_ID"
xcodebuild test \
-project apps/ios/Clawdis.xcodeproj \
-scheme Clawdis \
-destination "platform=iOS Simulator,id=$DEST_ID" \
-resultBundlePath "$RESULT_BUNDLE_PATH" \
-enableCodeCoverage YES
- name: iOS coverage summary
run: |
set -euo pipefail
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
- name: iOS coverage gate (43%)
run: |
set -euo pipefail
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
import json
import os
import subprocess
import sys
target_name = "Clawdis.app"
minimum = 0.43
report = json.loads(
subprocess.check_output(
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
text=True,
)
)
target_coverage = None
for target in report.get("targets", []):
if target.get("name") == target_name:
target_coverage = float(target["lineCoverage"])
break
if target_coverage is None:
print(f"Could not find coverage for target: {target_name}")
sys.exit(1)
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
if target_coverage + 1e-12 < minimum:
sys.exit(1)
PY
android:
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
include:
- task: test
command: ./gradlew --no-daemon :app:testDebugUnitTest
- task: build
command: ./gradlew --no-daemon :app:assembleDebug
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
accept-android-sdk-licenses: false
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: 8.11.1
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null
sdkmanager --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0"
- name: Run Android ${{ matrix.task }}
working-directory: apps/android
run: ${{ matrix.command }}

View File

@@ -0,0 +1,201 @@
name: Docker Release
on:
push:
branches:
- main
tags:
- "v*"
paths-ignore:
- "docs/**"
- "**/*.md"
- "**/*.mdx"
- ".agents/**"
- "skills/**"
concurrency:
group: docker-release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Build amd64 image
build-amd64:
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image tags (amd64)
id: tags
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
tags=()
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main-amd64")
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}-amd64")
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}"
exit 1
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push amd64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
tags: ${{ steps.tags.outputs.value }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
provenance: false
push: true
# Build arm64 image
build-arm64:
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve image tags (arm64)
id: tags
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
tags=()
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main-arm64")
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}-arm64")
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}"
exit 1
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push arm64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
tags: ${{ steps.tags.outputs.value }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
provenance: false
push: true
# Create multi-platform manifest
create-manifest:
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
packages: write
contents: read
needs: [build-amd64, build-arm64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve manifest tags
id: tags
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
tags=()
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main")
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}")
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
tags+=("${IMAGE}:latest")
fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
exit 1
fi
{
echo "value<<EOF"
printf "%s\n" "${tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create and push manifest
shell: bash
run: |
set -euo pipefail
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
args=()
for tag in "${tags[@]}"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" \
${{ needs.build-amd64.outputs.image-digest }} \
${{ needs.build-arm64.outputs.image-digest }}

View File

@@ -0,0 +1,64 @@
name: Install Smoke
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
docs-scope:
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-changes
install-smoke:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
check-latest: true
- name: Setup pnpm + cache store
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.23.0"
cache-key-suffix: "node22"
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Run root Dockerfile CLI smoke
run: |
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
- name: Run installer docker tests
env:
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
run: pnpm test:install:smoke

519
openclaw/.github/workflows/labeler.yml vendored Normal file
View File

@@ -0,0 +1,519 @@
name: Labeler
on:
pull_request_target:
types: [opened, synchronize, reopened]
issues:
types: [opened]
workflow_dispatch:
inputs:
max_prs:
description: "Maximum number of open PRs to process (0 = all)"
required: false
default: "200"
per_page:
description: "PRs per page (1-100)"
required: false
default: "50"
permissions: {}
jobs:
label:
permissions:
contents: read
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}
sync-labels: true
- name: Apply PR size label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
return;
}
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
const labelColor = "b76e79";
for (const label of sizeLabels) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: labelColor,
});
}
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
const totalChangedLines = files.reduce((total, file) => {
const path = file.filename ?? "";
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
return total;
}
return total + (file.additions ?? 0) + (file.deletions ?? 0);
}, 0);
let targetSizeLabel = "size: XL";
if (totalChangedLines < 50) {
targetSizeLabel = "size: XS";
} else if (totalChangedLines < 200) {
targetSizeLabel = "size: S";
} else if (totalChangedLines < 500) {
targetSizeLabel = "size: M";
} else if (totalChangedLines < 1000) {
targetSizeLabel = "size: L";
}
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
for (const label of currentLabels) {
const name = label.name ?? "";
if (!sizeLabels.includes(name)) {
continue;
}
if (name === targetSizeLabel) {
continue;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name,
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
- name: Apply maintainer or trusted-contributor label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const login = context.payload.pull_request?.user?.login;
if (!login) {
return;
}
const repo = `${context.repo.owner}/${context.repo.repo}`;
const trustedLabel = "trusted-contributor";
const experiencedLabel = "experienced-contributor";
const trustedThreshold = 4;
const experiencedThreshold = 10;
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: "maintainer",
username: login,
});
isMaintainer = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
if (isMaintainer) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: ["maintainer"],
});
return;
}
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
let mergedCount = 0;
try {
const merged = await github.rest.search.issuesAndPullRequests({
q: mergedQuery,
per_page: 1,
});
mergedCount = merged?.data?.total_count ?? 0;
} catch (error) {
if (error?.status !== 422) {
throw error;
}
core.warning(`Skipping merged search for ${login}; treating as 0.`);
}
if (mergedCount >= experiencedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [experiencedLabel],
});
return;
}
if (mergedCount >= trustedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [trustedLabel],
});
}
backfill-pr-labels:
if: github.event_name == 'workflow_dispatch'
permissions:
contents: read
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Backfill PR labels
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const repoFull = `${owner}/${repo}`;
const inputs = context.payload.inputs ?? {};
const maxPrsInput = inputs.max_prs ?? "200";
const perPageInput = inputs.per_page ?? "50";
const parsedMaxPrs = Number.parseInt(maxPrsInput, 10);
const parsedPerPage = Number.parseInt(perPageInput, 10);
const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200;
const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50;
const processAll = maxPrs <= 0;
const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
const labelColor = "b76e79";
const trustedLabel = "trusted-contributor";
const experiencedLabel = "experienced-contributor";
const trustedThreshold = 4;
const experiencedThreshold = 10;
const contributorCache = new Map();
async function ensureSizeLabels() {
for (const label of sizeLabels) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: label,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: labelColor,
});
}
}
}
async function resolveContributorLabel(login) {
if (contributorCache.has(login)) {
return contributorCache.get(login);
}
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: owner,
team_slug: "maintainer",
username: login,
});
isMaintainer = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
if (isMaintainer) {
contributorCache.set(login, "maintainer");
return "maintainer";
}
const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`;
let mergedCount = 0;
try {
const merged = await github.rest.search.issuesAndPullRequests({
q: mergedQuery,
per_page: 1,
});
mergedCount = merged?.data?.total_count ?? 0;
} catch (error) {
if (error?.status !== 422) {
throw error;
}
core.warning(`Skipping merged search for ${login}; treating as 0.`);
}
let label = null;
if (mergedCount >= experiencedThreshold) {
label = experiencedLabel;
} else if (mergedCount >= trustedThreshold) {
label = trustedLabel;
}
contributorCache.set(login, label);
return label;
}
async function applySizeLabel(pullRequest, currentLabels, labelNames) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullRequest.number,
per_page: 100,
});
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
const totalChangedLines = files.reduce((total, file) => {
const path = file.filename ?? "";
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
return total;
}
return total + (file.additions ?? 0) + (file.deletions ?? 0);
}, 0);
let targetSizeLabel = "size: XL";
if (totalChangedLines < 50) {
targetSizeLabel = "size: XS";
} else if (totalChangedLines < 200) {
targetSizeLabel = "size: S";
} else if (totalChangedLines < 500) {
targetSizeLabel = "size: M";
} else if (totalChangedLines < 1000) {
targetSizeLabel = "size: L";
}
for (const label of currentLabels) {
const name = label.name ?? "";
if (!sizeLabels.includes(name)) {
continue;
}
if (name === targetSizeLabel) {
continue;
}
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pullRequest.number,
name,
});
labelNames.delete(name);
}
if (!labelNames.has(targetSizeLabel)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
labelNames.add(targetSizeLabel);
}
}
async function applyContributorLabel(pullRequest, labelNames) {
const login = pullRequest.user?.login;
if (!login) {
return;
}
const label = await resolveContributorLabel(login);
if (!label) {
return;
}
if (labelNames.has(label)) {
return;
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pullRequest.number,
labels: [label],
});
labelNames.add(label);
}
await ensureSizeLabels();
let page = 1;
let processed = 0;
while (processed < maxCount) {
const remaining = maxCount - processed;
const pageSize = processAll ? perPage : Math.min(perPage, remaining);
const { data: pullRequests } = await github.rest.pulls.list({
owner,
repo,
state: "open",
per_page: pageSize,
page,
});
if (pullRequests.length === 0) {
break;
}
for (const pullRequest of pullRequests) {
if (!processAll && processed >= maxCount) {
break;
}
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner,
repo,
issue_number: pullRequest.number,
per_page: 100,
});
const labelNames = new Set(
currentLabels.map((label) => label.name).filter((name) => typeof name === "string"),
);
await applySizeLabel(pullRequest, currentLabels, labelNames);
await applyContributorLabel(pullRequest, labelNames);
processed += 1;
}
if (pullRequests.length < pageSize) {
break;
}
page += 1;
}
core.info(`Processed ${processed} pull requests.`);
label-issues:
permissions:
issues: write
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Apply maintainer or trusted-contributor label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const login = context.payload.issue?.user?.login;
if (!login) {
return;
}
const repo = `${context.repo.owner}/${context.repo.repo}`;
const trustedLabel = "trusted-contributor";
const experiencedLabel = "experienced-contributor";
const trustedThreshold = 4;
const experiencedThreshold = 10;
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: "maintainer",
username: login,
});
isMaintainer = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
if (isMaintainer) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: ["maintainer"],
});
return;
}
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
let mergedCount = 0;
try {
const merged = await github.rest.search.issuesAndPullRequests({
q: mergedQuery,
per_page: 1,
});
mergedCount = merged?.data?.total_count ?? 0;
} catch (error) {
if (error?.status !== 422) {
throw error;
}
core.warning(`Skipping merged search for ${login}; treating as 0.`);
}
if (mergedCount >= experiencedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: [experiencedLabel],
});
return;
}
if (mergedCount >= trustedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: [trustedLabel],
});
}

View File

@@ -0,0 +1,56 @@
name: Sandbox Common Smoke
on:
push:
branches: [main]
paths:
- Dockerfile.sandbox
- Dockerfile.sandbox-common
- scripts/sandbox-common-setup.sh
pull_request:
paths:
- Dockerfile.sandbox
- Dockerfile.sandbox-common
- scripts/sandbox-common-setup.sh
concurrency:
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
sandbox-common-smoke:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Build minimal sandbox base (USER sandbox)
shell: bash
run: |
set -euo pipefail
docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
FROM debian:bookworm-slim
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
EOF
- name: Build sandbox-common image (root for installs, sandbox at runtime)
shell: bash
run: |
set -euo pipefail
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
PACKAGES="ca-certificates" \
INSTALL_PNPM=0 \
INSTALL_BUN=0 \
INSTALL_BREW=0 \
FINAL_USER=sandbox \
scripts/sandbox-common-setup.sh
u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
test "$u" = "sandbox"

51
openclaw/.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Stale
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions: {}
jobs:
stale:
permissions:
issues: write
pull-requests: write
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Mark stale issues and pull requests
uses: actions/stale@v9
with:
repo-token: ${{ steps.app-token.outputs.token }}
days-before-issue-stale: 7
days-before-issue-close: 5
days-before-pr-stale: 5
days-before-pr-close: 3
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
exempt-pr-labels: maintainer,no-stale
operations-per-run: 10000
exempt-all-assignees: true
remove-stale-when-updated: true
stale-issue-message: |
This issue has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.
stale-pr-message: |
This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
close-issue-reason: not_planned
close-pr-message: |
Closing due to inactivity.
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.

View File

@@ -0,0 +1,67 @@
name: Workflow Sanity
on:
pull_request:
push:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
no-tabs:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fail on tabs in workflow files
run: |
python - <<'PY'
from __future__ import annotations
import pathlib
import sys
root = pathlib.Path(".github/workflows")
bad: list[str] = []
for path in sorted(root.rglob("*.yml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
for path in sorted(root.rglob("*.yaml")):
if b"\t" in path.read_bytes():
bad.append(str(path))
if bad:
print("Tabs found in workflow file(s):")
for path in bad:
print(f"- {path}")
sys.exit(1)
PY
actionlint:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install actionlint
shell: bash
run: |
set -euo pipefail
ACTIONLINT_VERSION="1.7.11"
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
curl -sSfL -o "${archive}" "${base_url}/${archive}"
curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
grep " ${archive}\$" checksums.txt | sha256sum -c -
tar -xzf "${archive}" actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint
- name: Lint workflows
run: actionlint
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py

122
openclaw/.gitignore vendored Normal file
View File

@@ -0,0 +1,122 @@
node_modules
**/node_modules/
.env
docker-compose.extra.yml
dist
pnpm-lock.yaml
bun.lock
bun.lockb
coverage
__pycache__/
*.pyc
.tsbuildinfo
.pnpm-store
.worktrees/
.DS_Store
**/.DS_Store
ui/src/ui/__screenshots__/
ui/playwright-report/
ui/test-results/
packages/dashboard-next/.next/
packages/dashboard-next/out/
# Mise configuration files
mise.toml
# Android build artifacts
apps/android/.gradle/
apps/android/app/build/
apps/android/.cxx/
# Bun build artifacts
*.bun-build
apps/macos/.build/
apps/shared/MoltbotKit/.build/
apps/shared/OpenClawKit/.build/
apps/shared/OpenClawKit/Package.resolved
**/ModuleCache/
bin/
bin/clawdbot-mac
bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/MoltbotKit/.swiftpm/
apps/shared/OpenClawKit/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
apps/ios/.derivedData/
apps/ios/.local-signing.xcconfig
vendor/
apps/ios/Clawdbot.xcodeproj/
apps/ios/Clawdbot.xcodeproj/**
apps/macos/.build/**
**/*.bun-build
apps/ios/*.xcfilelist
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
src/canvas-host/a2ui/*.bundle.js
src/canvas-host/a2ui/*.map
.bundle.hash
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
apps/ios/fastlane/.env
# fastlane build artifacts (local)
apps/ios/*.ipa
apps/ios/*.dSYM.zip
# provisioning profiles (local)
apps/ios/*.mobileprovision
# Local untracked files
.local/
docs/.local/
IDENTITY.md
USER.md
.tgz
.idea
# local tooling
.serena/
# Agent credentials and memory (NEVER COMMIT)
/memory/
.agent/*.json
!.agent/workflows/
/local/
package-lock.json
.claude/settings.local.json
.agents/
.agents
.agent/
skills-lock.json
# Local iOS signing overrides
apps/ios/LocalSigning.xcconfig
# Xcode build directories (xcodebuild output)
apps/ios/build/
apps/shared/OpenClawKit/build/
Swabble/build/
# Generated protocol schema (produced via pnpm protocol:gen)
dist/protocol.schema.json
.ant-colony/
# Eclipse
**/.project
**/.classpath
**/.settings/
**/.gradle/
# Synthing
**/.stfolder/

13
openclaw/.mailmap Normal file
View File

@@ -0,0 +1,13 @@
# Canonical contributor identity mappings for cherry-picked commits.
bmendonca3 <208517100+bmendonca3@users.noreply.github.com> <brianmendonca@Brians-MacBook-Air.local>
hcl <7755017+hclsys@users.noreply.github.com> <chenglunhu@gmail.com>
Glucksberg <80581902+Glucksberg@users.noreply.github.com> <markuscontasul@gmail.com>
JackyWay <53031570+JackyWay@users.noreply.github.com> <jackybbc@gmail.com>
Marcus Castro <7562095+mcaxtr@users.noreply.github.com> <mcaxtr@gmail.com>
Marc Gratch <2238658+mgratch@users.noreply.github.com> <me@marcgratch.com>
Peter Machona <7957943+chilu18@users.noreply.github.com> <chilu.machona@icloud.com>
Ben Marvell <92585+easternbloc@users.noreply.github.com> <ben@marvell.consulting>
zerone0x <39543393+zerone0x@users.noreply.github.com> <hi@trine.dev>
Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> <m.didionisio23@gmail.com>
mujiannan <46643837+mujiannan@users.noreply.github.com> <shennan@mujiannan.com>
Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> <noreply@anthropic.com>

View File

@@ -0,0 +1,52 @@
{
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"],
"config": {
"default": true,
"MD013": false,
"MD025": false,
"MD029": false,
"MD033": {
"allowed_elements": [
"Note",
"Info",
"Tip",
"Warning",
"Card",
"CardGroup",
"Columns",
"Steps",
"Step",
"Tabs",
"Tab",
"Accordion",
"AccordionGroup",
"CodeGroup",
"Frame",
"Callout",
"ParamField",
"ResponseField",
"RequestExample",
"ResponseExample",
"img",
"a",
"br",
"details",
"summary",
"p",
"strong",
"picture",
"source",
"Tooltip",
"Check",
],
},
"MD036": false,
"MD040": false,
"MD041": false,
"MD046": false,
},
}

1
openclaw/.npmrc Normal file
View File

@@ -0,0 +1 @@
# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.

26
openclaw/.oxfmtrc.jsonc Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"experimentalSortImports": {
"newlinesBetween": false,
},
"experimentalSortPackageJson": {
"sortScripts": true,
},
"tabWidth": 2,
"useTabs": false,
"ignorePatterns": [
"apps/",
"assets/",
"CLAUDE.md",
"docker-compose.yml",
"dist/",
"docs/_layouts/",
"node_modules/",
"patches/",
"pnpm-lock.yaml/",
"src/gateway/server-methods/CLAUDE.md",
"src/auto-reply/reply/export-html/",
"Swabble/",
"vendor/",
],
}

39
openclaw/.oxlintrc.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {
"correctness": "error",
"perf": "error",
"suspicious": "error"
},
"rules": {
"curly": "error",
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-new": "off",
"eslint/no-shadow": "off",
"eslint/no-unmodified-loop-condition": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "off",
"typescript/no-unsafe-type-assertion": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/require-post-message-target-origin": "off"
},
"ignorePatterns": [
"assets/",
"dist/",
"docs/_layouts/",
"extensions/",
"node_modules/",
"patches/",
"pnpm-lock.yaml",
"skills/",
"src/auto-reply/reply/export-html/template.js",
"src/canvas-host/a2ui/a2ui.bundle.js",
"Swabble/",
"vendor/"
]
}

View File

@@ -0,0 +1,195 @@
/**
* Diff Extension
*
* /diff command shows modified/deleted/new files from git status and opens
* the selected file in VS Code's diff view.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
interface FileInfo {
status: string;
statusLabel: string;
file: string;
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("diff", {
description: "Show git changes and open in VS Code diff view",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("No UI available", "error");
return;
}
// Get changed files from git status
const result = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
if (result.code !== 0) {
ctx.ui.notify(`git status failed: ${result.stderr}`, "error");
return;
}
if (!result.stdout || !result.stdout.trim()) {
ctx.ui.notify("No changes in working tree", "info");
return;
}
// Parse git status output
// Format: XY filename (where XY is two-letter status, then space, then filename)
const lines = result.stdout.split("\n");
const files: FileInfo[] = [];
for (const line of lines) {
if (line.length < 4) {
continue;
} // Need at least "XY f"
const status = line.slice(0, 2);
const file = line.slice(2).trimStart();
// Translate status codes to short labels
let statusLabel: string;
if (status.includes("M")) {
statusLabel = "M";
} else if (status.includes("A")) {
statusLabel = "A";
} else if (status.includes("D")) {
statusLabel = "D";
} else if (status.includes("?")) {
statusLabel = "?";
} else if (status.includes("R")) {
statusLabel = "R";
} else if (status.includes("C")) {
statusLabel = "C";
} else {
statusLabel = status.trim() || "~";
}
files.push({ status: statusLabel, statusLabel, file });
}
if (files.length === 0) {
ctx.ui.notify("No changes found", "info");
return;
}
const openSelected = async (fileInfo: FileInfo): Promise<void> => {
try {
// Open in VS Code diff view.
// For untracked files, git difftool won't work, so fall back to just opening the file.
if (fileInfo.status === "?") {
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
return;
}
const diffResult = await pi.exec(
"git",
["difftool", "-y", "--tool=vscode", fileInfo.file],
{
cwd: ctx.cwd,
},
);
if (diffResult.code !== 0) {
await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to open ${fileInfo.file}: ${message}`, "error");
}
};
// Show file picker with SelectList
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
// Build select items with colored status
const items: SelectItem[] = files.map((f) => {
let statusColor: string;
switch (f.status) {
case "M":
statusColor = theme.fg("warning", f.status);
break;
case "A":
statusColor = theme.fg("success", f.status);
break;
case "D":
statusColor = theme.fg("error", f.status);
break;
case "?":
statusColor = theme.fg("muted", f.status);
break;
default:
statusColor = theme.fg("dim", f.status);
}
return {
value: f,
label: `${statusColor} ${f.file}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileInfo);
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
},
});
}

View File

@@ -0,0 +1,194 @@
/**
* Files Extension
*
* /files command lists all files the model has read/written/edited in the active session branch,
* coalesced by path and sorted newest first. Selecting a file opens it in VS Code.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
interface FileEntry {
path: string;
operations: Set<"read" | "write" | "edit">;
lastTimestamp: number;
}
type FileToolName = "read" | "write" | "edit";
export default function (pi: ExtensionAPI) {
pi.registerCommand("files", {
description: "Show files read/written/edited in this session",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("No UI available", "error");
return;
}
// Get the current branch (path from leaf to root)
const branch = ctx.sessionManager.getBranch();
// First pass: collect tool calls (id -> {path, name}) from assistant messages
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
for (const entry of branch) {
if (entry.type !== "message") {
continue;
}
const msg = entry.message;
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "toolCall") {
const name = block.name;
if (name === "read" || name === "write" || name === "edit") {
const path = block.arguments?.path;
if (path && typeof path === "string") {
toolCalls.set(block.id, { path, name, timestamp: msg.timestamp });
}
}
}
}
}
}
// Second pass: match tool results to get the actual execution timestamp
const fileMap = new Map<string, FileEntry>();
for (const entry of branch) {
if (entry.type !== "message") {
continue;
}
const msg = entry.message;
if (msg.role === "toolResult") {
const toolCall = toolCalls.get(msg.toolCallId);
if (!toolCall) {
continue;
}
const { path, name } = toolCall;
const timestamp = msg.timestamp;
const existing = fileMap.get(path);
if (existing) {
existing.operations.add(name);
if (timestamp > existing.lastTimestamp) {
existing.lastTimestamp = timestamp;
}
} else {
fileMap.set(path, {
path,
operations: new Set([name]),
lastTimestamp: timestamp,
});
}
}
}
if (fileMap.size === 0) {
ctx.ui.notify("No files read/written/edited in this session", "info");
return;
}
// Sort by most recent first
const files = Array.from(fileMap.values()).toSorted(
(a, b) => b.lastTimestamp - a.lastTimestamp,
);
const openSelected = async (file: FileEntry): Promise<void> => {
try {
await pi.exec("code", ["-g", file.path], { cwd: ctx.cwd });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error");
}
};
// Show file picker with SelectList
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Title
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
// Build select items with colored operations
const items: SelectItem[] = files.map((f) => {
const ops: string[] = [];
if (f.operations.has("read")) {
ops.push(theme.fg("muted", "R"));
}
if (f.operations.has("write")) {
ops.push(theme.fg("success", "W"));
}
if (f.operations.has("edit")) {
ops.push(theme.fg("warning", "E"));
}
const opsLabel = ops.join("");
return {
value: f,
label: `${opsLabel} ${f.path}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileEntry);
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
},
});
}

View File

@@ -0,0 +1,193 @@
import {
DynamicBorder,
type ExtensionAPI,
type ExtensionContext,
} from "@mariozechner/pi-coding-agent";
import { Container, Text } from "@mariozechner/pi-tui";
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
type PromptMatch = {
kind: "pr" | "issue";
url: string;
};
type GhMetadata = {
title?: string;
author?: {
login?: string;
name?: string | null;
};
};
function extractPromptMatch(prompt: string): PromptMatch | undefined {
const prMatch = prompt.match(PR_PROMPT_PATTERN);
if (prMatch?.[1]) {
return { kind: "pr", url: prMatch[1].trim() };
}
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
if (issueMatch?.[1]) {
return { kind: "issue", url: issueMatch[1].trim() };
}
return undefined;
}
async function fetchGhMetadata(
pi: ExtensionAPI,
kind: PromptMatch["kind"],
url: string,
): Promise<GhMetadata | undefined> {
const args =
kind === "pr"
? ["pr", "view", url, "--json", "title,author"]
: ["issue", "view", url, "--json", "title,author"];
try {
const result = await pi.exec("gh", args);
if (result.code !== 0 || !result.stdout) {
return undefined;
}
return JSON.parse(result.stdout) as GhMetadata;
} catch {
return undefined;
}
}
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
if (!author) {
return undefined;
}
const name = author.name?.trim();
const login = author.login?.trim();
if (name && login) {
return `${name} (@${login})`;
}
if (login) {
return `@${login}`;
}
if (name) {
return name;
}
return undefined;
}
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
const setWidget = (
ctx: ExtensionContext,
match: PromptMatch,
title?: string,
authorText?: string,
) => {
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
const urlLine = thm.fg("dim", match.url);
const lines = [titleText];
if (authorLine) {
lines.push(authorLine);
}
lines.push(urlLine);
const container = new Container();
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
container.addChild(new Text(lines.join("\n"), 1, 0));
return container;
});
};
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
const label = match.kind === "pr" ? "PR" : "Issue";
const trimmedTitle = title?.trim();
const fallbackName = `${label}: ${match.url}`;
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
const currentName = pi.getSessionName()?.trim();
if (!currentName) {
pi.setSessionName(desiredName);
return;
}
if (currentName === match.url || currentName === fallbackName) {
pi.setSessionName(desiredName);
}
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) {
return;
}
const match = extractPromptMatch(event.prompt);
if (!match) {
return;
}
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
});
pi.on("session_switch", async (_event, ctx) => {
rebuildFromSession(ctx);
});
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
if (!content) {
return "";
}
if (typeof content === "string") {
return content;
}
return (
content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n") ?? ""
);
};
const rebuildFromSession = (ctx: ExtensionContext) => {
if (!ctx.hasUI) {
return;
}
const entries = ctx.sessionManager.getEntries();
const lastMatch = [...entries].toReversed().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") {
return false;
}
const text = getUserText(entry.message.content);
return !!extractPromptMatch(text);
});
const content =
lastMatch?.type === "message" && lastMatch.message.role === "user"
? lastMatch.message.content
: undefined;
const text = getUserText(content);
const match = text ? extractPromptMatch(text) : undefined;
if (!match) {
ctx.ui.setWidget("prompt-url", undefined);
return;
}
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
};
pi.on("session_start", async (_event, ctx) => {
rebuildFromSession(ctx);
});
}

View File

@@ -0,0 +1,26 @@
/**
* Redraws Extension
*
* Exposes /tui to show TUI redraw stats.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
export default function (pi: ExtensionAPI) {
pi.registerCommand("tui", {
description: "Show TUI stats",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
return;
}
let redraws = 0;
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
redraws = tui.fullRedraws;
done(undefined);
return new Text("", 0, 0);
});
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
},
});
}

2
openclaw/.pi/git/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,58 @@
---
description: Audit changelog entries before release
---
Audit changelog entries for all commits since the last release.
## Process
1. **Find the last release tag:**
```bash
git tag --sort=-version:refname | head -1
```
2. **List all commits since that tag:**
```bash
git log <tag>..HEAD --oneline
```
3. **Read each package's [Unreleased] section:**
- packages/ai/CHANGELOG.md
- packages/tui/CHANGELOG.md
- packages/coding-agent/CHANGELOG.md
4. **For each commit, check:**
- Skip: changelog updates, doc-only changes, release housekeeping
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
- Verify a changelog entry exists in the affected package(s)
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
5. **Cross-package duplication rule:**
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
6. **Add New Features section after changelog fixes:**
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
- Propose the top new features to the user for confirmation before writing them.
- Link to relevant docs and sections whenever possible.
7. **Report:**
- List commits with missing entries
- List entries that need cross-package duplication
- Add any missing entries directly
## Changelog Format Reference
Sections (in order):
- `### Breaking Changes` - API changes requiring migration
- `### Added` - New features
- `### Changed` - Changes to existing functionality
- `### Fixed` - Bug fixes
- `### Removed` - Removed features
Attribution:
- Internal: `Fixed foo ([#123](https://github.com/badlogic/pi-mono/issues/123))`
- External: `Added bar ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@user](https://github.com/user))`

View File

@@ -0,0 +1,22 @@
---
description: Analyze GitHub issues (bugs or feature requests)
---
Analyze GitHub issue(s): $ARGUMENTS
For each issue:
1. Read the issue in full, including all comments and linked issues/PRs.
2. **For bugs**:
- Ignore any root cause analysis in the issue (likely wrong)
- Read all related code files in full (no truncation)
- Trace the code path and identify the actual root cause
- Propose a fix
3. **For feature requests**:
- Read all related code files in full (no truncation)
- Propose the most concise implementation approach
- List affected files and changes needed
Do NOT implement unless explicitly asked. Analyze and propose only.

View File

@@ -0,0 +1,73 @@
---
description: Land a PR (merge with proper workflow)
---
Input
- PR: $1 <number|url>
- If missing: use the most recent PR mentioned in the conversation.
- If ambiguous: ask.
Do (end-to-end)
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
1. Assign PR to self:
- `gh pr edit <PR> --add-assignee @me`
2. Repo clean: `git status`.
3. Identify PR meta (author + head branch):
```sh
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
contrib=$(gh pr view <PR> --json author --jq .author.login)
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
```
4. Fast-forward base:
- `git checkout main`
- `git pull --ff-only`
5. Create temp base branch from main:
- `git checkout -b temp/landpr-<ts-or-pr>`
6. Check out PR branch locally:
- `gh pr checkout <PR>`
7. Rebase PR branch onto temp base:
- `git rebase temp/landpr-<ts-or-pr>`
- Fix conflicts; keep history tidy.
8. Fix + tests + changelog:
- Implement fixes + add/adjust tests
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
9. Decide merge strategy:
- Rebase if we want to preserve commit history
- Squash if we want a single clean commit
- If unclear, ask
10. Full gate (BEFORE commit):
- `pnpm lint && pnpm build && pnpm test`
11. Commit via committer (final merge commit only includes PR # + thanks):
- For the final merge-ready commit: `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
- If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
- `land_sha=$(git rev-parse HEAD)`
12. Push updated PR branch (rebase => usually needs force):
```sh
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
git push --force-with-lease prhead HEAD:$head
```
13. Merge PR (must show MERGED on GitHub):
- Rebase: `gh pr merge <PR> --rebase`
- Squash: `gh pr merge <PR> --squash`
- Never `gh pr close` (closing is wrong)
14. Sync main:
- `git checkout main`
- `git pull --ff-only`
15. Comment on PR with what we did + SHAs + thanks:
```sh
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
```
16. Verify PR state == MERGED:
- `gh pr view <PR> --json state --jq .state`
17. Delete temp branch:
- `git branch -D temp/landpr-<ts-or-pr>`

View File

@@ -0,0 +1,105 @@
---
description: Review a PR thoroughly without merging
---
Input
- PR: $1 <number|url>
- If missing: use the most recent PR mentioned in the conversation.
- If ambiguous: ask.
Do (review-only)
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
1. Identify PR meta + context
```sh
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}'
```
2. Read the PR description carefully
- Summarize the stated goal, scope, and any "why now?" rationale.
- Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk.
3. Read the diff thoroughly (prefer full diff)
```sh
gh pr diff <PR>
# If you need more surrounding context for files:
gh pr checkout <PR> # optional; still review-only
git show --stat
```
4. Validate the change is needed / valuable
- What user/customer/dev pain does this solve?
- Is this change the smallest reasonable fix?
- Are we introducing complexity for marginal benefit?
- Are we changing behavior/contract in a way that needs docs or a release note?
5. Evaluate implementation quality + optimality
- Correctness: edge cases, error handling, null/undefined, concurrency, ordering.
- Design: is the abstraction/architecture appropriate or over/under-engineered?
- Performance: hot paths, allocations, queries, network, N+1s, caching.
- Security/privacy: authz/authn, input validation, secrets, logging PII.
- Backwards compatibility: public APIs, config, migrations.
- Style consistency: formatting, naming, patterns used elsewhere.
6. Tests & verification
- Identify what's covered by tests (unit/integration/e2e).
- Are there regression tests for the bug fixed / scenario added?
- Missing tests? Call out exact cases that should be added.
- If tests are present, do they actually assert the important behavior (not just snapshots / happy path)?
7. Follow-up refactors / cleanup suggestions
- Any code that should be simplified before merge?
- Any TODOs that should be tickets vs addressed now?
- Any deprecations, docs, types, or lint rules we should adjust?
8. Key questions to answer explicitly
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
- Any blocking concerns (must-fix before merge)?
- Is this PR ready to land, or does it need work?
9. Output (structured)
Produce a review with these sections:
A) TL;DR recommendation
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
- 13 sentence rationale.
B) What changed
- Brief bullet summary of the diff/behavioral changes.
C) What's good
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
D) Concerns / questions (actionable)
- Numbered list.
- Mark each item as:
- BLOCKER (must fix before merge)
- IMPORTANT (should fix before merge)
- NIT (optional)
- For each: point to the file/area and propose a concrete fix or alternative.
E) Tests
- What exists.
- What's missing (specific scenarios).
F) Follow-ups (optional)
- Non-blocking refactors/tickets to open later.
G) Suggested PR comment (optional)
- Offer: "Want me to draft a PR comment to the author?"
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
Rules / Guardrails
- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code.
- If you need clarification, ask questions rather than guessing.

View File

@@ -0,0 +1,131 @@
# Pre-commit hooks for openclaw
# Install: prek install
# Run manually: prek run --all-files
#
# See https://pre-commit.com for more information
repos:
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: end-of-file-fixer
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: check-yaml
args: [--allow-multiple-documents]
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
- id: detect-private-key
exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)'
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args:
- --baseline
- .secrets.baseline
- --exclude-files
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
- --exclude-lines
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
- --exclude-lines
- 'case \.apiKeyEnv: "API key \(env var\)"'
- --exclude-lines
- 'case apikey = "apiKey"'
- --exclude-lines
- '"gateway\.remote\.password"'
- --exclude-lines
- '"gateway\.auth\.password"'
- --exclude-lines
- '"talk\.apiKey"'
- --exclude-lines
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
hooks:
- id: shellcheck
args: [--severity=error] # Only fail on errors, not warnings/info
# Exclude vendor and scripts with embedded code or known issues
exclude: "^(vendor/|scripts/e2e/)"
# GitHub Actions linting
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
# GitHub Actions security audit
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: "^(vendor/|Swabble/)"
# Python checks for skills scripts
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.1
hooks:
- id: ruff
files: "^skills/.*\\.py$"
args: [--config, pyproject.toml]
- repo: local
hooks:
- id: skills-python-tests
name: skills python tests
entry: pytest -q skills
language: python
additional_dependencies: [pytest>=8, <9]
pass_filenames: false
files: "^skills/.*\\.py$"
# Project checks (same commands as CI)
- repo: local
hooks:
# pnpm audit --prod --audit-level=high
- id: pnpm-audit-prod
name: pnpm-audit-prod
entry: pnpm audit --prod --audit-level=high
language: system
pass_filenames: false
# oxlint --type-aware src test
- id: oxlint
name: oxlint
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# oxfmt --check src test
- id: oxfmt
name: oxfmt
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# swiftlint (same as CI)
- id: swiftlint
name: swiftlint
entry: swiftlint --config .swiftlint.yml
language: system
pass_filenames: false
types: [swift]
# swiftformat --lint (same as CI)
- id: swiftformat
name: swiftformat
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
language: system
pass_filenames: false
types: [swift]

13104
openclaw/.secrets.baseline Normal file

File diff suppressed because it is too large Load Diff

25
openclaw/.shellcheckrc Normal file
View File

@@ -0,0 +1,25 @@
# ShellCheck configuration
# https://www.shellcheck.net/wiki/
# Disable common false positives and style suggestions
# SC2034: Variable appears unused (often exported or used indirectly)
disable=SC2034
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
disable=SC2155
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
disable=SC2295
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
disable=SC1012
# SC2026: Word outside quotes (info-level, often intentional)
disable=SC2026
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
disable=SC2016
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
disable=SC2129

51
openclaw/.swiftformat Normal file
View File

@@ -0,0 +1,51 @@
# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly)
--swiftversion 6.2
# Self handling
--self insert
--selfrequired
# Imports / extensions
--importgrouping testable-bottom
--extensionacl on-declarations
# Indentation
--indent 4
--indentcase false
--ifdef no-indent
--xcodeindentation enabled
# Line breaks
--linebreaks lf
--maxwidth 120
# Whitespace
--trimwhitespace always
--emptybraces no-space
--nospaceoperators ...,..<
--ranges no-space
--someAny true
--voidtype void
# Wrapping
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--closingparen same-line
# Organization
--organizetypes class,struct,enum,extension
--extensionmark "MARK: - %t + %p"
--marktypes always
--markextensions always
--structthreshold 0
--enumthreshold 0
# Other
--stripunusedargs closure-only
--header ignore
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol

148
openclaw/.swiftlint.yml Normal file
View File

@@ -0,0 +1,148 @@
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
included:
- apps/macos/Sources
excluded:
- .build
- DerivedData
- "**/.build"
- "**/.swiftpm"
- "**/DerivedData"
- "**/Generated"
- "**/Resources"
- "**/Package.swift"
- "**/Tests/Resources"
- node_modules
- dist
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
analyzer_rules:
- unused_declaration
- unused_import
opt_in_rules:
- array_init
- closure_spacing
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- fallthrough
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- overridden_super_call
- pattern_matching_keywords
- private_outlet
- prohibited_super_call
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
disabled_rules:
# SwiftFormat handles these
- trailing_whitespace
- trailing_newline
- trailing_comma
- vertical_whitespace
- indentation_width
# Style exclusions
- explicit_self
- identifier_name
- file_header
- explicit_top_level_acl
- explicit_acl
- explicit_type_interface
- missing_docs
- required_deinit
- prefer_nimble
- quick_discouraged_call
- quick_discouraged_focused_test
- quick_discouraged_pending_test
- anonymous_argument_in_multiline_closure
- no_extension_access_modifier
- no_grouping_extension
- switch_case_on_newline
- strict_fileprivate
- extension_access_modifier
- convenience_type
- no_magic_numbers
- one_declaration_per_file
- vertical_whitespace_between_cases
- vertical_whitespace_closing_braces
- superfluous_else
- number_separator
- prefixed_toplevel_constant
- opening_brace
- trailing_closure
- contrasted_opening_brace
- sorted_imports
- redundant_type_annotation
- shorthand_optional_binding
- untyped_error_in_catch
- file_name
- todo
force_cast: warning
force_try: warning
type_name:
min_length:
warning: 2
error: 1
max_length:
warning: 60
error: 80
function_body_length:
warning: 150
error: 300
function_parameter_count:
warning: 7
error: 10
file_length:
warning: 1500
error: 2500
ignore_comment_only_lines: true
type_body_length:
warning: 800
error: 1200
cyclomatic_complexity:
warning: 20
error: 120
large_tuple:
warning: 4
error: 5
nesting:
type_level:
warning: 4
error: 6
function_level:
warning: 5
error: 7
line_length:
warning: 120
error: 250
ignores_comments: true
ignores_urls: true
reporter: "xcode"

3
openclaw/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
}

22
openclaw/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"editor.formatOnSave": true,
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"typescript.preferences.importModuleSpecifierEnding": "js",
"typescript.reportStyleChecksAsWarnings": false,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.experimental.useTsgo": true
}

256
openclaw/AGENTS.md Normal file
View File

@@ -0,0 +1,256 @@
# Repository Guidelines
- 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).
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
## 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`).
- Tests: colocated `*.test.ts`.
- 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).
- 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/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors).
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.openclaw.ai).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- When working with documentation, read the mintlify skill.
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
- When you touch docs, end the reply with the `https://docs.openclaw.ai/...` URLs you referenced.
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## Docs i18n (zh-CN)
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
- See `docs/.i18n/README.md`.
- The pipeline can be slow/inefficient; if its dragging, ping @jospalmbier on Discord instead of hacking around it.
## exe.dev VM ops (general)
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
- Update: `sudo npm i -g openclaw@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `openclaw config set ...`; ensure `gateway.mode=local` is set.
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
- Restart: stop old gateway and run:
`pkill -9 -f openclaw-gateway || true; nohup openclaw gateway run --bind loopback --port 18789 --force > /tmp/openclaw-gateway.log 2>&1 &`
- Verify: `openclaw channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/openclaw-gateway.log`.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
- 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.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-<patch>` and `vYYYY.M.D.beta.N` remain recognized.
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Do not set test workers above 16; tried already.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
## 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.
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- 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>`.
- 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: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## GHSA (Repo Advisory) Patch/Publish
- Before reviewing security advisories, read `SECURITY.md`.
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
- Private fork PRs must be closed:
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
`gh pr list -R "$fork" --state open` (must be empty)
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- 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.**
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary keys are managed outside the repo; follow internal release docs.
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
- **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.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
- Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. 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.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Plugin Release Fast Path (no core `openclaw` publish)
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
- `eval "$(op signin --account my.1password.com)"`
- 1Password helpers:
- password used by `npm login`:
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
- OTP:
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
- compare local plugin `version` to `npm view <name> version`
- only run `npm publish --access public --otp="<otp>"` when versions differ
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
## 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.

3147
openclaw/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

1
openclaw/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
AGENTS.md

159
openclaw/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,159 @@
# Contributing to OpenClaw
Welcome to the lobster tank! 🦞
## Quick Links
- **GitHub:** https://github.com/openclaw/openclaw
- **Vision:** [`VISION.md`](VISION.md)
- **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
## Maintainers
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
- **Jos** - Telegram, API, Nix mode
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
- **Ayaan Zaidi** - Telegram subsystem, iOS app
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
- **Mariano Belinky** - iOS app, Security
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
- **Nimrod Gutman** - iOS app, macOS app and crustacean features
- GitHub: [@ngutman](https://github.com/ngutman) · X: [@theguti](https://x.com/theguti)
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
- **Val Alexander** - UI/UX, Docs, and Agent DevX
- GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev)
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
- **Christoph Nakazawa** - JS Infra
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Before You PR
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why
## Control UI Decorators
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
`accessor` fields required for standard decorators). When adding reactive fields, keep the
legacy style:
```ts
@state() foo = "bar";
@property({ type: Number }) count = 0;
```
The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`)
with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI
build tooling to support standard decorators.
## AI/Vibe-Coded PRs Welcome! 🤖
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
## Current Focus & Roadmap 🗺
We are currently prioritizing:
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
- **UX**: Improving the onboarding wizard and error messages.
- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills.
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
## Maintainers
We're selectively expanding the maintainer team.
If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you.
Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward.
Still interested? Email contributing@openclaw.ai with:
- Links to your PRs on OpenClaw (if you don't have any, start there first)
- Links to open source projects you maintain or actively contribute to
- Your GitHub, Discord, and X/Twitter handles
- A brief intro: background, experience, and areas of interest
- Languages you speak and where you're based
- How much time you can realistically commit
We welcome people across all skill sets — engineering, documentation, community management, and more.
We review every human-only-written application carefully and add maintainers slowly and deliberately.
Please allow a few weeks for a response.
## Report a Vulnerability
We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives:
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it.
### Required in Reports
1. **Title**
2. **Severity Assessment**
3. **Impact**
4. **Affected Component**
5. **Technical Reproduction**
6. **Demonstrated Impact**
7. **Environment**
8. **Remediation Advice**
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.

72
openclaw/Dockerfile Normal file
View File

@@ -0,0 +1,72 @@
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
# Install Bun (required for build scripts)
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
RUN corepack enable
WORKDIR /app
RUN chown node:node /app
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY --chown=node:node ui/package.json ./ui/package.json
COPY --chown=node:node patches ./patches
COPY --chown=node:node scripts ./scripts
USER node
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
# Optionally install Chromium and Xvfb for browser automation.
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
# Must run after pnpm install so playwright-core is available in node_modules.
USER root
ARG OPENCLAW_INSTALL_BROWSER=""
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
mkdir -p /home/node/.cache/ms-playwright && \
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
chown -R node:node /home/node/.cache/ms-playwright && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
USER node
COPY --chown=node:node . .
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
# Expose the CLI binary without requiring npm global writes as non-root.
USER root
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs
ENV NODE_ENV=production
# Security hardening: Run as non-root user
# The node:22-bookworm image includes a 'node' user (uid 1000)
# This reduces the attack surface by preventing container escape via root privileges
USER node
# Start gateway server with default config.
# Binds to loopback (127.0.0.1) by default for security.
#
# For container platforms requiring external health checks:
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

View File

@@ -0,0 +1,20 @@
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
CMD ["sleep", "infinity"]

View File

@@ -0,0 +1,32 @@
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
chromium \
curl \
fonts-liberation \
fonts-noto-color-emoji \
git \
jq \
novnc \
python3 \
socat \
websockify \
x11vnc \
xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox
EXPOSE 9222 5900 6080
CMD ["openclaw-sandbox-browser"]

View File

@@ -0,0 +1,45 @@
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
FROM ${BASE_IMAGE}
USER root
ENV DEBIAN_FRONTEND=noninteractive
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
ARG INSTALL_PNPM=1
ARG INSTALL_BUN=1
ARG BUN_INSTALL_DIR=/opt/bun
ARG INSTALL_BREW=1
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
ARG FINAL_USER=sandbox
ENV BUN_INSTALL=${BUN_INSTALL_DIR}
ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES} \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
curl -fsSL https://bun.sh/install | bash; \
ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
fi
RUN if [ "${INSTALL_BREW}" = "1" ]; then \
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
mkdir -p "${BREW_INSTALL_DIR}"; \
chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
fi
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
USER ${FINAL_USER}

21
openclaw/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

555
openclaw/README.md Normal file
View File

@@ -0,0 +1,555 @@
# 🦞 OpenClaw — Personal AI Assistant
<p align="center">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
</picture>
</p>
<p align="center">
<strong>EXFOLIATE! EXFOLIATE!</strong>
</p>
<p align="center">
<a href="https://github.com/openclaw/openclaw/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/openclaw/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/openclaw/openclaw/releases"><img src="https://img.shields.io/github/v/release/openclaw/openclaw?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
## Sponsors
| OpenAI | Blacksmith |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) |
**Subscriptions (OAuth):**
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for longcontext strength and better promptinjection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
## Models (selection + auth)
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
## Install (recommended)
Runtime: **Node ≥22**.
```bash
npm install -g openclaw@latest
# or: pnpm add -g openclaw@latest
openclaw onboard --install-daemon
```
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
## Quick start (TL;DR)
Runtime: **Node ≥22**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
```bash
openclaw onboard --install-daemon
openclaw gateway --port 18789 --verbose
# Send a message
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
openclaw agent --message "Ship checklist" --thinking high
```
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
```bash
git clone https://github.com/openclaw/openclaw.git
cd openclaw
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm openclaw onboard --install-daemon
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
```
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
## Security defaults (DM access)
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
Run `openclaw doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=openclaw/openclaw&type=date&legend=top-left)](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
## Everything we built so far
### Core platform
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups).
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
### Channels
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
### Apps + nodes
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
### Tools + automation
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
### Runtime + safety
- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
### Ops + packaging
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
┌───────────────────────────────┐
│ Gateway │
│ (control plane) │
│ ws://127.0.0.1:18789 │
└──────────────┬────────────────┘
├─ Pi agent (RPC)
├─ CLI (openclaw …)
├─ WebChat UI
├─ macOS app
└─ iOS / Android nodes
```
## Key subsystems
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclawmanaged Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agentdriven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — alwayson speech and continuous conversation.
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOSonly `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard)
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
- `off`: no Tailscale automation (default).
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
Notes:
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
## Remote Gateway (Linux is great)
Its perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute devicelocal actions when needed.
- **Gateway host** runs the exec tool and channel connections by default.
- **Device nodes** run devicelocal actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: exec runs where the Gateway lives; device actions run where the device lives.
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
## macOS permissions via the Gateway protocol
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise youll get `PERMISSION_MISSING`).
- `system.notify` posts a user notification and fails if notifications are denied.
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
Elevated bash (host permissions) is separate from macOS TCC:
- Use `/elevated on|off` to toggle persession elevated access when enabled + allowlisted.
- Gateway persists the persession toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
## Agent to Agent (sessions\_\* tools)
- Use these to coordinate work across sessions without jumping between chat surfaces.
- `sessions_list` — discover active sessions (agents) and their metadata.
- `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional replyback pingpong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
## Skills registry (ClawHub)
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
[ClawHub](https://clawhub.com)
## Chat commands
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off`
- `/usage off|tokens|full` — per-response usage footer
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
## Apps (optional)
The Gateway alone delivers a great experience. All apps are optional and add extra features.
If you plan to build/run companion apps, follow the platform runbooks below.
### macOS (OpenClaw.app) (optional)
- Menu bar control for the Gateway and health.
- Voice Wake + push-to-talk overlay.
- WebChat + debug tools.
- Remote gateway control over SSH.
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
### iOS node (optional)
- Pairs as a node via the Bridge.
- Voice trigger forwarding + Canvas surface.
- Controlled via `openclaw nodes …`.
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
### Android node (optional)
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
## Agent workspace + skills
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
## Configuration
Minimal `~/.openclaw/openclaw.json` (model + defaults):
```json5
{
agent: {
model: "anthropic/claude-opus-4-6",
},
}
```
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
## Security model (important)
- **Default:** tools run on the host for the **main** session, so the agent has full access when its just you.
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **nonmain sessions** (groups/channels) inside persession Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Telegram](https://docs.openclaw.ai/channels/telegram)
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
```json5
{
channels: {
telegram: {
botToken: "123456:ABCDEF",
},
},
}
```
### [Slack](https://docs.openclaw.ai/channels/slack)
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
### [Discord](https://docs.openclaw.ai/channels/discord)
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
```json5
{
channels: {
discord: {
token: "1234abcd",
},
},
}
```
### [Signal](https://docs.openclaw.ai/channels/signal)
- Requires `signal-cli` and a `channels.signal` config section.
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
- **Recommended** iMessage integration.
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
### [WebChat](https://docs.openclaw.ai/web/webchat)
- Uses the Gateway WebSocket; no separate WebChat port/config.
Browser control (optional):
```json5
{
browser: {
enabled: true,
color: "#FF4500",
},
}
```
## Docs
Use these when youre past the onboarding flow and want the deeper reference.
- [Start with the docs index for navigation and “whats where.”](https://docs.openclaw.ai)
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
## Advanced docs (discovery + control)
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
- [Control UI](https://docs.openclaw.ai/web/control-ui)
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
## Operations & troubleshooting
- [Health checks](https://docs.openclaw.ai/gateway/health)
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
- [Background process](https://docs.openclaw.ai/gateway/background-process)
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
- [Logging](https://docs.openclaw.ai/logging)
## Deep dives
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
- [Presence](https://docs.openclaw.ai/concepts/presence)
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
- [Queue](https://docs.openclaw.ai/concepts/queue)
## Workspace & skills
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
## Platform internals
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
- [iOS node](https://docs.openclaw.ai/platforms/ios)
- [Android node](https://docs.openclaw.ai/platforms/android)
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
- [Linux app](https://docs.openclaw.ai/platforms/linux)
## Email hooks (Gmail)
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
## Molty
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
by Peter Steinberger and the community.
- [openclaw.ai](https://openclaw.ai)
- [soul.md](https://soul.md)
- [steipete.me](https://steipete.me)
- [@openclaw](https://x.com/openclaw)
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
[pi-mono](https://github.com/badlogic/pi-mono).
Special thanks to Adam Doppelt for lobster.bot.
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a>
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a>
<a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a>
<a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a>
<a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a>
<a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="Harald Buerbaumer" title="Harald Buerbaumer"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/Phineas1500"><img src="https://avatars.githubusercontent.com/u/41450967?v=4&s=48" width="48" height="48" alt="Phineas1500" title="Phineas1500"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a>
<a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="Hiren Patel" title="Hiren Patel"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/Ryan-Haines"><img src="https://avatars.githubusercontent.com/u/1855752?v=4&s=48" width="48" height="48" alt="Ryan Haines" title="Ryan Haines"/></a> <a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a>
<a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a> <a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="divanoli" title="divanoli"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a>
<a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/lewiswigmore"><img src="https://avatars.githubusercontent.com/u/58551848?v=4&s=48" width="48" height="48" alt="Lewis" title="Lewis"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a>
<a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/CharlieGreenman"><img src="https://avatars.githubusercontent.com/u/8540141?v=4&s=48" width="48" height="48" alt="CharlieGreenman" title="CharlieGreenman"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/Mellowambience"><img src="https://avatars.githubusercontent.com/u/40958792?v=4&s=48" width="48" height="48" alt="Mars" title="Mars"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a> <a href="https://github.com/PeterShanxin"><img src="https://avatars.githubusercontent.com/u/128674037?v=4&s=48" width="48" height="48" alt="LI SHANXIN" title="LI SHANXIN"/></a> <a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="Simone Macario" title="Simone Macario"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a>
<a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="Jessy LANGE" title="Jessy LANGE"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="Jay Caldwell" title="Jay Caldwell"/></a> <a href="https://github.com/KirillShchetinin"><img src="https://avatars.githubusercontent.com/u/13061871?v=4&s=48" width="48" height="48" alt="Kirill Shchetynin" title="Kirill Shchetynin"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
<a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/jackheuberger"><img src="https://avatars.githubusercontent.com/u/7830838?v=4&s=48" width="48" height="48" alt="jackheuberger" title="jackheuberger"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="0xRaini" title="0xRaini"/></a> <a href="https://github.com/ruypang"><img src="https://avatars.githubusercontent.com/u/46941315?v=4&s=48" width="48" height="48" alt="ruypang" title="ruypang"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a>
<a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a> <a href="https://github.com/adhitShet"><img src="https://avatars.githubusercontent.com/u/131381638?v=4&s=48" width="48" height="48" alt="adhitShet" title="adhitShet"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/dorukardahan"><img src="https://avatars.githubusercontent.com/u/35905596?v=4&s=48" width="48" height="48" alt="dorukardahan" title="dorukardahan"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
<a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="Igor Markelov" title="Igor Markelov"/></a> <a href="https://github.com/rrenamed"><img src="https://avatars.githubusercontent.com/u/87486610?v=4&s=48" width="48" height="48" alt="rrenamed" title="rrenamed"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a>
<a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a> <a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="不做了睡大觉" title="不做了睡大觉"/></a> <a href="https://github.com/luijoc"><img src="https://avatars.githubusercontent.com/u/96428056?v=4&s=48" width="48" height="48" alt="Luis Conde" title="Luis Conde"/></a> <a href="https://github.com/Kepler2024"><img src="https://avatars.githubusercontent.com/u/166882517?v=4&s=48" width="48" height="48" alt="Harry Cui Kepler" title="Harry Cui Kepler"/></a> <a href="https://github.com/SidQin-cyber"><img src="https://avatars.githubusercontent.com/u/201593046?v=4&s=48" width="48" height="48" alt="SidQin-cyber" title="SidQin-cyber"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/minupla"><img src="https://avatars.githubusercontent.com/u/42547246?v=4&s=48" width="48" height="48" alt="minupla" title="minupla"/></a> <a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a>
<a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4&s=48" width="48" height="48" alt="Joly0" title="Joly0"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/niceysam"><img src="https://avatars.githubusercontent.com/u/256747835?v=4&s=48" width="48" height="48" alt="niceysam" title="niceysam"/></a>
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="carrotRakko" title="carrotRakko"/></a> <a href="https://github.com/Oceanswave"><img src="https://avatars.githubusercontent.com/u/760674?v=4&s=48" width="48" height="48" alt="Oceanswave" title="Oceanswave"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="cdorsey" title="cdorsey"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a>
<a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/CornBrother0x"><img src="https://avatars.githubusercontent.com/u/101160087?v=4&s=48" width="48" height="48" alt="CornBrother0x" title="CornBrother0x"/></a> <a href="https://github.com/DukeDeSouth"><img src="https://avatars.githubusercontent.com/u/51200688?v=4&s=48" width="48" height="48" alt="DukeDeSouth" title="DukeDeSouth"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a> <a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a>
<a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="garnetlyx" title="garnetlyx"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="miloudbelarebia" title="miloudbelarebia"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="Jeremiah Lowin" title="Jeremiah Lowin"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="liebertar" title="liebertar"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a>
<a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/pahdo"><img src="https://avatars.githubusercontent.com/u/12799392?v=4&s=48" width="48" height="48" alt="pahdo" title="pahdo"/></a>
<a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a> <a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/justinhuangcode"><img src="https://avatars.githubusercontent.com/u/252443740?v=4&s=48" width="48" height="48" alt="justinhuangcode" title="justinhuangcode"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="dinakars777" title="dinakars777"/></a>
<a href="https://github.com/Flash-LHR"><img src="https://avatars.githubusercontent.com/u/47357603?v=4&s=48" width="48" height="48" alt="Flash-LHR" title="Flash-LHR"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="JINNYEONG KIM" title="JINNYEONG KIM"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a>
<a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John Rood" title="John Rood"/></a> <a href="https://github.com/dddabtc"><img src="https://avatars.githubusercontent.com/u/104875499?v=4&s=48" width="48" height="48" alt="dddabtc" title="dddabtc"/></a> <a href="https://github.com/JonathanWorks"><img src="https://avatars.githubusercontent.com/u/124476234?v=4&s=48" width="48" height="48" alt="Jonathan Works" title="Jonathan Works"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
<a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" width="48" height="48" alt="ThomsenDrake" title="ThomsenDrake"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a>
<a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
<a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="Jeremy Mumford" title="Jeremy Mumford"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/DylanWoodAkers"><img src="https://avatars.githubusercontent.com/u/253595314?v=4&s=48" width="48" height="48" alt="DylanWoodAkers" title="DylanWoodAkers"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a>
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ZetiMente"><img src="https://avatars.githubusercontent.com/u/76985631?v=4&s=48" width="48" height="48" alt="Matthew" title="Matthew"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/ProspectOre"><img src="https://avatars.githubusercontent.com/u/54486432?v=4&s=48" width="48" height="48" alt="ProspectOre" title="ProspectOre"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
<a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/santiagomed"><img src="https://avatars.githubusercontent.com/u/30184543?v=4&s=48" width="48" height="48" alt="santiagomed" title="santiagomed"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a>
<a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/dashed"><img src="https://avatars.githubusercontent.com/u/139499?v=4&s=48" width="48" height="48" alt="dashed" title="dashed"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a>
<a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="Mark Liu" title="Mark Liu"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a>
<a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a>
<a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/huntharo"><img src="https://avatars.githubusercontent.com/u/5617868?v=4&s=48" width="48" height="48" alt="huntharo" title="huntharo"/></a> <a href="https://github.com/hydro13"><img src="https://avatars.githubusercontent.com/u/6640526?v=4&s=48" width="48" height="48" alt="hydro13" title="hydro13"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/MisterGuy420"><img src="https://avatars.githubusercontent.com/u/255743668?v=4&s=48" width="48" height="48" alt="MisterGuy420" title="MisterGuy420"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/SleuthCo"><img src="https://avatars.githubusercontent.com/u/259695222?v=4&s=48" width="48" height="48" alt="SleuthCo" title="SleuthCo"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/TaKO8Ki"><img src="https://avatars.githubusercontent.com/u/41065217?v=4&s=48" width="48" height="48" alt="TaKO8Ki" title="TaKO8Ki"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a>
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a>
<a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a>
<a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/dario-github"><img src="https://avatars.githubusercontent.com/u/40749119?v=4&s=48" width="48" height="48" alt="dario-github" title="dario-github"/></a> <a href="https://github.com/DarwinsBuddy"><img src="https://avatars.githubusercontent.com/u/490836?v=4&s=48" width="48" height="48" alt="DarwinsBuddy" title="DarwinsBuddy"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/dcantu96"><img src="https://avatars.githubusercontent.com/u/32658690?v=4&s=48" width="48" height="48" alt="dcantu96" title="dcantu96"/></a> <a href="https://github.com/dndodson"><img src="https://avatars.githubusercontent.com/u/5123985?v=4&s=48" width="48" height="48" alt="dndodson" title="dndodson"/></a> <a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a>
<a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a> <a href="https://github.com/EmberCF"><img src="https://avatars.githubusercontent.com/u/258471336?v=4&s=48" width="48" height="48" alt="EmberCF" title="EmberCF"/></a> <a href="https://github.com/ephraimm"><img src="https://avatars.githubusercontent.com/u/2803669?v=4&s=48" width="48" height="48" alt="ephraimm" title="ephraimm"/></a> <a href="https://github.com/ereid7"><img src="https://avatars.githubusercontent.com/u/27597719?v=4&s=48" width="48" height="48" alt="ereid7" title="ereid7"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a>
<a href="https://github.com/kesor"><img src="https://avatars.githubusercontent.com/u/7056?v=4&s=48" width="48" height="48" alt="kesor" title="kesor"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Olshansk"><img src="https://avatars.githubusercontent.com/u/1892194?v=4&s=48" width="48" height="48" alt="Olshansk" title="Olshansk"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/SecondThread"><img src="https://avatars.githubusercontent.com/u/18317476?v=4&s=48" width="48" height="48" alt="SecondThread" title="SecondThread"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a>
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a>
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a>
<a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
</p>

267
openclaw/SECURITY.md Normal file
View File

@@ -0,0 +1,267 @@
# Security Policy
If you believe you've found a security issue in OpenClaw, please report it privately.
## Reporting
Report vulnerabilities directly to the repository where the issue lives:
- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw)
- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos)
- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios)
- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android)
- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub)
- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust)
For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it.
For full reporting instructions see our [Trust page](https://trust.openclaw.ai).
### Required in Reports
1. **Title**
2. **Severity Assessment**
3. **Impact**
4. **Affected Component**
5. **Technical Reproduction**
6. **Demonstrated Impact**
7. **Environment**
8. **Remediation Advice**
Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues.
### Report Acceptance Gate (Triage Fast Path)
For fastest triage, include all of the following:
- Exact vulnerable path (`file`, function, and line range) on a current revision.
- Tested version details (OpenClaw version and/or commit SHA).
- Reproducible PoC against latest `main` or latest released version.
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
- Scope check explaining why the report is **not** covered by the Out of Scope section below.
- For command-risk/parity reports (for example obfuscation detection differences), a concrete boundary-bypass path is required (auth/approval/allowlist/sandbox). Parity-only findings are treated as hardening, not vulnerabilities.
Reports that miss these requirements may be closed as `invalid` or `no-action`.
### Common False-Positive Patterns
These are frequently reported but are typically closed with no code change:
- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope).
- Operator-intended local features (for example TUI local `!` shell) presented as remote injection.
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Missing HSTS findings on default local/loopback deployments.
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
### Duplicate Report Handling
- Search existing advisories before filing.
- Include likely duplicate GHSA IDs in your report when applicable.
- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report.
## Security & Trust
**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development.
## Bug Bounties
OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly.
The best way to help the project right now is by sending PRs.
## Maintainers: GHSA Updates via CLI
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
## Operator Trust Model (Important)
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway.
- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user.
- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default.
- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`.
- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host.
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.
## Trusted Plugin Concept (Core)
Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host.
- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary.
- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin.
## Out of Scope
- Public Internet Exposure
- Using OpenClaw in ways that the docs recommend not to
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
## Deployment Assumptions
OpenClaw security guidance assumes:
- The host where OpenClaw runs is within a trusted OS/admin boundary.
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user).
## One-User Trust Model (Personal Assistant)
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime.
- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk.
## Agent and Model Assumptions
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
## Gateway and Node trust concept
OpenClaw separates routing from execution, but both remain inside the same operator trust boundary:
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.
## Workspace Memory Trust Boundary
`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state.
- If someone can edit workspace memory files, they already crossed the trusted operator boundary.
- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary.
- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it."
- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways.
## Plugin Trust Boundary
Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code.
- Plugins can execute with the same OS privileges as the OpenClaw process.
- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary.
- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids.
## Temp Folder Boundary (Media/Sandbox)
OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts:
- Preferred temp root: `/tmp/openclaw` (when available and safe on the host).
- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-<uid>` on multi-user hosts).
Security boundary notes:
- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root.
- Arbitrary host tmp paths are not treated as trusted media roots.
- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files.
- Enforcement reference points:
- temp root resolver: `src/infra/tmp-openclaw-dir.ts`
- SDK temp helpers: `src/plugin-sdk/temp-path.ts`
- messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs`
## Operational Guidance
For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see:
- `https://docs.openclaw.ai/gateway/security`
### Tool filesystem hardening
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory.
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
### Web Interface Safety
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
- Config: `gateway.bind="loopback"` (default).
- CLI: `openclaw gateway run --bind loopback`.
- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use.
- OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups.
- Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings.
- This operator-selected tradeoff is by design and not, by itself, a security vulnerability.
- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet).
- Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls.
- Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`.
- This deployment model alone is not a security vulnerability.
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
## Runtime Requirements
### Node.js Version
OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
Verify your Node.js version:
```bash
node --version # Should be v22.12.0 or later
```
### Docker Security
When running OpenClaw in Docker:
1. The official image runs as a non-root user (`node`) for reduced attack surface
2. Use `--read-only` flag when possible for additional filesystem protection
3. Limit container capabilities with `--cap-drop=ALL`
Example secure Docker run:
```bash
docker run --read-only --cap-drop=ALL \
-v openclaw-data:/app/data \
openclaw/openclaw:latest
```
## Security Scanning
This project uses `detect-secrets` for automated secret detection in CI/CD.
See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline.
Run locally:
```bash
pip install detect-secrets==1.5.0
detect-secrets scan --baseline .secrets.baseline
```

View File

@@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: macos-latest
defaults:
run:
shell: bash
working-directory: swabble
steps:
- name: Checkout swabble
uses: actions/checkout@v4
with:
path: swabble
- name: Select Xcode 26.1 (prefer 26.1.1)
run: |
set -euo pipefail
# pick the newest installed 26.1.x, fallback to newest 26.x
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
if [[ -z "$CANDIDATE" ]]; then
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
fi
if [[ -z "$CANDIDATE" ]]; then
echo "No Xcode 26.x found on runner" >&2
exit 1
fi
echo "Selecting $CANDIDATE"
sudo xcode-select -s "$CANDIDATE"
xcodebuild -version
- name: Show Swift version
run: swift --version
- name: Install tooling
run: |
brew update
brew install swiftlint swiftformat
- name: Format check
run: |
./scripts/format.sh
git diff --exit-code
- name: Lint
run: ./scripts/lint.sh
- name: Test
run: swift test --parallel

33
openclaw/Swabble/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# macOS
.DS_Store
# SwiftPM / Build
/.build
/.swiftpm
/DerivedData
xcuserdata/
*.xcuserstate
# Editors
/.vscode
.idea/
# Xcode artifacts
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
# Playgrounds
*.xcplayground
playground.xcworkspace
timeline.xctimeline
# Carthage
Carthage/Build/
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output

View File

@@ -0,0 +1,8 @@
--swiftversion 6.2
--indent 4
--maxwidth 120
--wraparguments before-first
--wrapcollections before-first
--stripunusedargs closure-only
--self remove
--header ""

View File

@@ -0,0 +1,43 @@
# SwiftLint for swabble
included:
- Sources
excluded:
- .build
- DerivedData
- "**/.swiftpm"
- "**/.build"
- "**/DerivedData"
- "**/.DS_Store"
opt_in_rules:
- array_init
- closure_spacing
- explicit_init
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- vertical_parameter_alignment_on_call
- vertical_whitespace_opening_braces
- vertical_whitespace_closing_braces
disabled_rules:
- trailing_whitespace
- trailing_newline
- indentation_width
- identifier_name
- explicit_self
- file_header
- todo
line_length:
warning: 140
error: 180
reporter: "xcode"

View File

@@ -0,0 +1,11 @@
# Changelog
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.

21
openclaw/Swabble/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,69 @@
{
"originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
},
{
"identity" : "swift-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-testing",
"state" : {
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
"version" : "0.3.1"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,55 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "swabble",
platforms: [
.macOS(.v15),
.iOS(.v17),
],
products: [
.library(name: "Swabble", targets: ["Swabble"]),
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [
.target(
name: "Swabble",
path: "Sources/SwabbleCore",
swiftSettings: []),
.target(
name: "SwabbleKit",
path: "Sources/SwabbleKit",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "SwabbleCLI",
dependencies: [
"Swabble",
"SwabbleKit",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/swabble"),
.testTarget(
name: "SwabbleKitTests",
dependencies: [
"SwabbleKit",
.product(name: "Testing", package: "swift-testing"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "swabbleTests",
dependencies: [
"Swabble",
.product(name: "Testing", package: "swift-testing"),
]),
],
swiftLanguageModes: [.v6])

111
openclaw/Swabble/README.md Normal file
View File

@@ -0,0 +1,111 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
## Quick start
```bash
# Install deps
brew install swiftformat swiftlint
# Build
swift build
# Write default config (~/.config/swabble/config.json)
swift run swabble setup
# Run foreground daemon
swift run swabble serve
# Test your hook
swift run swabble test-hook "hello world"
# Transcribe a file to SRT
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
```swift
// Package.swift
dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
]),
]
```
## CLI
- `serve` — foreground loop (mic → wake → hook)
- `transcribe <file>` — offline transcription (txt|srt)
- `test-hook "text"` — invoke configured hook
- `mic list|set <index>` — enumerate/select input device
- `setup` — write default config JSON
- `doctor` — check Speech auth & device availability
- `health` — prints `ok`
- `tail-log` — last 10 transcripts
- `status` — show wake state + recent transcripts
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
- `start|stop|restart` — placeholders until full launchd wiring
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
## Config
`~/.config/swabble/config.json` (auto-created by `setup`):
```json
{
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
"hook": {
"command": "",
"args": [],
"prefix": "Voice swabble from ${hostname}: ",
"cooldownSeconds": 1,
"minCharacters": 24,
"timeoutSeconds": 5,
"env": {}
},
"logging": {"level": "info", "format": "text"},
"transcripts": {"enabled": true, "maxEntries": 50},
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
}
```
- Config path override: `--config /path/to/config.json` on relevant commands.
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
## Hook protocol
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
```
<command> <args...> "<prefix><text>"
```
Environment variables:
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
- plus any `hook.env` key/values
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter``AnalyzerInput``SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
- Tests: `swift test` (uses swift-testing package)
## Roadmap
- launchd control (load/bootout, PID + status socket)
- JSON logging + PII redaction toggle
- Stronger wake-word detection and control socket status/health

View File

@@ -0,0 +1,77 @@
import Foundation
public struct SwabbleConfig: Codable, Sendable {
public struct Audio: Codable, Sendable {
public var deviceName: String = ""
public var deviceIndex: Int = -1
public var sampleRate: Double = 16000
public var channels: Int = 1
}
public struct Wake: Codable, Sendable {
public var enabled: Bool = true
public var word: String = "clawd"
public var aliases: [String] = ["claude"]
}
public struct Hook: Codable, Sendable {
public var command: String = ""
public var args: [String] = []
public var prefix: String = "Voice swabble from ${hostname}: "
public var cooldownSeconds: Double = 1
public var minCharacters: Int = 24
public var timeoutSeconds: Double = 5
public var env: [String: String] = [:]
}
public struct Logging: Codable, Sendable {
public var level: String = "info"
public var format: String = "text" // text|json placeholder
}
public struct Transcripts: Codable, Sendable {
public var enabled: Bool = true
public var maxEntries: Int = 50
}
public struct Speech: Codable, Sendable {
public var localeIdentifier: String = Locale.current.identifier
public var etiquetteReplacements: Bool = false
}
public var audio = Audio()
public var wake = Wake()
public var hook = Hook()
public var logging = Logging()
public var transcripts = Transcripts()
public var speech = Speech()
public static let defaultPath = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".config/swabble/config.json")
public init() {}
}
public enum ConfigError: Error {
case missingConfig
}
public enum ConfigLoader {
public static func load(at path: URL?) throws -> SwabbleConfig {
let url = path ?? SwabbleConfig.defaultPath
if !FileManager.default.fileExists(atPath: url.path) {
throw ConfigError.missingConfig
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
}
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
let url = path ?? SwabbleConfig.defaultPath
let dir = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(config)
try data.write(to: url)
}
}

View File

@@ -0,0 +1,75 @@
import Foundation
public struct HookJob: Sendable {
public let text: String
public let timestamp: Date
public init(text: String, timestamp: Date) {
self.text = text
self.timestamp = timestamp
}
}
public actor HookExecutor {
private let config: SwabbleConfig
private var lastRun: Date?
private let hostname: String
public init(config: SwabbleConfig) {
self.config = config
hostname = Host.current().localizedName ?? "host"
}
public func shouldRun() -> Bool {
guard config.hook.cooldownSeconds > 0 else { return true }
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
return false
}
return true
}
public func run(job: HookJob) async throws {
guard shouldRun() else { return }
guard !config.hook.command.isEmpty else { throw NSError(
domain: "Hook",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
let payload = prefix + job.text
let process = Process()
process.executableURL = URL(fileURLWithPath: config.hook.command)
process.arguments = config.hook.args + [payload]
var env = ProcessInfo.processInfo.environment
env["SWABBLE_TEXT"] = job.text
env["SWABBLE_PREFIX"] = prefix
for (k, v) in config.hook.env {
env[k] = v
}
process.environment = env
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
process.waitUntilExit()
}
group.addTask {
try await Task.sleep(nanoseconds: timeoutNanos)
if process.isRunning {
process.terminate()
}
}
try await group.next()
group.cancelAll()
}
lastRun = Date()
}
}

View File

@@ -0,0 +1,50 @@
@preconcurrency import AVFoundation
import Foundation
final class BufferConverter {
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
enum ConverterError: Swift.Error {
case failedToCreateConverter
case failedToCreateConversionBuffer
case conversionFailed(NSError?)
}
private var converter: AVAudioConverter?
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
let inputFormat = buffer.format
if inputFormat == format {
return buffer
}
if converter == nil || converter?.outputFormat != format {
converter = AVAudioConverter(from: inputFormat, to: format)
converter?.primeMethod = .none
}
guard let converter else { throw ConverterError.failedToCreateConverter }
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
else {
throw ConverterError.failedToCreateConversionBuffer
}
var nsError: NSError?
let consumed = Box(false)
let inputBuffer = buffer
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
if consumed.value {
statusPtr.pointee = .noDataNow
return nil
}
consumed.value = true
statusPtr.pointee = .haveData
return inputBuffer
}
if status == .error {
throw ConverterError.conversionFailed(nsError)
}
return conversionBuffer
}
}

View File

@@ -0,0 +1,114 @@
import AVFoundation
import Foundation
import Speech
@available(macOS 26.0, iOS 26.0, *)
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
@available(macOS 26.0, iOS 26.0, *)
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
case transcriberUnavailable
}
/// Live microphone SpeechAnalyzer SpeechTranscriber pipeline.
@available(macOS 26.0, iOS 26.0, *)
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
private var engine = AVAudioEngine()
private var transcriber: SpeechTranscriber?
private var analyzer: SpeechAnalyzer?
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
private var resultTask: Task<Void, Never>?
private let converter = BufferConverter()
public init() {}
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
let auth = await requestAuthorizationIfNeeded()
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
let transcriberModule = SpeechTranscriber(
locale: Locale(identifier: localeIdentifier),
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
reportingOptions: [.volatileResults],
attributeOptions: [])
transcriber = transcriberModule
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
else {
throw SpeechPipelineError.analyzerFormatUnavailable
}
analyzer = SpeechAnalyzer(modules: [transcriberModule])
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
inputContinuation = continuation
let inputNode = engine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
guard let self else { return }
let boxed = UnsafeBuffer(buffer: buffer)
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
}
engine.prepare()
try engine.start()
try await analyzer?.start(inputSequence: stream)
guard let transcriberForStream = transcriber else {
throw SpeechPipelineError.transcriberUnavailable
}
return AsyncStream { continuation in
self.resultTask = Task {
do {
for try await result in transcriberForStream.results {
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
continuation.yield(seg)
}
} catch {
// swallow errors and finish
}
continuation.finish()
}
continuation.onTermination = { _ in
Task { await self.stop() }
}
}
}
public func stop() async {
resultTask?.cancel()
inputContinuation?.finish()
engine.inputNode.removeTap(onBus: 0)
engine.stop()
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
}
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
do {
let converted = try converter.convert(buffer, to: targetFormat)
let input = AnalyzerInput(buffer: converted)
inputContinuation?.yield(input)
} catch {
// drop on conversion failure
}
}
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
let current = SFSpeechRecognizer.authorizationStatus()
guard current == .notDetermined else { return current }
return await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
}

View File

@@ -0,0 +1,62 @@
import CoreMedia
import Foundation
import NaturalLanguage
extension AttributedString {
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
let tokenizer = NLTokenizer(unit: .sentence)
let string = String(characters)
tokenizer.string = string
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
(
$0,
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!)
}
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
let sentence = self[sentenceRange]
guard let maxLength, sentence.characters.count > maxLength else {
return [sentenceRange]
}
let wordTokenizer = NLTokenizer(unit: .word)
wordTokenizer.string = string
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
AttributedString.Index($0.lowerBound, within: self)!
..<
AttributedString.Index($0.upperBound, within: self)!
}
guard !wordRanges.isEmpty else { return [sentenceRange] }
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)
}
}
return ranges
}
return ranges.compactMap { range in
let audioTimeRanges = self[range].runs.filter {
!String(self[$0.range].characters)
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}.compactMap(\.audioTimeRange)
guard !audioTimeRanges.isEmpty else { return nil }
let start = audioTimeRanges.first!.start
let end = audioTimeRanges.last!.end
var attributes = AttributeContainer()
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
start: start,
end: end)
return AttributedString(self[range].characters, attributes: attributes)
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
case trace, debug, info, warn, error
var rank: Int {
switch self {
case .trace: 0
case .debug: 1
case .info: 2
case .warn: 3
case .error: 4
}
}
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
}
public struct Logger: Sendable {
public let level: LogLevel
public init(level: LogLevel) { self.level = level }
public func log(_ level: LogLevel, _ message: String) {
guard level >= self.level else { return }
let ts = ISO8601DateFormatter().string(from: Date())
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
}
public func trace(_ msg: String) { log(.trace, msg) }
public func debug(_ msg: String) { log(.debug, msg) }
public func info(_ msg: String) { log(.info, msg) }
public func warn(_ msg: String) { log(.warn, msg) }
public func error(_ msg: String) { log(.error, msg) }
}
extension LogLevel {
public init?(configValue: String) {
self.init(rawValue: configValue.lowercased())
}
}

View File

@@ -0,0 +1,45 @@
import CoreMedia
import Foundation
public enum OutputFormat: String {
case txt
case srt
public var needsAudioTimeRange: Bool {
switch self {
case .srt: true
default: false
}
}
public func text(for transcript: AttributedString, maxLength: Int) -> String {
switch self {
case .txt:
return String(transcript.characters)
case .srt:
func format(_ timeInterval: TimeInterval) -> String {
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
let s = Int(timeInterval) % 60
let m = (Int(timeInterval) / 60) % 60
let h = Int(timeInterval) / 60 / 60
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
}
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
CMTimeRange,
String)? in
guard let timeRange = sentence.audioTimeRange else { return nil }
return (timeRange, String(sentence.characters))
}.enumerated().map { index, run in
let (timeRange, text) = run
return """
\(index + 1)
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
"""
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
public actor TranscriptsStore {
public static let shared = TranscriptsStore()
private var entries: [String] = []
private let limit = 100
private let fileURL: URL
public init() {
let dir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8) {
entries = text.split(separator: "\n").map(String.init).suffix(limit)
}
}
public func append(text: String) {
entries.append(text)
if entries.count > limit {
entries.removeFirst(entries.count - limit)
}
let body = entries.joined(separator: "\n")
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
}
public func latest() -> [String] { entries }
}
extension String {
private func appendLine(to url: URL) throws {
let data = (self + "\n").data(using: .utf8) ?? Data()
if FileManager.default.fileExists(atPath: url.path) {
let handle = try FileHandle(forWritingTo: url)
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.close()
} else {
try data.write(to: url)
}
}
}

View File

@@ -0,0 +1,197 @@
import Foundation
public struct WakeWordSegment: Sendable, Equatable {
public let text: String
public let start: TimeInterval
public let duration: TimeInterval
public let range: Range<String.Index>?
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
self.text = text
self.start = start
self.duration = duration
self.range = range
}
public var end: TimeInterval { start + duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
public var triggers: [String]
public var minPostTriggerGap: TimeInterval
public var minCommandLength: Int
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1) {
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
}
}
public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
}
}
public enum WakeWordGate {
private struct Token {
let normalized: String
let start: TimeInterval
let end: TimeInterval
let range: Range<String.Index>?
let text: String
}
private struct TriggerTokens {
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: MatchCandidate?
for trigger in triggerTokens {
let count = trigger.tokens.count
guard count > 0, tokens.count > count else { continue }
for i in 0...(tokens.count - count - 1) {
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
if !matched { continue }
let triggerEnd = tokens[i + count - 1].end
let nextToken = tokens[i + count]
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
if let best, i <= best.index { continue }
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
}
}
guard let best else { return nil }
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
}
public static func commandText(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval)
-> String {
let threshold = triggerEndTime + 0.001
for segment in segments where segment.start >= threshold {
if normalizeToken(segment.text).isEmpty { continue }
if let range = segment.range {
let slice = transcript[range.lowerBound...]
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
break
}
let text = segments
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
.map(\.text)
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
return false
}
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
var output: [TriggerTokens] = []
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
}
return output
}
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
start: segment.start,
end: segment.end,
range: segment.range,
text: segment.text)
}
}
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: whitespaceAndPunctuation)
.lowercased()
}
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
}
#if canImport(Speech)
import Speech
public enum WakeWordSpeechSegments {
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
transcription.segments.map { segment in
let range = Range(segment.substringRange, in: transcript)
return WakeWordSegment(
text: segment.substring,
start: segment.timestamp,
duration: segment.duration,
range: range)
}
}
}
#endif

View File

@@ -0,0 +1,71 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
let serveDesc = descriptor(for: ServeCommand.self)
let transcribeDesc = descriptor(for: TranscribeCommand.self)
let testHookDesc = descriptor(for: TestHookCommand.self)
let micList = descriptor(for: MicList.self)
let micSet = descriptor(for: MicSet.self)
let micRoot = CommandDescriptor(
name: "mic",
abstract: "Microphone management",
discussion: nil,
signature: CommandSignature(),
subcommands: [micList, micSet])
let serviceRoot = CommandDescriptor(
name: "service",
abstract: "launchd helper",
discussion: nil,
signature: CommandSignature(),
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self)
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
let healthDesc = descriptor(for: HealthCommand.self)
let tailLogDesc = descriptor(for: TailLogCommand.self)
let startDesc = descriptor(for: StartCommand.self)
let stopDesc = descriptor(for: StopCommand.self)
let restartDesc = descriptor(for: RestartCommand.self)
let statusDesc = descriptor(for: StatusCommand.self)
let rootSignature = CommandSignature().withStandardRuntimeFlags()
let root = CommandDescriptor(
name: "swabble",
abstract: "Speech hook daemon",
discussion: "Local wake-word → SpeechTranscriber → hook",
signature: rootSignature,
subcommands: [
serveDesc,
transcribeDesc,
testHookDesc,
micRoot,
serviceRoot,
doctorDesc,
setupDesc,
healthDesc,
tailLogDesc,
startDesc,
stopDesc,
restartDesc,
statusDesc
])
return [root]
}
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
return CommandDescriptor(
name: type.commandDescription.commandName ?? "",
abstract: type.commandDescription.abstract,
discussion: type.commandDescription.discussion,
signature: sig,
subcommands: [])
}
}

View File

@@ -0,0 +1,37 @@
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct DoctorCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let auth = await SFSpeechRecognizer.authorizationStatus()
print("Speech auth: \(auth)")
do {
_ = try ConfigLoader.load(at: configURL)
print("Config: OK")
} catch {
print("Config missing or invalid; run setup")
}
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
print("Mics found: \(session.devices.count)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,16 @@
import Commander
import Foundation
@MainActor
struct HealthCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "health", abstract: "Health probe")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
print("ok")
}
}

View File

@@ -0,0 +1,62 @@
import AVFoundation
import Commander
import Foundation
import Swabble
@MainActor
struct MicCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "mic",
abstract: "Microphone management",
subcommands: [MicList.self, MicSet.self])
}
}
@MainActor
struct MicList: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "list", abstract: "List input devices")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.microphone, .external],
mediaType: .audio,
position: .unspecified)
let devices = session.devices
if devices.isEmpty { print("no audio inputs found"); return }
for (idx, device) in devices.enumerated() {
print("[\(idx)] \(device.localizedName)")
}
}
}
@MainActor
struct MicSet: ParsableCommand {
@Argument(help: "Device index from list") var index: Int = 0
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "set", abstract: "Set default input device index")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
var cfg = try ConfigLoader.load(at: configURL)
cfg.audio.deviceIndex = index
try ConfigLoader.save(cfg, at: configURL)
print("saved device index \(index)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,81 @@
import Commander
import Foundation
import Swabble
import SwabbleKit
@available(macOS 26.0, *)
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "serve",
abstract: "Run swabble in the foreground")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if parsed.flags.contains("noWake") { noWake = true }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
var cfg: SwabbleConfig
do {
cfg = try ConfigLoader.load(at: configURL)
} catch {
cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: configURL)
}
if noWake {
cfg.wake.enabled = false
}
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
let pipeline = SpeechPipeline()
do {
let stream = try await pipeline.start(
localeIdentifier: cfg.speech.localeIdentifier,
etiquette: cfg.speech.etiquetteReplacements)
for await seg in stream {
if cfg.wake.enabled {
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
}
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
let job = HookJob(text: stripped, timestamp: Date())
let executor = HookExecutor(config: cfg)
try await executor.run(job: job)
if cfg.transcripts.enabled {
await TranscriptsStore.shared.append(text: stripped)
}
if seg.isFinal {
logger.info("final: \(stripped)")
} else {
logger.debug("partial: \(stripped)")
}
}
} catch {
logger.error("serve error: \(error)")
throw error
}
}
private var configURL: URL? {
configPath.map { URL(fileURLWithPath: $0) }
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.stripWake(text: text, triggers: triggers)
}
}

View File

@@ -0,0 +1,77 @@
import Commander
import Foundation
@MainActor
struct ServiceRootCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "service",
abstract: "Manage launchd agent",
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
}
}
private enum LaunchdHelper {
static let label = "com.swabble.agent"
static var plistURL: URL {
FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static func writePlist(executable: String) throws {
let plist: [String: Any] = [
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)
}
static func removePlist() throws {
try? FileManager.default.removeItem(at: plistURL)
}
}
@MainActor
struct ServiceInstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "install", abstract: "Install user launch agent")
}
mutating func run() async throws {
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
try LaunchdHelper.writePlist(executable: exe)
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
}
}
@MainActor
struct ServiceUninstall: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
}
mutating func run() async throws {
try LaunchdHelper.removePlist()
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
}
}
@MainActor
struct ServiceStatus: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show launch agent status")
}
mutating func run() async throws {
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
print("plist present at \(LaunchdHelper.plistURL.path)")
} else {
print("launchd plist not installed")
}
}
}

View File

@@ -0,0 +1,26 @@
import Commander
import Foundation
import Swabble
@MainActor
struct SetupCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "setup", abstract: "Write default config")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = SwabbleConfig()
try ConfigLoader.save(cfg, at: configURL)
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,35 @@
import Commander
import Foundation
@MainActor
struct StartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
}
mutating func run() async throws {
print("start: launchd helper not implemented; run 'swabble serve' instead")
}
}
@MainActor
struct StopCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
}
mutating func run() async throws {
print("stop: launchd helper not implemented yet")
}
}
@MainActor
struct RestartCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
}
mutating func run() async throws {
print("restart: launchd helper not implemented yet")
}
}

View File

@@ -0,0 +1,34 @@
import Commander
import Foundation
import Swabble
@MainActor
struct StatusCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "status", abstract: "Show daemon state")
}
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
init() {}
init(parsed: ParsedValues) {
self.init()
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = try? ConfigLoader.load(at: configURL)
let wake = cfg?.wake.word ?? "clawd"
let wakeEnabled = cfg?.wake.enabled ?? false
let latest = await TranscriptsStore.shared.latest().suffix(3)
print("wake: \(wakeEnabled ? wake : "disabled")")
if latest.isEmpty {
print("transcripts: (none yet)")
} else {
print("last transcripts:")
latest.forEach { print("- \($0)") }
}
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,20 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TailLogCommand: ParsableCommand {
static var commandDescription: CommandDescription {
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
}
init() {}
init(parsed: ParsedValues) {}
mutating func run() async throws {
let latest = await TranscriptsStore.shared.latest()
for line in latest.suffix(10) {
print(line)
}
}
}

View File

@@ -0,0 +1,30 @@
import Commander
import Foundation
import Swabble
@MainActor
struct TestHookCommand: ParsableCommand {
@Argument(help: "Text to send to hook") var text: String
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
static var commandDescription: CommandDescription {
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { text = positional }
if let cfg = parsed.options["config"]?.last { configPath = cfg }
}
mutating func run() async throws {
let cfg = try ConfigLoader.load(at: configURL)
let executor = HookExecutor(config: cfg)
try await executor.run(job: HookJob(text: text, timestamp: Date()))
print("hook invoked")
}
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
}

View File

@@ -0,0 +1,61 @@
import AVFoundation
import Commander
import Foundation
import Speech
import Swabble
@MainActor
struct TranscribeCommand: ParsableCommand {
@Argument(help: "Path to audio/video file") var inputFile: String = ""
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
.identifier
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
static var commandDescription: CommandDescription {
CommandDescription(
commandName: "transcribe",
abstract: "Transcribe a media file locally")
}
init() {}
init(parsed: ParsedValues) {
self.init()
if let positional = parsed.positional.first { inputFile = positional }
if let loc = parsed.options["locale"]?.last { locale = loc }
if parsed.flags.contains("censor") { censor = true }
if let out = parsed.options["output"]?.last { outputFile = out }
if let fmt = parsed.options["format"]?.last { format = fmt }
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
}
mutating func run() async throws {
let fileURL = URL(fileURLWithPath: inputFile)
let audioFile = try AVAudioFile(forReading: fileURL)
let outputFormat = OutputFormat(rawValue: format) ?? .txt
let transcriber = SpeechTranscriber(
locale: Locale(identifier: locale),
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
reportingOptions: [],
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
let analyzer = SpeechAnalyzer(modules: [transcriber])
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
var transcript: AttributedString = ""
for try await result in transcriber.results {
transcript += result.text
}
let output = outputFormat.text(for: transcript, maxLength: maxLength)
if let path = outputFile {
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
} else {
print(output)
}
}
}

View File

@@ -0,0 +1,151 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
private func runCLI() async -> Int32 {
do {
let descriptors = CLIRegistry.descriptors
let program = Program(descriptors: descriptors)
let invocation = try program.resolve(argv: CommandLine.arguments)
try await dispatch(invocation: invocation)
return 0
} catch {
fputs("error: \(error)\n", stderr)
return 1
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
let path = invocation.path
guard let first = path.first else { throw CommanderProgramError.missingCommand }
switch first {
case "swabble":
try await dispatchSwabble(parsed: parsed, path: path)
default:
throw CommanderProgramError.unknownCommand(first)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
let sub = try subcommand(path, index: 1, command: "swabble")
switch sub {
case "mic":
try await dispatchMic(parsed: parsed, path: path)
case "service":
try await dispatchService(path: path)
default:
let handlers = swabbleHandlers(parsed: parsed)
guard let handler = handlers[sub] else {
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await handler()
}
}
@available(macOS 26.0, *)
@MainActor
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
[
"serve": {
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
},
"transcribe": {
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
},
"test-hook": {
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
},
"doctor": {
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
},
"setup": {
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
},
"health": {
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
},
"tail-log": {
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
},
"start": {
var cmd = StartCommand()
try await cmd.run()
},
"stop": {
var cmd = StopCommand()
try await cmd.run()
},
"restart": {
var cmd = RestartCommand()
try await cmd.run()
},
"status": {
var cmd = StatusCommand()
try await cmd.run()
}
]
}
@available(macOS 26.0, *)
@MainActor
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
let micSub = try subcommand(path, index: 2, command: "mic")
switch micSub {
case "list":
var cmd = MicList(parsed: parsed)
try await cmd.run()
case "set":
var cmd = MicSet(parsed: parsed)
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchService(path: [String]) async throws {
let svcSub = try subcommand(path, index: 2, command: "service")
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
}
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
guard path.count > index else {
throw CommanderProgramError.missingSubcommand(command: command)
}
return path[index]
}
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)
} else {
fputs("error: swabble requires macOS 26 or newer\n", stderr)
exit(1)
}

View File

@@ -0,0 +1,63 @@
import Foundation
import SwabbleKit
import Testing
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do thing")
}
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do it")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -0,0 +1,23 @@
import Foundation
import Testing
@testable import Swabble
@Test
func configRoundTrip() throws {
var cfg = SwabbleConfig()
cfg.wake.word = "robot"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
defer { try? FileManager.default.removeItem(at: url) }
try ConfigLoader.save(cfg, at: url)
let loaded = try ConfigLoader.load(at: url)
#expect(loaded.wake.word == "robot")
#expect(loaded.hook.prefix.contains("Voice swabble"))
}
@Test
func configMissingThrows() {
#expect(throws: ConfigError.missingConfig) {
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
}
}

View File

@@ -0,0 +1,33 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
- Foreground `serve`; later launchd helper for start/stop/restart.
- File transcription command emitting txt or srt.
- Basic status/health surfaces and mic selection stubs.
## Architecture
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.

View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="${ROOT}/.swiftformat"
swiftformat --config "$CONFIG" "$ROOT/Sources"

View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="${ROOT}/.swiftlint.yml"
if ! command -v swiftlint >/dev/null; then
echo "swiftlint not installed" >&2
exit 1
fi
swiftlint --config "$CONFIG"

110
openclaw/VISION.md Normal file
View File

@@ -0,0 +1,110 @@
## OpenClaw Vision
OpenClaw is the AI that actually does things.
It runs on your devices, in your channels, with your rules.
This document explains the current state and direction of the project.
We are still early, so iteration is fast.
Project overview and developer docs: [`README.md`](README.md)
Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md)
OpenClaw started as a personal playground to learn AI and build something genuinely useful:
an assistant that can run real tasks on a real computer.
It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw.
The goal: a personal assistant that is easy to use, supports a wide range of platforms, and respects privacy and security.
The current focus is:
Priority:
- Security and safe defaults
- Bug fixes and stability
- Setup reliability and first-run UX
Next priorities:
- Supporting all major model providers
- Improving support for major messaging channels (and adding a few high-demand ones)
- Performance and test infrastructure
- Better computer-use and agent harness capabilities
- Ergonomics across CLI and web frontend
- Companion apps on macOS, iOS, Android, Windows, and Linux
Contribution rules:
- One PR = one issue/topic. Do not bundle multiple unrelated fixes/features.
- PRs over ~5,000 changed lines are reviewed only in exceptional circumstances.
- Do not open large batches of tiny PRs at once; each PR has review cost.
- For very small related fixes, grouping into one focused PR is encouraged.
## Security
Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability.
The goal is to stay powerful for real work while making risky paths explicit and operator-controlled.
Canonical security policy and reporting:
- [`SECURITY.md`](SECURITY.md)
We prioritize secure defaults, but also expose clear knobs for trusted high-power workflows.
## Plugins & Memory
OpenClaw has an extensive plugin API.
Core stays lean; optional capability should usually ship as plugins.
Preferred plugin path is npm package distribution plus local extension loading for development.
If you build a plugin, host and maintain it in your own repository.
The bar for adding optional plugins to core is intentionally high.
Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md)
Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community
Memory is a special plugin slot where only one memory plugin can be active at a time.
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
### Skills
We still ship some bundled skills for baseline UX.
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
Core skill additions should be rare and require a strong product or security reason.
### MCP Support
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
This keeps MCP integration flexible and decoupled from core runtime:
- add or change MCP servers without restarting the gateway
- keep core tool/context surface lean
- reduce MCP churn impact on core stability and security
For now, we prefer this bridge model over building first-class MCP runtime into core.
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
### Setup
OpenClaw is currently terminal-first by design.
This keeps setup explicit: users see docs, auth, permissions, and security posture up front.
Long term, we want easier onboarding flows as hardening matures.
We do not want convenience wrappers that hide critical security decisions from users.
### Why TypeScript?
OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations.
TypeScript was chosen to keep OpenClaw hackable by default.
It is widely known, fast to iterate in, and easy to read, modify, and extend.
## What We Will Not Merge (For Now)
- New core skills when they can live on ClawHub
- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later)
- Commercial service integrations that do not clearly fit the model-provider category
- Wrapper channels around already supported channels without a clear capability or security gap
- First-class MCP runtime in core when `mcporter` already provides the integration path
- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture
- Heavy orchestration layers that duplicate existing agent and tool infrastructure
This list is a roadmap guardrail, not a law of physics.
Strong user demand and strong technical rationale can change it.

314
openclaw/appcast.xml Normal file
View File

@@ -0,0 +1,314 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.2.14</title>
<pubDate>Sun, 15 Feb 2026 04:24:34 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>202602140</sparkle:version>
<sparkle:shortVersionString>2026.2.14</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.14</h2>
<h3>Changes</h3>
<ul>
<li>Telegram: add poll sending via <code>openclaw message poll</code> (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.</li>
<li>Slack/Discord: add <code>dmPolicy</code> + <code>allowFrom</code> config aliases for DM access control; legacy <code>dm.policy</code> + <code>dm.allowFrom</code> keys remain supported and <code>openclaw doctor --fix</code> can migrate them.</li>
<li>Discord: allow exec approval prompts to target channels or both DM+channel via <code>channels.discord.execApprovals.target</code>. (#16051) Thanks @leonnardo.</li>
<li>Sandbox: add <code>sandbox.browser.binds</code> to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.</li>
<li>Discord: add debug logging for message routing decisions to improve <code>--debug</code> tracing. (#16202) Thanks @jayleekr.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>CLI/Plugins: ensure <code>openclaw message send</code> exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.</li>
<li>CLI/Plugins: run registered plugin <code>gateway_stop</code> hooks before <code>openclaw message</code> exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.</li>
<li>WhatsApp: honor per-account <code>dmPolicy</code> overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.</li>
<li>Telegram: when <code>channels.telegram.commands.native</code> is <code>false</code>, exclude plugin commands from <code>setMyCommands</code> menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.</li>
<li>LINE: return 200 OK for Developers Console "Verify" requests (<code>{"events":[]}</code>) without <code>X-Line-Signature</code>, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.</li>
<li>Cron: deliver text-only output directly when <code>delivery.to</code> is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.</li>
<li>Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.</li>
<li>Media: accept <code>MEDIA:</code>-prefixed paths (lenient whitespace) when loading outbound media to prevent <code>ENOENT</code> for tool-returned local media paths. (#13107) Thanks @mcaxtr.</li>
<li>Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.</li>
<li>Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)</li>
<li>Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit <code>workspaceDir</code>. (#16722)</li>
<li>BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.</li>
<li>CLI: fix lazy core command registration so top-level maintenance commands (<code>doctor</code>, <code>dashboard</code>, <code>reset</code>, <code>uninstall</code>) resolve correctly instead of exposing a non-functional <code>maintenance</code> placeholder command.</li>
<li>CLI/Dashboard: when <code>gateway.bind=lan</code>, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.</li>
<li>TUI/Gateway: resolve local gateway target URL from <code>gateway.bind</code> mode (tailnet/lan) instead of hardcoded localhost so <code>openclaw tui</code> connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.</li>
<li>TUI: honor explicit <code>--session <key></code> in <code>openclaw tui</code> even when <code>session.scope</code> is <code>global</code>, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.</li>
<li>TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.</li>
<li>TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.</li>
<li>TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.</li>
<li>TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.</li>
<li>TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.</li>
<li>TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.</li>
<li>TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.</li>
<li>TUI/Hooks: pass explicit reset reason (<code>new</code> vs <code>reset</code>) through <code>sessions.reset</code> and emit internal command hooks for gateway-triggered resets so <code>/new</code> hook workflows fire in TUI/webchat.</li>
<li>Cron: prevent <code>cron list</code>/<code>cron status</code> from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.</li>
<li>Cron: repair missing/corrupt <code>nextRunAtMs</code> for the updated job without globally recomputing unrelated due jobs during <code>cron update</code>. (#15750)</li>
<li>Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale <code>runningAtMs</code> markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.</li>
<li>Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as <code>guild=dm</code>. Thanks @thewilloftheshadow.</li>
<li>Discord: treat empty per-guild <code>channels: {}</code> config maps as no channel allowlist (not deny-all), so <code>groupPolicy: "open"</code> guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.</li>
<li>Models/CLI: guard <code>models status</code> string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.</li>
<li>Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.</li>
<li>Gateway/Sessions: abort active embedded runs and clear queued session work before <code>sessions.reset</code>, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.</li>
<li>Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.</li>
<li>Agents: add a safety timeout around embedded <code>session.compact()</code> to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.</li>
<li>Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including <code>session_status</code> model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.</li>
<li>Agents/Process/Bootstrap: preserve unbounded <code>process log</code> offset-only pagination (default tail applies only when both <code>offset</code> and <code>limit</code> are omitted) and enforce strict <code>bootstrapTotalMaxChars</code> budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.</li>
<li>Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing <code>BOOTSTRAP.md</code> once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.</li>
<li>Agents/Workspace: create <code>BOOTSTRAP.md</code> when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.</li>
<li>Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.</li>
<li>Agents: treat empty-stream provider failures (<code>request ended without sending any chunks</code>) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.</li>
<li>Agents: treat <code>read</code> tool <code>file_path</code> arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.</li>
<li>Ollama/Agents: avoid forcing <code><final></code> tag enforcement for Ollama models, which could suppress all output as <code>(no output)</code>. (#16191) Thanks @Glucksberg.</li>
<li>Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.</li>
<li>Skills: watch <code>SKILL.md</code> only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.</li>
<li>Memory/QMD: make <code>memory status</code> read-only by skipping QMD boot update/embed side effects for status-only manager checks.</li>
<li>Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.</li>
<li>Memory/Builtin: keep <code>memory status</code> dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.</li>
<li>Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological <code>qmd</code> command output.</li>
<li>Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.</li>
<li>Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.</li>
<li>Memory/QMD: pass result limits to <code>search</code>/<code>vsearch</code> commands so QMD can cap results earlier.</li>
<li>Memory/QMD: avoid reading full markdown files when a <code>from/lines</code> window is requested in QMD reads.</li>
<li>Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.</li>
<li>Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy <code>stdout</code>.</li>
<li>Memory/QMD: treat prefixed <code>no results found</code> marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.</li>
<li>Memory/QMD: avoid multi-collection <code>query</code> ranking corruption by running one <code>qmd query -c <collection></code> per managed collection and merging by best score (also used for <code>search</code>/<code>vsearch</code> fallback-to-query). (#16740) Thanks @volarian-vai.</li>
<li>Memory/QMD: detect null-byte <code>ENOTDIR</code> update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.</li>
<li>Memory/QMD/Security: add <code>rawKeyPrefix</code> support for QMD scope rules and preserve legacy <code>keyPrefix: "agent:..."</code> matching, preventing scoped deny bypass when operators match agent-prefixed session keys.</li>
<li>Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.</li>
<li>Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.</li>
<li>Security/Memory-LanceDB: require explicit <code>autoCapture: true</code> opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.</li>
<li>Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.</li>
<li>Gateway/Memory: clean up <code>agentRunSeq</code> tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.</li>
<li>Auto-reply/Memory: bound <code>ABORT_MEMORY</code> growth by evicting oldest entries and deleting reset (<code>false</code>) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.</li>
<li>Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.</li>
<li>Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.</li>
<li>Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.</li>
<li>Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.</li>
<li>Media/Security: allow local media reads from OpenClaw state <code>workspace/</code> and <code>sandboxes/</code> roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.</li>
<li>Media/Security: harden local media allowlist bypasses by requiring an explicit <code>readFile</code> override when callers mark paths as validated, and reject filesystem-root <code>localRoots</code> entries. (#16739)</li>
<li>Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.</li>
<li>Security/BlueBubbles: require explicit <code>mediaLocalRoots</code> allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.</li>
<li>Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.</li>
<li>Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.</li>
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
<li>Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.</li>
<li>Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.</li>
<li>Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.</li>
<li>Security/Hooks: restrict hook transform modules to <code>~/.openclaw/hooks/transforms</code> (prevents path traversal/escape module loads via config). Config note: <code>hooks.transformsDir</code> must now be within that directory. Thanks @akhmittra.</li>
<li>Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).</li>
<li>Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.</li>
<li>Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.</li>
<li>Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.</li>
<li>Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.</li>
<li>Security/Slack: compute command authorization for DM slash commands even when <code>dmPolicy=open</code>, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.</li>
<li>Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.</li>
<li>Security/Google Chat: deprecate <code>users/<email></code> allowlists (treat <code>users/...</code> as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.</li>
<li>Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.</li>
<li>Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject <code>@username</code> principals), auto-resolve <code>@username</code> to IDs in <code>openclaw doctor --fix</code> (when possible), and warn in <code>openclaw security audit</code> when legacy configs contain usernames. Thanks @vincentkoc.</li>
<li>Telegram/Security: reject Telegram webhook startup when <code>webhookSecret</code> is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.</li>
<li>Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).</li>
<li>Telegram: set webhook callback timeout handling to <code>onTimeout: "return"</code> (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.</li>
<li>Signal: preserve case-sensitive <code>group:</code> target IDs during normalization so mixed-case group IDs no longer fail with <code>Group not found</code>. (#16748) Thanks @repfigit.</li>
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
<li>Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.</li>
<li>Security/Agents: enforce workspace-root path bounds for <code>apply_patch</code> in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.</li>
<li>Security/Agents: enforce symlink-escape checks for <code>apply_patch</code> delete hunks under <code>workspaceOnly</code>, while still allowing deleting the symlink itself. Thanks @p80n-sec.</li>
<li>Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.</li>
<li>macOS: hard-limit unkeyed <code>openclaw://agent</code> deep links and ignore <code>deliver</code> / <code>to</code> / <code>channel</code> unless a valid unattended key is provided. Thanks @Cillian-Collins.</li>
<li>Scripts/Security: validate GitHub logins and avoid shell invocation in <code>scripts/update-clawtributors.ts</code> to prevent command injection via malicious commit records. Thanks @scanleale.</li>
<li>Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).</li>
<li>Security/Gateway: harden tool-supplied <code>gatewayUrl</code> overrides by restricting them to loopback or the configured <code>gateway.remote.url</code>. Thanks @p80n-sec.</li>
<li>Security/Gateway: block <code>system.execApprovals.*</code> via <code>node.invoke</code> (use <code>exec.approvals.node.*</code> instead). Thanks @christos-eth.</li>
<li>Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.</li>
<li>Security/Gateway: stop returning raw resolved config values in <code>skills.status</code> requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.</li>
<li>Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.</li>
<li>Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.</li>
<li>Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.</li>
<li>Security/Node Host: enforce <code>system.run</code> rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.</li>
<li>Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.</li>
<li>Security/Exec: harden PATH handling by disabling project-local <code>node_modules/.bin</code> bootstrapping by default, disallowing node-host <code>PATH</code> overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.</li>
<li>Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: <code>channels.tlon.allowPrivateNetwork</code>). Thanks @p80n-sec.</li>
<li>Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without <code>telnyx.publicKey</code> are now rejected unless <code>skipSignatureVerification</code> is enabled. Thanks @p80n-sec.</li>
<li>Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.</li>
<li>Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.14/OpenClaw-2026.2.14.zip" length="22914034" type="application/octet-stream" sparkle:edSignature="lR3nuq46/akMIN8RFDpMkTE0VOVoDVG53Xts589LryMGEtUvJxRQDtHBXfx7ZvToTq6CFKG+L5Kq/4rUspMoAQ=="/>
</item>
<item>
<title>2026.2.15</title>
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>202602150</sparkle:version>
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
<h3>Changes</h3>
<ul>
<li>Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.</li>
<li>Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.</li>
<li>Plugins: expose <code>llm_input</code> and <code>llm_output</code> hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.</li>
<li>Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set <code>agents.defaults.subagents.maxSpawnDepth: 2</code> to allow sub-agents to spawn their own children. Includes <code>maxChildrenPerAgent</code> limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.</li>
<li>Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.</li>
<li>Cron/Gateway: add finished-run webhook delivery toggle (<code>notify</code>) and dedicated webhook auth token support (<code>cron.webhookToken</code>) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.</li>
<li>Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.</li>
<li>Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.</li>
<li>Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.</li>
<li>Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.</li>
<li>Gateway/Security: redact sensitive session/path details from <code>status</code> responses for non-admin clients; full details remain available to <code>operator.admin</code>. (#8590) Thanks @fr33d3m0n.</li>
<li>Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (<code>allowInsecureAuth</code> / <code>dangerouslyDisableDeviceAuth</code>) when device identity is unavailable, preventing false <code>missing scope</code> failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.</li>
<li>LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.</li>
<li>Skills/Security: restrict <code>download</code> installer <code>targetDir</code> to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.</li>
<li>Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.</li>
<li>Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.</li>
<li>Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving <code>passwordFile</code> path exemptions, preventing accidental redaction of non-secret config values like <code>maxTokens</code> and IRC password-file paths. (#16042) Thanks @akramcodez.</li>
<li>Dev tooling: harden git <code>pre-commit</code> hook against option injection from malicious filenames (for example <code>--force</code>), preventing accidental staging of ignored files. Thanks @mrthankyou.</li>
<li>Gateway/Agent: reject malformed <code>agent:</code>-prefixed session keys (for example, <code>agent:main</code>) in <code>agent</code> and <code>agent.identity.get</code> instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.</li>
<li>Gateway/Chat: harden <code>chat.send</code> inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.</li>
<li>Gateway/Send: return an actionable error when <code>send</code> targets internal-only <code>webchat</code>, guiding callers to use <code>chat.send</code> or a deliverable channel. (#15703) Thanks @rodrigouroz.</li>
<li>Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing <code>script-src 'self'</code>. Thanks @Adam55A-code.</li>
<li>Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.</li>
<li>Agents/Sandbox: clarify system prompt path guidance so sandbox <code>bash/exec</code> uses container paths (for example <code>/workspace</code>) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.</li>
<li>Agents/Context: apply configured model <code>contextWindow</code> overrides after provider discovery so <code>lookupContextTokens()</code> honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.</li>
<li>Agents/Context: derive <code>lookupContextTokens()</code> from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.</li>
<li>Agents/OpenAI: force <code>store=true</code> for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.</li>
<li>Memory/FTS: make <code>buildFtsQuery</code> Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.</li>
<li>Auto-reply/Compaction: resolve <code>memory/YYYY-MM-DD.md</code> placeholders with timezone-aware runtime dates and append a <code>Current time:</code> line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.</li>
<li>Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.</li>
<li>Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.</li>
<li>Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.</li>
<li>Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.</li>
<li>Subagents/Models: preserve <code>agents.defaults.model.fallbacks</code> when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.</li>
<li>Telegram: omit <code>message_thread_id</code> for DM sends/draft previews and keep forum-topic handling (<code>id=1</code> general omitted, non-general kept), preventing DM failures with <code>400 Bad Request: message thread not found</code>. (#10942) Thanks @garnetlyx.</li>
<li>Telegram: replace inbound <code><media:audio></code> placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.</li>
<li>Telegram: retry inbound media <code>getFile</code> calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.</li>
<li>Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.</li>
<li>Discord: preserve channel session continuity when runtime payloads omit <code>message.channelId</code> by falling back to event/raw <code>channel_id</code> values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as <code>sessionKey=unknown</code>. (#17622) Thanks @shakkernerd.</li>
<li>Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with <code>_2</code> suffixes. (#17365) Thanks @seewhyme.</li>
<li>Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.</li>
<li>Web UI/Agents: hide <code>BOOTSTRAP.md</code> in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.</li>
<li>Auto-reply/WhatsApp/TUI/Web: when a final assistant message is <code>NO_REPLY</code> and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show <code>NO_REPLY</code> placeholders. (#7010) Thanks @Morrowind-Xie.</li>
<li>Cron: infer <code>payload.kind="agentTurn"</code> for model-only <code>cron.update</code> payload patches, so partial agent-turn updates do not fail validation when <code>kind</code> is omitted. (#15664) Thanks @rodrigouroz.</li>
<li>TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.</li>
<li>TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.</li>
<li>TUI: suppress false <code>(no output)</code> placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.</li>
<li>TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.</li>
<li>CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
</item>
<item>
<title>2026.2.26</title>
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>15221</sparkle:version>
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
<h3>Changes</h3>
<ul>
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.</li>
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.</li>
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
<li>Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.</li>
<li>Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.</li>
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
<li>Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)</li>
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.</li>
<li>Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.</li>
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
<li>TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)</li>
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
<li>Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)</li>
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.</li>
<li>Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)</li>
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.</li>
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
<li>Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)</li>
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
<li>Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.</li>
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.</li>
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.</li>
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.</li>
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
<li>Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.</li>
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)</li>
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
<li>Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Config includes: harden <code>$include</code> file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
<li>Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.</li>
<li>Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)</li>
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.</li>
<li>Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)</li>
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
<li>Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
<li>iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.</li>
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
</item>
</channel>
</rss>

5
openclaw/apps/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
.idea/
**/*.iml

View File

@@ -0,0 +1,206 @@
## OpenClaw Android App
Status: **extremely alpha**. The app is actively being rebuilt from the ground up.
### Rebuild Checklist
- [x] New 4-step onboarding flow
- [x] Connect tab with `Setup Code` + `Manual` modes
- [x] Encrypted persistence for gateway setup/auth state
- [x] Chat UI restyled
- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect)
- [ ] QR code scanning in onboarding
- [ ] Performance improvements
- [ ] Streaming support in chat UI
- [ ] Request camera/location and other permissions in onboarding/settings flow
- [ ] Push notifications for gateway/chat status updates
- [ ] Security hardening (biometric lock, token handling, safer defaults)
- [ ] Voice tab full functionality
- [ ] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening
## Open in Android Studio
- Open the folder `apps/android`.
## Build / Run
```bash
cd apps/android
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
```
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
## Macrobenchmark (Startup + Frame Timing)
```bash
cd apps/android
./gradlew :benchmark:connectedDebugAndroidTest
```
Reports are written under:
- `apps/android/benchmark/build/reports/androidTests/connected/`
## Perf CLI (low-noise)
Deterministic startup measurement + hotspot extraction with compact CLI output:
```bash
cd apps/android
./scripts/perf-startup-benchmark.sh
./scripts/perf-startup-hotspots.sh
```
Benchmark script behavior:
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
- Prints median/min/max/COV in one line.
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
Hotspot script behavior:
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
- Writes raw `perf.data` path for deeper follow-up if needed.
## Run on a Real Android Phone (USB)
1) On phone, enable **Developer options** + **USB debugging**.
2) Connect by USB and accept the debugging trust prompt on phone.
3) Verify ADB can see the device:
```bash
adb devices -l
```
4) Install + launch debug build:
```bash
pnpm android:install
pnpm android:run
```
If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
### USB-only gateway testing (no LAN dependency)
Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
Terminal A (gateway):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
Terminal B (USB tunnel):
```bash
adb reverse tcp:18789 tcp:18789
```
Then in app **Connect → Manual**:
- Host: `127.0.0.1`
- Port: `18789`
- TLS: off
## Hot Reload / Fast Iteration
This app is native Kotlin + Jetpack Compose.
- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
## Connect / Pair
1) Start the gateway (on your main machine):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
2) In the Android app:
- Open the **Connect** tab.
- Use **Setup Code** or **Manual** mode to connect.
3) Approve pairing (on the gateway machine):
```bash
openclaw nodes pending
openclaw nodes approve <requestId>
```
More details: `docs/platforms/android.md`.
## Permissions
- Discovery:
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
- Camera:
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Integration Capability Test (Preconditioned)
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
Pre-req checklist:
1) Gateway is running and reachable from the Android app.
2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected.
3) App stays unlocked and in foreground for the whole run.
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
6) No interactive system dialogs should be pending before test start.
7) Canvas host is enabled and reachable from the device (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
8) Local operator test client pairing is approved. If first run fails with `pairing required`, approve latest pending device pairing request, then rerun:
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
```bash
openclaw devices list
openclaw devices approve --latest
```
Run:
```bash
pnpm android:test:integration
```
Optional overrides:
- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config)
- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...`
- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...`
- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...`
What it does:
- Reads `node.describe` command list from the selected Android node.
- Invokes advertised non-interactive commands.
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
Common failure quick-fixes:
- `pairing required` before tests start:
- approve pending device pairing (`openclaw devices approve --latest`) and rerun.
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
- ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun.
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
## Contributions
This Android app is currently being rebuilt.
Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord.

View File

@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,156 @@
import com.android.build.api.variant.impl.VariantOutputImpl
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "ai.openclaw.android"
compileSdk = 36
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
}
}
defaultConfig {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602260
versionName = "2026.2.26"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
)
}
}
lint {
disable += setOf(
"GradleDependency",
"IconLauncherShape",
"NewerVersionAvailable",
)
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val outputFileName = "openclaw-${versionName}-${buildType}.apk"
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.webkit:webkit:1.15.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.7")
debugImplementation("androidx.compose.ui:ui-tooling")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
implementation("org.commonmark:commonmark:0.27.1")
implementation("org.commonmark:commonmark-ext-autolink:0.27.1")
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1")
implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("org.robolectric:robolectric:4.16.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}

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