Compare commits

...

29 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
b8cfceaa37 fix: scope ClawDock PR and add changelog (#12817) (thanks @Olshansk) 2026-02-10 15:39:44 -05:00
Daniel Olshansky
fbb90b155d Fixing windows test 2026-02-10 15:38:08 -05:00
Daniel Olshansky
4a444a5978 Resolve comments from greptile-apps 2026-02-10 15:38:08 -05:00
Daniel Olshansky
4531b7930b checkpoint 2026-02-10 15:38:08 -05:00
Daniel Olshansky
c38de551ed checkpoint 2026-02-10 15:37:42 -05:00
Daniel Olshansky
46c2b3e70f checkpoint 2026-02-10 15:37:42 -05:00
Daniel Olshansky
503b799f21 Ran the formatter 2026-02-10 15:37:42 -05:00
Daniel Olshansky
01cb41632d checkpoint 2026-02-10 15:37:21 -05:00
Daniel Olshansky
1c2e989838 Finalizing PR 2026-02-10 15:37:21 -05:00
Daniel Olshansky
3715b9671c Renamed everything to clawdock 2026-02-10 15:37:21 -05:00
Daniel Olshansky
4da2e24d5c Remove scripts/shell-helpers/NEXT_STEPS.md 2026-02-10 15:37:21 -05:00
Daniel Olshansky
9780f91d7d docs: add NEXT_STEPS.md back with fork workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 15:37:21 -05:00
Daniel Olshansky
115cfa20a9 Removing next steps 2026-02-10 15:37:21 -05:00
Daniel Olshansky
51a8e2205b Fixing next steps 2026-02-10 15:37:21 -05:00
Daniel Olshansky
33b717e885 feat: add shell helpers for OpenClaw development
Add interactive shell helper scripts to streamline local development:

- openclaw-helpers.sh: Zsh functions for repo navigation, running nodes,
  wallet management, and log tailing
- install.sh: Installer script that backs up existing config and sources
  helpers from .zshrc
- README.md: Documentation for available commands
- NEXT_STEPS.md: Community contribution checklist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 15:37:21 -05:00
Gustavo Madeira Santana
a6187b568c chore: add missing CHANGELOG entry 2026-02-10 15:12:37 -05:00
Gustavo Madeira Santana
c4d3800c29 fix: resolve message tool lint error (#13453) (thanks @liebertar) 2026-02-10 15:09:58 -05:00
Cklee
22458f57f2 fix(agents): strip [Historical context: ...] and tool call text from streaming path (#13453)
- Add [Historical context: ...] marker pattern to stripDowngradedToolCallText
- Apply stripDowngradedToolCallText in emitBlockChunk streaming path
- Previously only stripBlockTags ran during streaming, leaking [Tool Call: ...] markers to users
- Add 7 test cases for the new pattern stripping
2026-02-10 15:04:52 -05:00
meaadore1221-afk
67d25c6533 fix: strip reasoning tags from messaging tool text to prevent <think> leakage (#11053)
Co-authored-by: MEA <mea@MEAdeMac-mini.local>
2026-02-10 14:48:17 -05:00
williamtwomey
5fab11198d Fix matrix media attachments (#12967) thanks @williamtwomey
Co-authored-by: Gustavo Madeira Santana @gumadeiras
2026-02-10 13:18:47 -05:00
Shadow
96c46ed612 Docs: restore maintainers in contributing 2026-02-10 10:33:32 -06:00
Shadow
71fd054711 Revert "fix(credits): deduplicate contributors by GitHub username and display name"
This reverts commit d2f5d45f08.
2026-02-10 10:25:51 -06:00
Shadow
614befd15d Revert "credits: categorize direct changes, exclude bots, fix MDX (#13322)"
This reverts commit 8666d9f837.
2026-02-10 10:25:48 -06:00
Shadow
cfd1fa4bd2 Revert "CI: extend stale timelines to be contributor-friendly (#13209)"
This reverts commit 656a467518.
2026-02-10 10:24:28 -06:00
Sebastian
8933010e84 docs(env): clarify .env precedence and config token note 2026-02-10 10:18:36 -05:00
Omair Afzal
6ac56baf8e docs: clarify which workspace files are injected into context window (#12937)
* docs: clarify which workspace files are injected into context window (#12909)

The system prompt docs listed bootstrap files but omitted MEMORY.md,
which IS injected when present. This led users to assume memory files
are on-demand only and not consuming context tokens.

Changes:
- Add MEMORY.md to the bootstrap file list
- Note that all listed files consume tokens on every turn
- Clarify that memory/*.md daily files are NOT injected (on-demand only)
- Document sub-agent bootstrap filtering (AGENTS.md + TOOLS.md only)

Closes #12909

* docs: mention memory.md alternate filename in bootstrap list

Address review feedback: the runtime also injects lowercase memory.md
(DEFAULT_MEMORY_ALT_FILENAME) when present.

* docs: align memory bootstrap docs (#12937) (thanks @omair445)

---------

Co-authored-by: Luna AI <luna@coredirection.ai>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-10 10:06:23 -05:00
Mateusz Michalik
6731c6a1cd fix(docker): support Bash 3.2 in docker-setup.sh (#9441)
* fix(docker): use Bash 3.2-compatible upsert_env in docker-setup.sh

* refactor(docker): simplify argument handling in write_extra_compose function

* fix(docker): add bash 3.2 regression coverage (#9441) (thanks @mateusz-michalik)

---------

Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-10 09:55:43 -05:00
Gustavo Madeira Santana
2914cb1d48 Onboard: rename Custom API Endpoint to Custom Provider 2026-02-10 07:36:04 -05:00
Blossom
c0befdee0b feat(onboard): add custom/local API configuration flow (#11106)
* feat(onboard): add custom/local API configuration flow

* ci: retry macos check

* fix: expand custom API onboarding (#11106) (thanks @MackDing)

* fix: refine custom endpoint detection (#11106) (thanks @MackDing)

* fix: streamline custom endpoint onboarding (#11106) (thanks @MackDing)

* fix: skip model picker for custom endpoint (#11106) (thanks @MackDing)

* fix: avoid allowlist picker for custom endpoint (#11106) (thanks @MackDing)

* Onboard: reuse shared fetch timeout helper (#11106) (thanks @MackDing)

* Onboard: clarify default base URL name (#11106) (thanks @MackDing)

---------

Co-authored-by: OpenClaw Contributor <contributor@openclaw.ai>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-10 07:31:02 -05:00
32 changed files with 1870 additions and 581 deletions

View File

@@ -1,5 +1,70 @@
# Copy to .env and fill with your Twilio credentials
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp:
TWILIO_WHATSAPP_FROM=whatsapp:+17343367101
# 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-...
# 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=...

View File

@@ -23,10 +23,10 @@ jobs:
uses: actions/stale@v9
with:
repo-token: ${{ steps.app-token.outputs.token }}
days-before-issue-stale: 30
days-before-issue-close: 14
days-before-pr-stale: 14
days-before-pr-close: 7
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

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Added
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
- Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky.
@@ -16,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.
- Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy.
- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.
- Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing.
### Fixes
@@ -31,7 +33,9 @@ Docs: https://docs.openclaw.ai
- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale.
- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).
- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik.
- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.

View File

@@ -8,9 +8,28 @@ Welcome to the lobster tank! 🦞
- **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw)
## Contributors
## Maintainers
See [Credits & Maintainers](https://docs.openclaw.ai/reference/credits) for the full list.
- **Peter Steinberger** - Benevolent Dictator
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
- **Shadow** - Discord + Slack subsystem
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
- **Vignesh** - Memory (QMD), formal modeling, TUI, 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)
- **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)
- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
- GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
## How to Contribute

View File

@@ -56,7 +56,6 @@ COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local -a mounts=("$@")
local mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
@@ -71,7 +70,7 @@ YAML
printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
for mount in "$@"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
@@ -86,7 +85,7 @@ YAML
printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
for mount in "$@"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
@@ -111,7 +110,12 @@ if [[ -n "$EXTRA_MOUNTS" ]]; then
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
# Bash 3.2 + nounset treats "${array[@]}" on an empty array as unbound.
if [[ ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
else
write_extra_compose "$HOME_VOLUME_NAME"
fi
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
@@ -129,7 +133,9 @@ upsert_env() {
local -a keys=("$@")
local tmp
tmp="$(mktemp)"
declare -A seen=()
# Use a delimited string instead of an associative array so the script
# works with Bash 3.2 (macOS default) which lacks `declare -A`.
local seen=" "
if [[ -f "$file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
@@ -138,7 +144,7 @@ upsert_env() {
for k in "${keys[@]}"; do
if [[ "$key" == "$k" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
seen["$k"]=1
seen="$seen$k "
replaced=true
break
fi
@@ -150,7 +156,7 @@ upsert_env() {
fi
for k in "${keys[@]}"; do
if [[ -z "${seen[$k]:-}" ]]; then
if [[ "$seen" != *" $k "* ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
fi
done

View File

@@ -12,6 +12,7 @@ Interactive onboarding wizard (local or remote Gateway setup).
## Related guides
- CLI onboarding hub: [Onboarding Wizard (CLI)](/start/wizard)
- Onboarding overview: [Onboarding Overview](/start/onboarding-overview)
- CLI onboarding reference: [CLI Onboarding Reference](/start/wizard-cli-reference)
- CLI automation: [CLI Automation](/start/wizard-cli-automation)
- macOS onboarding: [Onboarding (macOS App)](/start/onboarding)
@@ -30,6 +31,8 @@ Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup).
- Custom Provider: connect any OpenAI or Anthropic compatible endpoint,
including hosted providers not listed. Use Unknown to auto-detect.
## Common follow-up commands

View File

@@ -59,11 +59,24 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
- `USER.md`
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (only on brand-new workspaces)
- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected)
All of these files are **injected into the context window** on every turn, which
means they consume tokens. Keep them concise — especially `MEMORY.md`, which can
grow over time and lead to unexpectedly high context usage and more frequent
compaction.
> **Note:** `memory/*.md` daily files are **not** injected automatically. They
> are accessed on demand via the `memory_search` and `memory_get` tools, so they
> do not count against the context window unless the model explicitly reads them.
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
short missing-file marker.
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
are filtered out to keep the sub-agent context small).
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).

View File

@@ -802,7 +802,12 @@
},
{
"group": "First steps",
"pages": ["start/getting-started", "start/wizard", "start/onboarding"]
"pages": [
"start/getting-started",
"start/onboarding-overview",
"start/wizard",
"start/onboarding"
]
},
{
"group": "Guides",

View File

@@ -65,6 +65,24 @@ It writes config/workspace on the host:
Running on a VPS? See [Hetzner (Docker VPS)](/install/hetzner).
### Shell Helpers (optional)
For easier day-to-day Docker management, install `ClawDock`:
```bash
mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
```
**Add to your shell config (zsh):**
```bash
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrc
```
Then use `clawdock-start`, `clawdock-stop`, `clawdock-dashboard`, etc. Run `clawdock-help` for all commands.
See [`ClawDock` Helper README](https://github.com/openclaw/openclaw/blob/main/scripts/shell-helpers/README.md) for details.
### Manual flow (compose)
```bash

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000).
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@@ -0,0 +1,51 @@
---
summary: "Overview of OpenClaw onboarding options and flows"
read_when:
- Choosing an onboarding path
- Setting up a new environment
title: "Onboarding Overview"
sidebarTitle: "Onboarding Overview"
---
# Onboarding Overview
OpenClaw supports multiple onboarding paths depending on where the Gateway runs
and how you prefer to configure providers.
## Choose your onboarding path
- **CLI wizard** for macOS, Linux, and Windows (via WSL2).
- **macOS app** for a guided first run on Apple silicon or Intel Macs.
## CLI onboarding wizard
Run the wizard in a terminal:
```bash
openclaw onboard
```
Use the CLI wizard when you want full control of the Gateway, workspace,
channels, and skills. Docs:
- [Onboarding Wizard (CLI)](/start/wizard)
- [`openclaw onboard` command](/cli/onboard)
## macOS app onboarding
Use the OpenClaw app when you want a fully guided setup on macOS. Docs:
- [Onboarding (macOS App)](/start/onboarding)
## Custom Provider
If you need an endpoint that is not listed, including hosted providers that
expose standard OpenAI or Anthropic APIs, choose **Custom Provider** in the
CLI wizard. You will be asked to:
- Pick OpenAI-compatible, Anthropic-compatible, or **Unknown** (auto-detect).
- Enter a base URL and API key (if required by the provider).
- Provide a model ID and optional alias.
- Choose an Endpoint ID so multiple custom endpoints can coexist.
For detailed steps, follow the CLI onboarding docs above.

View File

@@ -12,6 +12,7 @@ sidebarTitle: "Onboarding: macOS App"
This doc describes the **current** first‑run onboarding flow. The goal is a
smooth “day 0” experience: pick where the Gateway runs, connect auth, run the
wizard, and let the agent bootstrap itself.
For a general overview of onboarding paths, see [Onboarding Overview](/start/onboarding-overview).
<Steps>
<Step title="Approve macOS warning">

View File

@@ -62,7 +62,8 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
**Local mode (default)** walks you through these steps:
1. **Model/Auth** — Anthropic API key (recommended), OAuth, OpenAI, or other providers. Pick a default model.
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
@@ -104,5 +105,6 @@ RPC API, and a full list of config fields the wizard writes, see the
## Related docs
- CLI command reference: [`openclaw onboard`](/cli/onboard)
- Onboarding overview: [Onboarding Overview](/start/onboarding-overview)
- macOS app onboarding: [Onboarding](/start/onboarding)
- Agent first-run ritual: [Agent Bootstrapping](/start/bootstrapping)

View File

@@ -30,11 +30,13 @@ async function fetchMatrixMediaBuffer(params: {
// Use the client's download method which handles auth
try {
const result = await params.client.downloadContent(params.mxcUrl);
const buffer = result.data;
const raw = result.data ?? result;
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: Buffer.from(buffer) };
return { buffer, headerType: result.contentType };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
}

View File

@@ -0,0 +1,226 @@
# ClawDock <!-- omit in toc -->
Stop typing `docker-compose` commands. Just type `clawdock-start`.
Inspired by Simon Willison's [Running OpenClaw in Docker](https://til.simonwillison.net/llms/openclaw-docker).
- [Quickstart](#quickstart)
- [Available Commands](#available-commands)
- [Basic Operations](#basic-operations)
- [Container Access](#container-access)
- [Web UI \& Devices](#web-ui--devices)
- [Setup \& Configuration](#setup--configuration)
- [Maintenance](#maintenance)
- [Utilities](#utilities)
- [Common Workflows](#common-workflows)
- [Check Status and Logs](#check-status-and-logs)
- [Set Up WhatsApp Bot](#set-up-whatsapp-bot)
- [Troubleshooting Device Pairing](#troubleshooting-device-pairing)
- [Fix Token Mismatch Issues](#fix-token-mismatch-issues)
- [Permission Denied](#permission-denied)
- [Requirements](#requirements)
## Quickstart
**Install:**
```bash
mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
```
```bash
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrc
```
**See what you get:**
```bash
clawdock-help
```
On first command, ClawDock auto-detects your OpenClaw directory:
- Checks common paths (`~/openclaw`, `~/workspace/openclaw`, etc.)
- If found, asks you to confirm
- Saves to `~/.clawdock/config`
**First time setup:**
```bash
clawdock-start
```
```bash
clawdock-fix-token
```
```bash
clawdock-dashboard
```
If you see "pairing required":
```bash
clawdock-devices
```
And approve the request for the specific device:
```bash
clawdock-approve <request-id>
```
## Available Commands
### Basic Operations
| Command | Description |
| ------------------ | ------------------------------- |
| `clawdock-start` | Start the gateway |
| `clawdock-stop` | Stop the gateway |
| `clawdock-restart` | Restart the gateway |
| `clawdock-status` | Check container status |
| `clawdock-logs` | View live logs (follows output) |
### Container Access
| Command | Description |
| ------------------------- | ---------------------------------------------- |
| `clawdock-shell` | Interactive shell inside the gateway container |
| `clawdock-cli <command>` | Run OpenClaw CLI commands |
| `clawdock-exec <command>` | Execute arbitrary commands in the container |
### Web UI & Devices
| Command | Description |
| ----------------------- | ------------------------------------------ |
| `clawdock-dashboard` | Open web UI in browser with authentication |
| `clawdock-devices` | List device pairing requests |
| `clawdock-approve <id>` | Approve a device pairing request |
### Setup & Configuration
| Command | Description |
| -------------------- | ------------------------------------------------- |
| `clawdock-fix-token` | Configure gateway authentication token (run once) |
### Maintenance
| Command | Description |
| ------------------ | ------------------------------------------------ |
| `clawdock-rebuild` | Rebuild the Docker image |
| `clawdock-clean` | Remove all containers and volumes (destructive!) |
### Utilities
| Command | Description |
| -------------------- | ----------------------------------------- |
| `clawdock-health` | Run gateway health check |
| `clawdock-token` | Display the gateway authentication token |
| `clawdock-cd` | Jump to the OpenClaw project directory |
| `clawdock-config` | Open the OpenClaw config directory |
| `clawdock-workspace` | Open the workspace directory |
| `clawdock-help` | Show all available commands with examples |
## Common Workflows
### Check Status and Logs
**Restart the gateway:**
```bash
clawdock-restart
```
**Check container status:**
```bash
clawdock-status
```
**View live logs:**
```bash
clawdock-logs
```
### Set Up WhatsApp Bot
**Shell into the container:**
```bash
clawdock-shell
```
**Inside the container, login to WhatsApp:**
```bash
openclaw channels login --channel whatsapp --verbose
```
Scan the QR code with WhatsApp on your phone.
**Verify connection:**
```bash
openclaw status
```
### Troubleshooting Device Pairing
**Check for pending pairing requests:**
```bash
clawdock-devices
```
**Copy the Request ID from the "Pending" table, then approve:**
```bash
clawdock-approve <request-id>
```
Then refresh your browser.
### Fix Token Mismatch Issues
If you see "gateway token mismatch" errors:
```bash
clawdock-fix-token
```
This will:
1. Read the token from your `.env` file
2. Configure it in the OpenClaw config
3. Restart the gateway
4. Verify the configuration
### Permission Denied
**Ensure Docker is running and you have permission:**
```bash
docker ps
```
## Requirements
- Docker and Docker Compose installed
- Bash or Zsh shell
- OpenClaw project (from `docker-setup.sh`)
## Development
**Test with fresh config (mimics first-time install):**
```bash
unset CLAWDOCK_DIR && rm -f ~/.clawdock/config && source scripts/shell-helpers/clawdock-helpers.sh
```
Then run any command to trigger auto-detect:
```bash
clawdock-start
```

View File

@@ -0,0 +1,362 @@
#!/usr/bin/env bash
# ClawDock - Docker helpers for OpenClaw
# Inspired by Simon Willison's "Running OpenClaw in Docker"
# https://til.simonwillison.net/llms/openclaw-docker
#
# Installation:
# mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
# echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc
#
# Usage:
# clawdock-help # Show all available commands
# =============================================================================
# Colors
# =============================================================================
_CLR_RESET='\033[0m'
_CLR_BOLD='\033[1m'
_CLR_DIM='\033[2m'
_CLR_GREEN='\033[0;32m'
_CLR_YELLOW='\033[1;33m'
_CLR_BLUE='\033[0;34m'
_CLR_MAGENTA='\033[0;35m'
_CLR_CYAN='\033[0;36m'
_CLR_RED='\033[0;31m'
# Styled command output (green + bold)
_clr_cmd() {
echo -e "${_CLR_GREEN}${_CLR_BOLD}$1${_CLR_RESET}"
}
# Inline command for use in sentences
_cmd() {
echo "${_CLR_GREEN}${_CLR_BOLD}$1${_CLR_RESET}"
}
# =============================================================================
# Config
# =============================================================================
CLAWDOCK_CONFIG="${HOME}/.clawdock/config"
# Common paths to check for OpenClaw
CLAWDOCK_COMMON_PATHS=(
"${HOME}/openclaw"
"${HOME}/workspace/openclaw"
"${HOME}/projects/openclaw"
"${HOME}/dev/openclaw"
"${HOME}/code/openclaw"
"${HOME}/src/openclaw"
)
# Ensure CLAWDOCK_DIR is set and valid
_clawdock_ensure_dir() {
# Already set and valid?
if [[ -n "$CLAWDOCK_DIR" && -f "${CLAWDOCK_DIR}/docker-compose.yml" ]]; then
return 0
fi
# Try loading from config
if [[ -f "$CLAWDOCK_CONFIG" ]]; then
source "$CLAWDOCK_CONFIG"
if [[ -n "$CLAWDOCK_DIR" && -f "${CLAWDOCK_DIR}/docker-compose.yml" ]]; then
return 0
fi
fi
# Auto-detect from common paths
local found_path=""
for path in "${CLAWDOCK_COMMON_PATHS[@]}"; do
if [[ -f "${path}/docker-compose.yml" ]]; then
found_path="$path"
break
fi
done
if [[ -n "$found_path" ]]; then
echo ""
echo "🦞 Found OpenClaw at: $found_path"
echo -n " Use this location? [Y/n] "
read -r response
if [[ "$response" =~ ^[Nn] ]]; then
echo ""
echo "Set CLAWDOCK_DIR manually:"
echo " export CLAWDOCK_DIR=/path/to/openclaw"
return 1
fi
CLAWDOCK_DIR="$found_path"
else
echo ""
echo "❌ OpenClaw not found in common locations."
echo ""
echo "Clone it first:"
echo ""
echo " git clone https://github.com/openclaw/openclaw.git ~/openclaw"
echo " cd ~/openclaw && ./docker-setup.sh"
echo ""
echo "Or set CLAWDOCK_DIR if it's elsewhere:"
echo ""
echo " export CLAWDOCK_DIR=/path/to/openclaw"
echo ""
return 1
fi
# Save to config
if [[ ! -d "${HOME}/.clawdock" ]]; then
/bin/mkdir -p "${HOME}/.clawdock"
fi
echo "CLAWDOCK_DIR=\"$CLAWDOCK_DIR\"" > "$CLAWDOCK_CONFIG"
echo "✅ Saved to $CLAWDOCK_CONFIG"
echo ""
return 0
}
# Wrapper to run docker compose commands
_clawdock_compose() {
_clawdock_ensure_dir || return 1
command docker compose -f "${CLAWDOCK_DIR}/docker-compose.yml" "$@"
}
# Basic Operations
clawdock-start() {
_clawdock_compose up -d openclaw-gateway
}
clawdock-stop() {
_clawdock_compose down
}
clawdock-restart() {
_clawdock_compose restart openclaw-gateway
}
clawdock-logs() {
_clawdock_compose logs -f openclaw-gateway
}
clawdock-status() {
_clawdock_compose ps
}
# Navigation
clawdock-cd() {
_clawdock_ensure_dir || return 1
cd "${CLAWDOCK_DIR}"
}
clawdock-config() {
cd ~/.openclaw
}
clawdock-workspace() {
cd ~/.openclaw/workspace
}
# Container Access
clawdock-shell() {
_clawdock_compose exec openclaw-gateway \
bash -c 'echo "alias openclaw=\"./openclaw.mjs\"" > /tmp/.bashrc_openclaw && bash --rcfile /tmp/.bashrc_openclaw'
}
clawdock-exec() {
_clawdock_compose exec openclaw-gateway "$@"
}
clawdock-cli() {
_clawdock_compose run --rm openclaw-cli "$@"
}
# Maintenance
clawdock-rebuild() {
_clawdock_compose build openclaw-gateway
}
clawdock-clean() {
_clawdock_compose down -v --remove-orphans
}
# Health check
clawdock-health() {
_clawdock_ensure_dir || return 1
_clawdock_compose exec openclaw-gateway \
node dist/index.js health --token "$(grep OPENCLAW_GATEWAY_TOKEN ${CLAWDOCK_DIR}/.env | cut -d'=' -f2)"
}
# Show gateway token
clawdock-token() {
_clawdock_ensure_dir || return 1
grep OPENCLAW_GATEWAY_TOKEN "${CLAWDOCK_DIR}/.env" | cut -d'=' -f2
}
# Fix token configuration (run this once after setup)
clawdock-fix-token() {
_clawdock_ensure_dir || return 1
echo "🔧 Configuring gateway token..."
local token=$(clawdock-token)
if [[ -z "$token" ]]; then
echo "❌ Error: Could not find gateway token"
echo " Check: ${CLAWDOCK_DIR}/.env"
return 1
fi
echo "📝 Setting token: ${token:0:20}..."
_clawdock_compose exec -e "TOKEN=$token" openclaw-gateway \
bash -c './openclaw.mjs config set gateway.remote.token "$TOKEN" && ./openclaw.mjs config set gateway.auth.token "$TOKEN"' 2>&1 | grep -v "^WARN\|^time="
echo "🔍 Verifying token was saved..."
local saved_token=$(_clawdock_compose exec openclaw-gateway \
bash -c "./openclaw.mjs config get gateway.remote.token 2>/dev/null" 2>&1 | grep -v "^WARN\|^time=" | tr -d '\r\n' | head -c 64)
if [[ "$saved_token" == "$token" ]]; then
echo "✅ Token saved correctly!"
else
echo "⚠️ Token mismatch detected"
echo " Expected: ${token:0:20}..."
echo " Got: ${saved_token:0:20}..."
fi
echo "🔄 Restarting gateway..."
_clawdock_compose restart openclaw-gateway 2>&1 | grep -v "^WARN\|^time="
echo "⏳ Waiting for gateway to start..."
sleep 5
echo "✅ Configuration complete!"
echo -e " Try: $(_cmd clawdock-devices)"
}
# Open dashboard in browser
clawdock-dashboard() {
_clawdock_ensure_dir || return 1
echo "🦞 Getting dashboard URL..."
local url=$(_clawdock_compose run --rm openclaw-cli dashboard --no-open 2>&1 | grep -v "^WARN\|^time=" | grep -o 'http[s]\?://[^[:space:]]*')
if [[ -n "$url" ]]; then
echo "✅ Opening: $url"
open "$url" 2>/dev/null || xdg-open "$url" 2>/dev/null || echo " Please open manually: $url"
echo ""
echo -e "${_CLR_CYAN}💡 If you see 'pairing required' error:${_CLR_RESET}"
echo -e " 1. Run: $(_cmd clawdock-devices)"
echo " 2. Copy the Request ID from the Pending table"
echo -e " 3. Run: $(_cmd 'clawdock-approve <request-id>')"
else
echo "❌ Failed to get dashboard URL"
echo -e " Try restarting: $(_cmd clawdock-restart)"
fi
}
# List device pairings
clawdock-devices() {
_clawdock_ensure_dir || return 1
echo "🔍 Checking device pairings..."
_clawdock_compose exec openclaw-gateway \
node dist/index.js devices list 2>&1 | grep -v "^WARN\|^time="
local exit_code=${PIPESTATUS[0]}
if [ $exit_code -ne 0 ]; then
echo ""
echo -e "${_CLR_CYAN}💡 If you see token errors above:${_CLR_RESET}"
echo -e " 1. Verify token is set: $(_cmd clawdock-token)"
echo " 2. Try manual config inside container:"
echo -e " $(_cmd clawdock-shell)"
echo -e " $(_cmd 'openclaw config get gateway.remote.token')"
return 1
fi
echo ""
echo -e "${_CLR_CYAN}💡 To approve a pairing request:${_CLR_RESET}"
echo -e " $(_cmd 'clawdock-approve <request-id>')"
}
# Approve device pairing request
clawdock-approve() {
_clawdock_ensure_dir || return 1
if [[ -z "$1" ]]; then
echo -e "❌ Usage: $(_cmd 'clawdock-approve <request-id>')"
echo ""
echo -e "${_CLR_CYAN}💡 How to approve a device:${_CLR_RESET}"
echo -e " 1. Run: $(_cmd clawdock-devices)"
echo " 2. Find the Request ID in the Pending table (long UUID)"
echo -e " 3. Run: $(_cmd 'clawdock-approve <that-request-id>')"
echo ""
echo "Example:"
echo -e " $(_cmd 'clawdock-approve 6f9db1bd-a1cc-4d3f-b643-2c195262464e')"
return 1
fi
echo "✅ Approving device: $1"
_clawdock_compose exec openclaw-gateway \
node dist/index.js devices approve "$1" 2>&1 | grep -v "^WARN\|^time="
echo ""
echo "✅ Device approved! Refresh your browser."
}
# Show all available clawdock helper commands
clawdock-help() {
echo -e "\n${_CLR_BOLD}${_CLR_CYAN}🦞 ClawDock - Docker Helpers for OpenClaw${_CLR_RESET}\n"
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}⚡ Basic Operations${_CLR_RESET}"
echo -e " $(_cmd clawdock-start) ${_CLR_DIM}Start the gateway${_CLR_RESET}"
echo -e " $(_cmd clawdock-stop) ${_CLR_DIM}Stop the gateway${_CLR_RESET}"
echo -e " $(_cmd clawdock-restart) ${_CLR_DIM}Restart the gateway${_CLR_RESET}"
echo -e " $(_cmd clawdock-status) ${_CLR_DIM}Check container status${_CLR_RESET}"
echo -e " $(_cmd clawdock-logs) ${_CLR_DIM}View live logs (follows)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🐚 Container Access${_CLR_RESET}"
echo -e " $(_cmd clawdock-shell) ${_CLR_DIM}Shell into container (openclaw alias ready)${_CLR_RESET}"
echo -e " $(_cmd clawdock-cli) ${_CLR_DIM}Run CLI commands (e.g., clawdock-cli status)${_CLR_RESET}"
echo -e " $(_cmd clawdock-exec) ${_CLR_CYAN}<cmd>${_CLR_RESET} ${_CLR_DIM}Execute command in gateway container${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🌐 Web UI & Devices${_CLR_RESET}"
echo -e " $(_cmd clawdock-dashboard) ${_CLR_DIM}Open web UI in browser ${_CLR_CYAN}(auto-guides you)${_CLR_RESET}"
echo -e " $(_cmd clawdock-devices) ${_CLR_DIM}List device pairings ${_CLR_CYAN}(auto-guides you)${_CLR_RESET}"
echo -e " $(_cmd clawdock-approve) ${_CLR_CYAN}<id>${_CLR_RESET} ${_CLR_DIM}Approve device pairing ${_CLR_CYAN}(with examples)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}⚙️ Setup & Configuration${_CLR_RESET}"
echo -e " $(_cmd clawdock-fix-token) ${_CLR_DIM}Configure gateway token ${_CLR_CYAN}(run once)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🔧 Maintenance${_CLR_RESET}"
echo -e " $(_cmd clawdock-rebuild) ${_CLR_DIM}Rebuild Docker image${_CLR_RESET}"
echo -e " $(_cmd clawdock-clean) ${_CLR_RED}⚠️ Remove containers & volumes (nuclear)${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_MAGENTA}🛠️ Utilities${_CLR_RESET}"
echo -e " $(_cmd clawdock-health) ${_CLR_DIM}Run health check${_CLR_RESET}"
echo -e " $(_cmd clawdock-token) ${_CLR_DIM}Show gateway auth token${_CLR_RESET}"
echo -e " $(_cmd clawdock-cd) ${_CLR_DIM}Jump to openclaw project directory${_CLR_RESET}"
echo -e " $(_cmd clawdock-config) ${_CLR_DIM}Open config directory (~/.openclaw)${_CLR_RESET}"
echo -e " $(_cmd clawdock-workspace) ${_CLR_DIM}Open workspace directory${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${_CLR_RESET}"
echo -e "${_CLR_BOLD}${_CLR_GREEN}🚀 First Time Setup${_CLR_RESET}"
echo -e "${_CLR_CYAN} 1.${_CLR_RESET} $(_cmd clawdock-start) ${_CLR_DIM}# Start the gateway${_CLR_RESET}"
echo -e "${_CLR_CYAN} 2.${_CLR_RESET} $(_cmd clawdock-fix-token) ${_CLR_DIM}# Configure token${_CLR_RESET}"
echo -e "${_CLR_CYAN} 3.${_CLR_RESET} $(_cmd clawdock-dashboard) ${_CLR_DIM}# Open web UI${_CLR_RESET}"
echo -e "${_CLR_CYAN} 4.${_CLR_RESET} $(_cmd clawdock-devices) ${_CLR_DIM}# If pairing needed${_CLR_RESET}"
echo -e "${_CLR_CYAN} 5.${_CLR_RESET} $(_cmd clawdock-approve) ${_CLR_CYAN}<id>${_CLR_RESET} ${_CLR_DIM}# Approve pairing${_CLR_RESET}"
echo ""
echo -e "${_CLR_BOLD}${_CLR_GREEN}💬 WhatsApp Setup${_CLR_RESET}"
echo -e " $(_cmd clawdock-shell)"
echo -e " ${_CLR_BLUE}>${_CLR_RESET} $(_cmd 'openclaw channels login --channel whatsapp')"
echo -e " ${_CLR_BLUE}>${_CLR_RESET} $(_cmd 'openclaw status')"
echo ""
echo -e "${_CLR_BOLD}${_CLR_CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${_CLR_RESET}"
echo ""
echo -e "${_CLR_CYAN}💡 All commands guide you through next steps!${_CLR_RESET}"
echo -e "${_CLR_BLUE}📚 Docs: ${_CLR_RESET}${_CLR_CYAN}https://docs.openclaw.ai${_CLR_RESET}"
echo ""
}

View File

@@ -1,428 +0,0 @@
#!/usr/bin/env python3
"""
Sync maintainers and contributors in docs/reference/credits.md from git/GitHub.
- Maintainers: people who have merged PRs (via GitHub API) + direct pushes to main
- Contributors: all unique commit authors on main with commit counts
Usage: python scripts/sync-credits.py
"""
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
CREDITS_FILE = REPO_ROOT / "docs" / "reference" / "credits.md"
REPO = "openclaw/openclaw"
# Exclude bot accounts from maintainer list
EXCLUDED_MAINTAINERS = {
"app/clawdinator",
"clawdinator",
"github-actions",
"dependabot",
}
# Exclude bot/system names from contributor list
EXCLUDED_CONTRIBUTORS = {
"GitHub",
"github-actions[bot]",
"dependabot[bot]",
"clawdinator[bot]",
"blacksmith-sh[bot]",
"google-labs-jules[bot]",
"Maude Bot",
"Pocket Clawd",
"Ghost",
"Gregor's Bot",
"Jarvis",
"Jarvis Deploy",
"CI",
"Ubuntu",
"user",
"Developer",
# Bot names that appear in git history
"CLAWDINATOR Bot",
"Clawd",
"Clawdbot",
"Clawdbot Maintainers",
"Claude Code",
"L36 Server",
"seans-openclawbot",
"therealZpoint-bot",
"Vultr-Clawd Admin",
"hyf0-agent",
}
# Minimum merged PRs to be considered a maintainer
MIN_MERGES = 2
# Regex to extract GitHub username from noreply email
# Matches: ID+username@users.noreply.github.com or username@users.noreply.github.com
GITHUB_NOREPLY_RE = re.compile(r"^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$", re.I)
def extract_github_username(email: str) -> str | None:
"""Extract GitHub username from noreply email, or return None."""
match = GITHUB_NOREPLY_RE.match(email)
return match.group(1).lower() if match else None
def sanitize_name(name: str) -> str:
"""Sanitize name for MDX by removing curly braces (which MDX interprets as JS)."""
return name.replace("{", "").replace("}", "").strip()
def run_git(*args: str) -> str:
"""Run git command and return stdout."""
result = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
)
return result.stdout.strip()
def run_gh(*args: str) -> str:
"""Run gh CLI command and return stdout."""
result = subprocess.run(
["gh", *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
)
return result.stdout.strip()
def categorize_commit_files(files: list[str]) -> str:
"""Categorize a commit based on its changed files.
Returns: 'ci', 'docs only', 'docs', or 'other'
- 'ci': any commit with CI files (.github/, scripts/ci*)
- 'docs only': only documentation files (docs/ or any .md)
- 'docs': docs + other files mixed
- 'other': code without CI or docs
"""
has_ci = False
has_docs = False
has_other = False
for f in files:
f_lower = f.lower()
if f_lower.startswith(".github/") or f_lower.startswith("scripts/ci"):
has_ci = True
elif f_lower.startswith("docs/") or f_lower.endswith(".md"):
has_docs = True
else:
has_other = True
# CI takes priority if present
if has_ci:
return "ci"
if has_other:
if has_docs:
return "docs" # Mixed: docs + other
return "other" # Pure code
if has_docs:
return "docs only" # Pure docs
return "other"
def get_maintainers() -> list[tuple[str, int, dict[str, int]]]:
"""Get maintainers with (login, merge_count, push_counts_by_category).
- Merges: from GitHub API (who clicked "merge")
- Direct pushes: non-merge commits to main (by committer name matching login)
categorized into 'ci', 'docs', 'other'
"""
# 1. Fetch ALL merged PRs using gh pr list (handles pagination automatically)
print(" Fetching merged PRs from GitHub API...")
output = run_gh(
"pr",
"list",
"--repo",
REPO,
"--state",
"merged",
"--limit",
"10000",
"--json",
"mergedBy",
"--jq",
".[].mergedBy.login",
)
merge_counts: dict[str, int] = {}
if output:
for login in output.strip().splitlines():
login = login.strip()
if login and login not in EXCLUDED_MAINTAINERS:
merge_counts[login] = merge_counts.get(login, 0) + 1
print(
f" Found {sum(merge_counts.values())} merged PRs by {len(merge_counts)} users"
)
# 2. Count direct pushes (non-merge commits by committer) with categories
# Use GitHub username from noreply emails, or committer name as fallback
print(" Counting direct pushes from git history...")
# push_counts[key] = {"ci": N, "docs only": N, "docs": N, "other": N}
push_counts: dict[str, dict[str, int]] = {}
# Get commits with files using a delimiter to parse
output = run_git(
"log", "main", "--no-merges", "--format=COMMIT|%cN|%cE", "--name-only"
)
current_key: str | None = None
current_files: list[str] = []
def flush_commit() -> None:
nonlocal current_key, current_files
if current_key and current_files:
category = categorize_commit_files(current_files)
if current_key not in push_counts:
push_counts[current_key] = {
"ci": 0,
"docs only": 0,
"docs": 0,
"other": 0,
}
push_counts[current_key][category] += 1
current_key = None
current_files = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("COMMIT|"):
# Flush previous commit
flush_commit()
# Parse new commit
parts = line.split("|", 2)
if len(parts) < 3:
continue
_, name, email = parts
name = name.strip()
email = email.strip().lower()
if not name or name in EXCLUDED_CONTRIBUTORS:
current_key = None
continue
# Use GitHub username from noreply email if available
gh_user = extract_github_username(email)
current_key = gh_user if gh_user else name.lower()
else:
# This is a file path
if current_key:
current_files.append(line)
# Flush last commit
flush_commit()
# 3. Build maintainer list: anyone with merges >= MIN_MERGES
maintainers: list[tuple[str, int, dict[str, int]]] = []
for login, merges in merge_counts.items():
if merges >= MIN_MERGES:
# Try to find matching push count (case-insensitive)
pushes = push_counts.get(
login.lower(), {"ci": 0, "docs only": 0, "docs": 0, "other": 0}
)
maintainers.append((login, merges, pushes))
# Sort by total activity (merges + sum of pushes) descending
maintainers.sort(key=lambda x: (-(x[1] + sum(x[2].values())), x[0].lower()))
return maintainers
def get_contributors() -> list[tuple[str, int]]:
"""Get all unique commit authors on main with commit counts.
Merges authors by:
1. GitHub username (extracted from noreply emails)
2. Author name matching a known GitHub username
3. Display name (case-insensitive) as final fallback
"""
output = run_git("log", "main", "--format=%aN|%aE")
if not output:
return []
# First pass: collect all known GitHub usernames from noreply emails
known_github_users: set[str] = set()
for line in output.splitlines():
line = line.strip()
if not line or "|" not in line:
continue
_, email = line.rsplit("|", 1)
email = email.strip().lower()
if not email:
continue
gh_user = extract_github_username(email)
if gh_user:
known_github_users.add(gh_user)
# Second pass: count commits and pick canonical names
# Key priority: gh:username > name:lowercasename
counts: dict[str, int] = {}
canonical: dict[str, str] = {} # key -> preferred display name
for line in output.splitlines():
line = line.strip()
if not line or "|" not in line:
continue
name, email = line.rsplit("|", 1)
name = name.strip()
email = email.strip().lower()
if not name or not email or name in EXCLUDED_CONTRIBUTORS:
continue
# Sanitize name for MDX safety and consistent deduplication
sanitized = sanitize_name(name)
if not sanitized:
continue
# Determine the merge key:
# 1. If email is a noreply email, use the extracted GitHub username
# 2. If the author name matches a known GitHub username, use that
# 3. Otherwise use the sanitized display name (case-insensitive)
gh_user = extract_github_username(email)
if gh_user:
key = f"gh:{gh_user}"
elif sanitized.lower() in known_github_users:
key = f"gh:{sanitized.lower()}"
else:
key = f"name:{sanitized.lower()}"
counts[key] = counts.get(key, 0) + 1
# Prefer capitalized version, or longer name (more specific)
if key not in canonical or (
(sanitized[0].isupper() and not canonical[key][0].isupper())
or (
sanitized[0].isupper() == canonical[key][0].isupper()
and len(sanitized) > len(canonical[key])
)
):
canonical[key] = sanitized
# Build list with counts, sorted by count descending then name
contributors = [(canonical[key], count) for key, count in counts.items()]
contributors.sort(key=lambda x: (-x[1], x[0].lower()))
return contributors
def update_credits(
maintainers: list[tuple[str, int, dict[str, int]]],
contributors: list[tuple[str, int]],
) -> None:
"""Update the credits.md file with maintainers and contributors."""
content = CREDITS_FILE.read_text(encoding="utf-8")
# Build maintainers section (GitHub usernames with profile links)
maintainer_lines = []
for login, merges, push_cats in maintainers:
total_pushes = sum(push_cats.values())
if total_pushes > 0:
# Build categorized push breakdown
push_parts = []
if push_cats.get("ci", 0) > 0:
push_parts.append(f"{push_cats['ci']} ci")
if push_cats.get("docs only", 0) > 0:
push_parts.append(f"{push_cats['docs only']} docs only")
if push_cats.get("docs", 0) > 0:
push_parts.append(f"{push_cats['docs']} docs")
if push_cats.get("other", 0) > 0:
push_parts.append(f"{push_cats['other']} other")
push_str = ", ".join(push_parts)
line = f"- [@{login}](https://github.com/{login}) ({merges} merges, {total_pushes} direct changes: {push_str})"
else:
line = f"- [@{login}](https://github.com/{login}) ({merges} merges)"
maintainer_lines.append(line)
maintainer_section = (
"\n".join(maintainer_lines)
if maintainer_lines
else "_No maintainers detected._"
)
# Build contributors section with commit counts
# Sanitize names to avoid MDX interpreting special characters (like {}) as JS
contributor_lines = [
f"{sanitize_name(name)} ({count})" for name, count in contributors
]
contributor_section = (
", ".join(contributor_lines)
if contributor_lines
else "_No contributors detected._"
)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
contributor_section = f"{len(contributors)} contributors: {contributor_section}\n\n_Last updated: {timestamp}_"
# Replace sections by finding markers and rebuilding
lines = content.split("\n")
result = []
skip_until_next_section = False
i = 0
while i < len(lines):
line = lines[i]
if line == "## Maintainers":
result.append(line)
result.append("")
result.append(maintainer_section)
skip_until_next_section = True
i += 1
continue
if line == "## Contributors":
result.append("")
result.append(line)
result.append("")
result.append(contributor_section)
skip_until_next_section = True
i += 1
continue
# Check if we hit the next section
if skip_until_next_section and (
line.startswith("## ") or line.startswith("> ")
):
skip_until_next_section = False
result.append("") # blank line before next section
if not skip_until_next_section:
result.append(line)
i += 1
content = "\n".join(result)
CREDITS_FILE.write_text(content, encoding="utf-8")
print(f"Updated {CREDITS_FILE}")
print(f" Maintainers: {len(maintainers)}")
print(f" Contributors: {len(contributors)}")
def main() -> None:
print("Syncing credits from git/GitHub...")
maintainers = get_maintainers()
contributors = get_contributors()
update_credits(maintainers, contributors)
if __name__ == "__main__":
main()

View File

@@ -15,7 +15,7 @@ import {
normalizeTextForComparison,
} from "./pi-embedded-helpers.js";
import { createEmbeddedPiSessionEventHandler } from "./pi-embedded-subscribe.handlers.js";
import { formatReasoningMessage } from "./pi-embedded-utils.js";
import { formatReasoningMessage, stripDowngradedToolCallText } from "./pi-embedded-utils.js";
import { hasNonzeroUsage, normalizeUsage, type UsageLike } from "./usage.js";
const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
@@ -449,7 +449,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
return;
}
// Strip <think> and <final> blocks across chunk boundaries to avoid leaking reasoning.
const chunk = stripBlockTags(text, state.blockState).trimEnd();
// Also strip downgraded tool call text ([Tool Call: ...], [Historical context: ...], etc.).
const chunk = stripDowngradedToolCallText(stripBlockTags(text, state.blockState)).trimEnd();
if (!chunk) {
return;
}

View File

@@ -1,6 +1,10 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { extractAssistantText, formatReasoningMessage } from "./pi-embedded-utils.js";
import {
extractAssistantText,
formatReasoningMessage,
stripDowngradedToolCallText,
} from "./pi-embedded-utils.js";
describe("extractAssistantText", () => {
it("strips Minimax tool invocation XML from text", () => {
@@ -559,3 +563,39 @@ describe("formatReasoningMessage", () => {
);
});
});
describe("stripDowngradedToolCallText", () => {
it("strips [Historical context: ...] blocks", () => {
const text = `[Historical context: a different model called tool "exec" with arguments {"command":"git status"}]`;
expect(stripDowngradedToolCallText(text)).toBe("");
});
it("preserves text before [Historical context: ...] blocks", () => {
const text = `Here is the answer.\n[Historical context: a different model called tool "read"]`;
expect(stripDowngradedToolCallText(text)).toBe("Here is the answer.");
});
it("preserves text around [Historical context: ...] blocks", () => {
const text = `Before.\n[Historical context: tool call info]\nAfter.`;
expect(stripDowngradedToolCallText(text)).toBe("Before.\nAfter.");
});
it("strips multiple [Historical context: ...] blocks", () => {
const text = `[Historical context: first tool call]\n[Historical context: second tool call]`;
expect(stripDowngradedToolCallText(text)).toBe("");
});
it("strips mixed [Tool Call: ...] and [Historical context: ...] blocks", () => {
const text = `Intro.\n[Tool Call: exec (ID: toolu_1)]\nArguments: { "command": "ls" }\n[Historical context: a different model called tool "read"]`;
expect(stripDowngradedToolCallText(text)).toBe("Intro.");
});
it("returns text unchanged when no markers are present", () => {
const text = "Just a normal response with no markers.";
expect(stripDowngradedToolCallText(text)).toBe("Just a normal response with no markers.");
});
it("returns empty string for empty input", () => {
expect(stripDowngradedToolCallText("")).toBe("");
});
});

View File

@@ -37,7 +37,7 @@ export function stripDowngradedToolCallText(text: string): string {
if (!text) {
return text;
}
if (!/\[Tool (?:Call|Result)/i.test(text)) {
if (!/\[Tool (?:Call|Result)/i.test(text) && !/\[Historical context/i.test(text)) {
return text;
}
@@ -186,6 +186,9 @@ export function stripDowngradedToolCallText(text: string): string {
// Remove [Tool Result for ID ...] blocks and their content.
cleaned = cleaned.replace(/\[Tool Result for ID[^\]]*\]\n?[\s\S]*?(?=\n*\[Tool |\n*$)/gi, "");
// Remove [Historical context: ...] markers (self-contained within brackets).
cleaned = cleaned.replace(/\[Historical context:[^\]]*\]\n?/gi, "");
return cleaned.trim();
}

View File

@@ -162,6 +162,80 @@ describe("message tool description", () => {
});
});
describe("message tool reasoning tag sanitization", () => {
it("strips <think> tags from text field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
const tool = createMessageTool({ config: {} as never });
await tool.execute("1", {
action: "send",
target: "signal:+15551234567",
text: "<think>internal reasoning</think>Hello!",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.text).toBe("Hello!");
});
it("strips <think> tags from content field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "discord",
to: "discord:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
const tool = createMessageTool({ config: {} as never });
await tool.execute("1", {
action: "send",
target: "discord:123",
content: "<think>reasoning here</think>Reply text",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.content).toBe("Reply text");
});
it("passes through text without reasoning tags unchanged", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
const tool = createMessageTool({ config: {} as never });
await tool.execute("1", {
action: "send",
target: "signal:+15551234567",
text: "Normal message without any tags",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.text).toBe("Normal message without any tags");
});
});
describe("message tool sandbox passthrough", () => {
it("forwards sandboxRoot to runMessageAction", async () => {
mocks.runMessageAction.mockClear();

View File

@@ -16,6 +16,7 @@ import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { listChannelSupportedActions } from "../channel-tools.js";
@@ -405,7 +406,17 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
err.name = "AbortError";
throw err;
}
const params = args as Record<string, unknown>;
// Shallow-copy so we don't mutate the original event args (used for logging/dedup).
const params = { ...(args as Record<string, unknown>) };
// Strip reasoning tags from text fields — models may include <think>…</think>
// in tool arguments, and the messaging tool send path has no other tag filtering.
for (const field of ["text", "content", "message", "caption"]) {
if (typeof params[field] === "string") {
params[field] = stripReasoningTagsFromText(params[field]);
}
}
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", {
required: true,

View File

@@ -25,7 +25,8 @@ export type AuthChoiceGroupId =
| "qwen"
| "together"
| "qianfan"
| "xai";
| "xai"
| "custom";
export type AuthChoiceGroup = {
value: AuthChoiceGroupId;
@@ -148,6 +149,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "Account ID + Gateway ID + API key",
choices: ["cloudflare-ai-gateway-api-key"],
},
{
value: "custom",
label: "Custom Provider",
hint: "Any OpenAI or Anthropic compatible endpoint",
choices: ["custom-api-key"],
},
];
export function buildAuthChoiceOptions(params: {
@@ -252,6 +259,8 @@ export function buildAuthChoiceOptions(params: {
label: "MiniMax M2.1 Lightning",
hint: "Faster, higher output cost",
});
options.push({ value: "custom-api-key", label: "Custom Provider" });
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });
}

View File

@@ -42,6 +42,10 @@ export async function promptAuthChoiceGrouped(params: {
continue;
}
if (group.options.length === 1) {
return group.options[0].value;
}
const methodSelection = await params.prompter.select({
message: `${group.label} auth method`,
options: [...group.options, { value: BACK_VALUE, label: "Back" }],

View File

@@ -35,6 +35,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"qwen-portal": "qwen-portal",
"minimax-portal": "minimax-portal",
"qianfan-api-key": "qianfan",
"custom-api-key": "custom",
};
export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined {

View File

@@ -11,6 +11,7 @@ import {
promptDefaultModel,
promptModelAllowlist,
} from "./model-picker.js";
import { promptCustomApiConfig } from "./onboard-custom.js";
type GatewayAuthChoice = "token" | "password";
@@ -53,7 +54,10 @@ export async function promptAuthConfig(
});
let next = cfg;
if (authChoice !== "skip") {
if (authChoice === "custom-api-key") {
const customResult = await promptCustomApiConfig({ prompter, runtime, config: next });
next = customResult.config;
} else if (authChoice !== "skip") {
const applied = await applyAuthChoice({
authChoice,
config: next,
@@ -78,16 +82,18 @@ export async function promptAuthConfig(
const anthropicOAuth =
authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth";
const allowlistSelection = await promptModelAllowlist({
config: next,
prompter,
allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined,
initialSelections: anthropicOAuth ? ["anthropic/claude-opus-4-6"] : undefined,
message: anthropicOAuth ? "Anthropic OAuth models" : undefined,
});
if (allowlistSelection.models) {
next = applyModelAllowlist(next, allowlistSelection.models);
next = applyModelFallbacksFromSelection(next, allowlistSelection.models);
if (authChoice !== "custom-api-key") {
const allowlistSelection = await promptModelAllowlist({
config: next,
prompter,
allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined,
initialSelections: anthropicOAuth ? ["anthropic/claude-opus-4-6"] : undefined,
message: anthropicOAuth ? "Anthropic OAuth models" : undefined,
});
if (allowlistSelection.models) {
next = applyModelAllowlist(next, allowlistSelection.models);
next = applyModelFallbacksFromSelection(next, allowlistSelection.models);
}
}
return next;

View File

@@ -0,0 +1,270 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../runtime.js";
import { promptCustomApiConfig } from "./onboard-custom.js";
// Mock dependencies
vi.mock("./model-picker.js", () => ({
applyPrimaryModel: vi.fn((cfg) => cfg),
}));
describe("promptCustomApiConfig", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("handles openai flow and saves alias", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("llama3") // Model ID
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce("local"), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("openai"), // Compatibility
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({}),
}),
);
const result = await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
});
expect(prompter.text).toHaveBeenCalledTimes(5);
expect(prompter.select).toHaveBeenCalledTimes(1);
expect(result.config.models?.providers?.custom?.api).toBe("openai-completions");
expect(result.config.agents?.defaults?.models?.["custom/llama3"]?.alias).toBe("local");
});
it("retries when verification fails", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("bad-model") // Model ID
.mockResolvedValueOnce("good-model") // Model ID retry
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi
.fn()
.mockResolvedValueOnce("openai") // Compatibility
.mockResolvedValueOnce("model"), // Retry choice
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({}) }),
);
await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
});
expect(prompter.text).toHaveBeenCalledTimes(6);
expect(prompter.select).toHaveBeenCalledTimes(2);
});
it("detects openai compatibility when unknown", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("https://example.com/v1") // Base URL
.mockResolvedValueOnce("test-key") // API Key
.mockResolvedValueOnce("detected-model") // Model ID
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce("alias"), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("unknown"),
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({}),
}),
);
const result = await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
});
expect(prompter.text).toHaveBeenCalledTimes(5);
expect(prompter.select).toHaveBeenCalledTimes(1);
expect(result.config.models?.providers?.custom?.api).toBe("openai-completions");
});
it("re-prompts base url when unknown detection fails", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("https://bad.example.com/v1") // Base URL #1
.mockResolvedValueOnce("bad-key") // API Key #1
.mockResolvedValueOnce("bad-model") // Model ID #1
.mockResolvedValueOnce("https://ok.example.com/v1") // Base URL #2
.mockResolvedValueOnce("ok-key") // API Key #2
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("unknown").mockResolvedValueOnce("baseUrl"),
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) })
.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({}) }),
);
await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
});
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("did not respond"),
"Endpoint detection",
);
});
it("renames provider id when baseUrl differs", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("llama3") // Model ID
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("openai"),
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({}),
}),
);
const result = await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {
models: {
providers: {
custom: {
baseUrl: "http://old.example.com/v1",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
contextWindow: 1,
maxTokens: 1,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
},
],
},
},
},
},
});
expect(result.providerId).toBe("custom-2");
expect(result.config.models?.providers?.custom).toBeDefined();
expect(result.config.models?.providers?.["custom-2"]).toBeDefined();
});
it("aborts verification after timeout", async () => {
vi.useFakeTimers();
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("slow-model") // Model ID
.mockResolvedValueOnce("fast-model") // Model ID retry
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("openai").mockResolvedValueOnce("model"),
confirm: vi.fn(),
note: vi.fn(),
};
const fetchMock = vi
.fn()
.mockImplementationOnce((_url: string, init?: { signal?: AbortSignal }) => {
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new Error("AbortError")));
});
})
.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
vi.stubGlobal("fetch", fetchMock);
const promise = promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
});
await vi.advanceTimersByTimeAsync(10000);
await promise;
expect(prompter.text).toHaveBeenCalledTimes(6);
});
});

View File

@@ -0,0 +1,476 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import { applyPrimaryModel } from "./model-picker.js";
import { normalizeAlias } from "./models/shared.js";
const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
const DEFAULT_CONTEXT_WINDOW = 4096;
const DEFAULT_MAX_TOKENS = 4096;
const VERIFY_TIMEOUT_MS = 10000;
type CustomApiCompatibility = "openai" | "anthropic";
type CustomApiCompatibilityChoice = CustomApiCompatibility | "unknown";
type CustomApiResult = {
config: OpenClawConfig;
providerId?: string;
modelId?: string;
};
const COMPATIBILITY_OPTIONS: Array<{
value: CustomApiCompatibilityChoice;
label: string;
hint: string;
api?: "openai-completions" | "anthropic-messages";
}> = [
{
value: "openai",
label: "OpenAI-compatible",
hint: "Uses /chat/completions",
api: "openai-completions",
},
{
value: "anthropic",
label: "Anthropic-compatible",
hint: "Uses /messages",
api: "anthropic-messages",
},
{
value: "unknown",
label: "Unknown (detect automatically)",
hint: "Probes OpenAI then Anthropic endpoints",
},
];
function normalizeEndpointId(raw: string): string {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return "";
}
return trimmed.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
}
function buildEndpointIdFromUrl(baseUrl: string): string {
try {
const url = new URL(baseUrl);
const host = url.hostname.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
const port = url.port ? `-${url.port}` : "";
const candidate = `custom-${host}${port}`;
return normalizeEndpointId(candidate) || "custom";
} catch {
return "custom";
}
}
function resolveUniqueEndpointId(params: {
requestedId: string;
baseUrl: string;
providers: Record<string, ModelProviderConfig | undefined>;
}) {
const normalized = normalizeEndpointId(params.requestedId) || "custom";
const existing = params.providers[normalized];
if (!existing?.baseUrl || existing.baseUrl === params.baseUrl) {
return { providerId: normalized, renamed: false };
}
let suffix = 2;
let candidate = `${normalized}-${suffix}`;
while (params.providers[candidate]) {
suffix += 1;
candidate = `${normalized}-${suffix}`;
}
return { providerId: candidate, renamed: true };
}
function resolveAliasError(params: {
raw: string;
cfg: OpenClawConfig;
modelRef: string;
}): string | undefined {
const trimmed = params.raw.trim();
if (!trimmed) {
return undefined;
}
let normalized: string;
try {
normalized = normalizeAlias(trimmed);
} catch (err) {
return err instanceof Error ? err.message : "Alias is invalid.";
}
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const aliasKey = normalized.toLowerCase();
const existing = aliasIndex.byAlias.get(aliasKey);
if (!existing) {
return undefined;
}
const existingKey = modelKey(existing.ref.provider, existing.ref.model);
if (existingKey === params.modelRef) {
return undefined;
}
return `Alias ${normalized} already points to ${existingKey}.`;
}
function buildOpenAiHeaders(apiKey: string) {
const headers: Record<string, string> = {};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
return headers;
}
function buildAnthropicHeaders(apiKey: string) {
const headers: Record<string, string> = {
"anthropic-version": "2023-06-01",
};
if (apiKey) {
headers["x-api-key"] = apiKey;
}
return headers;
}
function formatVerificationError(error: unknown): string {
if (!error) {
return "unknown error";
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "unknown error";
}
}
type VerificationResult = {
ok: boolean;
status?: number;
error?: unknown;
};
async function requestOpenAiVerification(params: {
baseUrl: string;
apiKey: string;
modelId: string;
}): Promise<VerificationResult> {
const endpoint = new URL(
"chat/completions",
params.baseUrl.endsWith("/") ? params.baseUrl : `${params.baseUrl}/`,
).href;
try {
const res = await fetchWithTimeout(
endpoint,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...buildOpenAiHeaders(params.apiKey),
},
body: JSON.stringify({
model: params.modelId,
messages: [{ role: "user", content: "Hi" }],
max_tokens: 5,
}),
},
VERIFY_TIMEOUT_MS,
);
return { ok: res.ok, status: res.status };
} catch (error) {
return { ok: false, error };
}
}
async function requestAnthropicVerification(params: {
baseUrl: string;
apiKey: string;
modelId: string;
}): Promise<VerificationResult> {
const endpoint = new URL(
"messages",
params.baseUrl.endsWith("/") ? params.baseUrl : `${params.baseUrl}/`,
).href;
try {
const res = await fetchWithTimeout(
endpoint,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...buildAnthropicHeaders(params.apiKey),
},
body: JSON.stringify({
model: params.modelId,
max_tokens: 16,
messages: [{ role: "user", content: "Hi" }],
}),
},
VERIFY_TIMEOUT_MS,
);
return { ok: res.ok, status: res.status };
} catch (error) {
return { ok: false, error };
}
}
async function promptBaseUrlAndKey(params: {
prompter: WizardPrompter;
initialBaseUrl?: string;
}): Promise<{ baseUrl: string; apiKey: string }> {
const baseUrlInput = await params.prompter.text({
message: "API Base URL",
initialValue: params.initialBaseUrl ?? DEFAULT_OLLAMA_BASE_URL,
placeholder: "https://api.example.com/v1",
validate: (val) => {
try {
new URL(val);
return undefined;
} catch {
return "Please enter a valid URL (e.g. http://...)";
}
},
});
const apiKeyInput = await params.prompter.text({
message: "API Key (leave blank if not required)",
placeholder: "sk-...",
initialValue: "",
});
return { baseUrl: baseUrlInput.trim(), apiKey: apiKeyInput.trim() };
}
export async function promptCustomApiConfig(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
config: OpenClawConfig;
}): Promise<CustomApiResult> {
const { prompter, runtime, config } = params;
const baseInput = await promptBaseUrlAndKey({ prompter });
let baseUrl = baseInput.baseUrl;
let apiKey = baseInput.apiKey;
const compatibilityChoice = await prompter.select({
message: "Endpoint compatibility",
options: COMPATIBILITY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
hint: option.hint,
})),
});
let modelId = (
await prompter.text({
message: "Model ID",
placeholder: "e.g. llama3, claude-3-7-sonnet",
validate: (val) => (val.trim() ? undefined : "Model ID is required"),
})
).trim();
let compatibility: CustomApiCompatibility | null =
compatibilityChoice === "unknown" ? null : compatibilityChoice;
let providerApi =
COMPATIBILITY_OPTIONS.find((entry) => entry.value === compatibility)?.api ??
"openai-completions";
while (true) {
let verifiedFromProbe = false;
if (!compatibility) {
const probeSpinner = prompter.progress("Detecting endpoint type...");
const openaiProbe = await requestOpenAiVerification({ baseUrl, apiKey, modelId });
if (openaiProbe.ok) {
probeSpinner.stop("Detected OpenAI-compatible endpoint.");
compatibility = "openai";
providerApi = "openai-completions";
verifiedFromProbe = true;
} else {
const anthropicProbe = await requestAnthropicVerification({ baseUrl, apiKey, modelId });
if (anthropicProbe.ok) {
probeSpinner.stop("Detected Anthropic-compatible endpoint.");
compatibility = "anthropic";
providerApi = "anthropic-messages";
verifiedFromProbe = true;
} else {
probeSpinner.stop("Could not detect endpoint type.");
await prompter.note(
"This endpoint did not respond to OpenAI or Anthropic style requests.",
"Endpoint detection",
);
const retryChoice = await prompter.select({
message: "What would you like to change?",
options: [
{ value: "baseUrl", label: "Change base URL" },
{ value: "model", label: "Change model" },
{ value: "both", label: "Change base URL and model" },
],
});
if (retryChoice === "baseUrl" || retryChoice === "both") {
const retryInput = await promptBaseUrlAndKey({
prompter,
initialBaseUrl: baseUrl,
});
baseUrl = retryInput.baseUrl;
apiKey = retryInput.apiKey;
}
if (retryChoice === "model" || retryChoice === "both") {
modelId = (
await prompter.text({
message: "Model ID",
placeholder: "e.g. llama3, claude-3-7-sonnet",
validate: (val) => (val.trim() ? undefined : "Model ID is required"),
})
).trim();
}
continue;
}
}
}
if (verifiedFromProbe) {
break;
}
const verifySpinner = prompter.progress("Verifying...");
const result =
compatibility === "anthropic"
? await requestAnthropicVerification({ baseUrl, apiKey, modelId })
: await requestOpenAiVerification({ baseUrl, apiKey, modelId });
if (result.ok) {
verifySpinner.stop("Verification successful.");
break;
}
if (result.status !== undefined) {
verifySpinner.stop(`Verification failed: status ${result.status}`);
} else {
verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`);
}
const retryChoice = await prompter.select({
message: "What would you like to change?",
options: [
{ value: "baseUrl", label: "Change base URL" },
{ value: "model", label: "Change model" },
{ value: "both", label: "Change base URL and model" },
],
});
if (retryChoice === "baseUrl" || retryChoice === "both") {
const retryInput = await promptBaseUrlAndKey({
prompter,
initialBaseUrl: baseUrl,
});
baseUrl = retryInput.baseUrl;
apiKey = retryInput.apiKey;
}
if (retryChoice === "model" || retryChoice === "both") {
modelId = (
await prompter.text({
message: "Model ID",
placeholder: "e.g. llama3, claude-3-7-sonnet",
validate: (val) => (val.trim() ? undefined : "Model ID is required"),
})
).trim();
}
if (compatibilityChoice === "unknown") {
compatibility = null;
}
}
const providers = config.models?.providers ?? {};
const suggestedId = buildEndpointIdFromUrl(baseUrl);
const providerIdInput = await prompter.text({
message: "Endpoint ID",
initialValue: suggestedId,
placeholder: "custom",
validate: (value) => {
const normalized = normalizeEndpointId(value);
if (!normalized) {
return "Endpoint ID is required.";
}
return undefined;
},
});
const providerIdResult = resolveUniqueEndpointId({
requestedId: providerIdInput,
baseUrl,
providers,
});
if (providerIdResult.renamed) {
await prompter.note(
`Endpoint ID "${providerIdInput}" already exists for a different base URL. Using "${providerIdResult.providerId}".`,
"Endpoint ID",
);
}
const providerId = providerIdResult.providerId;
const modelRef = modelKey(providerId, modelId);
const aliasInput = await prompter.text({
message: "Model alias (optional)",
placeholder: "e.g. local, ollama",
initialValue: "",
validate: (value) => resolveAliasError({ raw: value, cfg: config, modelRef }),
});
const alias = aliasInput.trim();
const existingProvider = providers[providerId];
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const hasModel = existingModels.some((model) => model.id === modelId);
const nextModel = {
id: modelId,
name: `${modelId} (Custom Provider)`,
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
input: ["text"] as ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
};
const mergedModels = hasModel ? existingModels : [...existingModels, nextModel];
const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {};
const normalizedApiKey = apiKey.trim() || (existingApiKey ? existingApiKey.trim() : undefined);
let newConfig: OpenClawConfig = {
...config,
models: {
...config.models,
mode: config.models?.mode ?? "merge",
providers: {
...providers,
[providerId]: {
...existingProviderRest,
baseUrl,
api: providerApi,
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [nextModel],
},
},
},
};
newConfig = applyPrimaryModel(newConfig, modelRef);
if (alias) {
newConfig = {
...newConfig,
agents: {
...newConfig.agents,
defaults: {
...newConfig.agents?.defaults,
models: {
...newConfig.agents?.defaults?.models,
[modelRef]: {
...newConfig.agents?.defaults?.models?.[modelRef],
alias,
},
},
},
},
};
}
runtime.log(`Configured custom provider: ${providerId}/${modelId}`);
return { config: newConfig, providerId, modelId };
}

View File

@@ -38,7 +38,27 @@ export type AuthChoice =
| "qwen-portal"
| "xai-api-key"
| "qianfan-api-key"
| "custom-api-key"
| "skip";
export type AuthChoiceGroupId =
| "openai"
| "anthropic"
| "google"
| "copilot"
| "openrouter"
| "ai-gateway"
| "cloudflare-ai-gateway"
| "moonshot"
| "zai"
| "xiaomi"
| "opencode-zen"
| "minimax"
| "synthetic"
| "venice"
| "qwen"
| "qianfan"
| "xai"
| "custom";
export type GatewayAuthChoice = "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";

View File

@@ -7,6 +7,13 @@ import { describe, expect, it } from "vitest";
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
type DockerSetupSandbox = {
rootDir: string;
scriptPath: string;
logPath: string;
binDir: string;
};
async function writeDockerStub(binDir: string, logPath: string) {
const stub = `#!/usr/bin/env bash
set -euo pipefail
@@ -31,105 +38,132 @@ exit 0
await writeFile(logPath, "");
}
async function createDockerSetupSandbox(): Promise<DockerSetupSandbox> {
const rootDir = await mkdtemp(join(tmpdir(), "openclaw-docker-setup-"));
const scriptPath = join(rootDir, "docker-setup.sh");
const dockerfilePath = join(rootDir, "Dockerfile");
const composePath = join(rootDir, "docker-compose.yml");
const binDir = join(rootDir, "bin");
const logPath = join(rootDir, "docker-stub.log");
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
await writeFile(scriptPath, script, { mode: 0o755 });
await writeFile(dockerfilePath, "FROM scratch\n");
await writeFile(
composePath,
"services:\n openclaw-gateway:\n image: noop\n openclaw-cli:\n image: noop\n",
);
await writeDockerStub(binDir, logPath);
return { rootDir, scriptPath, logPath, binDir };
}
function createEnv(
sandbox: DockerSetupSandbox,
overrides: Record<string, string | undefined> = {},
): NodeJS.ProcessEnv {
return {
...process.env,
PATH: `${sandbox.binDir}:${process.env.PATH ?? ""}`,
DOCKER_STUB_LOG: sandbox.logPath,
OPENCLAW_GATEWAY_TOKEN: "test-token",
OPENCLAW_CONFIG_DIR: join(sandbox.rootDir, "config"),
OPENCLAW_WORKSPACE_DIR: join(sandbox.rootDir, "openclaw"),
...overrides,
};
}
describe("docker-setup.sh", () => {
it("handles unset optional env vars under strict mode", async () => {
const assocCheck = spawnSync("bash", ["-c", "declare -A _t=()"], {
encoding: "utf8",
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_DOCKER_APT_PACKAGES: undefined,
OPENCLAW_EXTRA_MOUNTS: undefined,
OPENCLAW_HOME_VOLUME: undefined,
});
if (assocCheck.status !== 0) {
return;
}
const rootDir = await mkdtemp(join(tmpdir(), "openclaw-docker-setup-"));
const scriptPath = join(rootDir, "docker-setup.sh");
const dockerfilePath = join(rootDir, "Dockerfile");
const composePath = join(rootDir, "docker-compose.yml");
const binDir = join(rootDir, "bin");
const logPath = join(rootDir, "docker-stub.log");
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
await writeFile(scriptPath, script, { mode: 0o755 });
await writeFile(dockerfilePath, "FROM scratch\n");
await writeFile(
composePath,
"services:\n openclaw-gateway:\n image: noop\n openclaw-cli:\n image: noop\n",
);
await writeDockerStub(binDir, logPath);
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
DOCKER_STUB_LOG: logPath,
OPENCLAW_GATEWAY_TOKEN: "test-token",
OPENCLAW_CONFIG_DIR: join(rootDir, "config"),
OPENCLAW_WORKSPACE_DIR: join(rootDir, "openclaw"),
};
delete env.OPENCLAW_DOCKER_APT_PACKAGES;
delete env.OPENCLAW_EXTRA_MOUNTS;
delete env.OPENCLAW_HOME_VOLUME;
const result = spawnSync("bash", [scriptPath], {
cwd: rootDir,
const result = spawnSync("bash", [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(rootDir, ".env"), "utf8");
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=");
expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS=");
expect(envFile).toContain("OPENCLAW_HOME_VOLUME=");
});
it("plumbs OPENCLAW_DOCKER_APT_PACKAGES into .env and docker build args", async () => {
const assocCheck = spawnSync("bash", ["-c", "declare -A _t=()"], {
encoding: "utf8",
});
if (assocCheck.status !== 0) {
return;
}
const rootDir = await mkdtemp(join(tmpdir(), "openclaw-docker-setup-"));
const scriptPath = join(rootDir, "docker-setup.sh");
const dockerfilePath = join(rootDir, "Dockerfile");
const composePath = join(rootDir, "docker-compose.yml");
const binDir = join(rootDir, "bin");
const logPath = join(rootDir, "docker-stub.log");
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
await writeFile(scriptPath, script, { mode: 0o755 });
await writeFile(dockerfilePath, "FROM scratch\n");
await writeFile(
composePath,
"services:\n openclaw-gateway:\n image: noop\n openclaw-cli:\n image: noop\n",
);
await writeDockerStub(binDir, logPath);
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
DOCKER_STUB_LOG: logPath,
OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential",
OPENCLAW_GATEWAY_TOKEN: "test-token",
OPENCLAW_CONFIG_DIR: join(rootDir, "config"),
OPENCLAW_WORKSPACE_DIR: join(rootDir, "openclaw"),
it("supports a home volume when extra mounts are empty", async () => {
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_EXTRA_MOUNTS: "",
OPENCLAW_HOME_VOLUME: "",
};
OPENCLAW_HOME_VOLUME: "openclaw-home",
});
const result = spawnSync("bash", [scriptPath], {
cwd: rootDir,
const result = spawnSync("bash", [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(rootDir, ".env"), "utf8");
const extraCompose = await readFile(join(sandbox.rootDir, "docker-compose.extra.yml"), "utf8");
expect(extraCompose).toContain("openclaw-home:/home/node");
expect(extraCompose).toContain("volumes:");
expect(extraCompose).toContain("openclaw-home:");
});
it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => {
const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8");
expect(script).not.toMatch(/^\s*declare -A\b/m);
const systemBash = "/bin/bash";
const assocCheck = spawnSync(systemBash, ["-c", "declare -A _t=()"], {
encoding: "utf8",
});
if (assocCheck.status === 0) {
return;
}
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_EXTRA_MOUNTS: "",
OPENCLAW_HOME_VOLUME: "",
});
const result = spawnSync(systemBash, [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("declare: -A: invalid option");
});
it("plumbs OPENCLAW_DOCKER_APT_PACKAGES into .env and docker build args", async () => {
const sandbox = await createDockerSetupSandbox();
const env = createEnv(sandbox, {
OPENCLAW_DOCKER_APT_PACKAGES: "ffmpeg build-essential",
OPENCLAW_EXTRA_MOUNTS: "",
OPENCLAW_HOME_VOLUME: "",
});
const result = spawnSync("bash", [sandbox.scriptPath], {
cwd: sandbox.rootDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential");
const log = await readFile(logPath, "utf8");
const log = await readFile(sandbox.logPath, "utf8");
expect(log).toContain("--build-arg OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential");
});

View File

@@ -18,6 +18,7 @@ import {
} from "../commands/auth-choice.js";
import { applyPrimaryModel, promptDefaultModel } from "../commands/model-picker.js";
import { setupChannels } from "../commands/onboard-channels.js";
import { promptCustomApiConfig } from "../commands/onboard-custom.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
@@ -378,26 +379,38 @@ export async function runOnboardingWizard(
includeSkip: true,
}));
const authResult = await applyAuthChoice({
authChoice,
config: nextConfig,
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: opts.tokenProvider,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
},
});
nextConfig = authResult.config;
let customPreferredProvider: string | undefined;
if (authChoice === "custom-api-key") {
const customResult = await promptCustomApiConfig({
prompter,
runtime,
config: nextConfig,
});
nextConfig = customResult.config;
customPreferredProvider = customResult.providerId;
} else {
const authResult = await applyAuthChoice({
authChoice,
config: nextConfig,
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: opts.tokenProvider,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
},
});
nextConfig = authResult.config;
}
if (authChoiceFromPrompt) {
if (authChoiceFromPrompt && authChoice !== "custom-api-key") {
const modelSelection = await promptDefaultModel({
config: nextConfig,
prompter,
allowKeep: true,
ignoreAllowlist: true,
preferredProvider: resolvePreferredProviderForAuthChoice(authChoice),
preferredProvider:
customPreferredProvider ?? resolvePreferredProviderForAuthChoice(authChoice),
});
if (modelSelection.model) {
nextConfig = applyPrimaryModel(nextConfig, modelSelection.model);