Compare commits
29 Commits
feature/on
...
feat/shell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8cfceaa37 | ||
|
|
fbb90b155d | ||
|
|
4a444a5978 | ||
|
|
4531b7930b | ||
|
|
c38de551ed | ||
|
|
46c2b3e70f | ||
|
|
503b799f21 | ||
|
|
01cb41632d | ||
|
|
1c2e989838 | ||
|
|
3715b9671c | ||
|
|
4da2e24d5c | ||
|
|
9780f91d7d | ||
|
|
115cfa20a9 | ||
|
|
51a8e2205b | ||
|
|
33b717e885 | ||
|
|
a6187b568c | ||
|
|
c4d3800c29 | ||
|
|
22458f57f2 | ||
|
|
67d25c6533 | ||
|
|
5fab11198d | ||
|
|
96c46ed612 | ||
|
|
71fd054711 | ||
|
|
614befd15d | ||
|
|
cfd1fa4bd2 | ||
|
|
8933010e84 | ||
|
|
6ac56baf8e | ||
|
|
6731c6a1cd | ||
|
|
2914cb1d48 | ||
|
|
c0befdee0b |
75
.env.example
75
.env.example
@@ -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=...
|
||||
|
||||
8
.github/workflows/stale.yml
vendored
8
.github/workflows/stale.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
51
docs/start/onboarding-overview.md
Normal file
51
docs/start/onboarding-overview.md
Normal 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
226
scripts/shell-helpers/README.md
Normal file
226
scripts/shell-helpers/README.md
Normal 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
|
||||
```
|
||||
362
scripts/shell-helpers/clawdock-helpers.sh
Executable file
362
scripts/shell-helpers/clawdock-helpers.sh
Executable 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 ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
270
src/commands/onboard-custom.test.ts
Normal file
270
src/commands/onboard-custom.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
476
src/commands/onboard-custom.ts
Normal file
476
src/commands/onboard-custom.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user