Merge branch 'main' into vincentkoc-code/slack-plugin-interactive-dedupe

This commit is contained in:
Vincent Koc 2026-03-15 20:48:40 -07:00 committed by GitHub
commit 81c8e66f61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
325 changed files with 10751 additions and 1718 deletions

View File

@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
- Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
@ -20,6 +21,8 @@ Docs: https://docs.openclaw.ai
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
### Fixes

View File

@ -40,6 +40,15 @@ openclaw plugins install --link <path-to-openclaw>/extensions/nostr
Restart the Gateway after installing or enabling plugins.
### Non-interactive setup
```bash
openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY"
openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" --relay-urls "wss://relay.damus.io,wss://relay.primal.net"
```
Use `--use-env` to keep `NOSTR_PRIVATE_KEY` in the environment instead of storing the key in config.
## Quick setup
1. Generate a Nostr keypair (if needed):

View File

@ -27,13 +27,17 @@ Details: [Plugins](/tools/plugin)
## Quick setup
1. Install and enable the Synology Chat plugin.
- `openclaw onboard` now shows Synology Chat in the same channel setup list as `openclaw channels add`.
- Non-interactive setup: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
2. In Synology Chat integrations:
- Create an incoming webhook and copy its URL.
- Create an outgoing webhook with your secret token.
3. Point the outgoing webhook URL to your OpenClaw gateway:
- `https://gateway-host/webhook/synology` by default.
- Or your custom `channels.synology-chat.webhookPath`.
4. Configure `channels.synology-chat` in OpenClaw.
4. Finish setup in OpenClaw.
- Guided: `openclaw onboard`
- Direct: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
5. Restart gateway and send a DM to the Synology Chat bot.
Minimal config:

View File

@ -30,10 +30,11 @@ openclaw channels logs --channel all
```bash
openclaw channels add --channel telegram --token <bot-token>
openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY"
openclaw channels remove --channel telegram --delete
```
Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc).
Tip: `openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc).
When you run `openclaw channels add` without flags, the interactive wizard can prompt:

View File

@ -34,13 +34,15 @@ openclaw daemon uninstall
## Common options
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json`
- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
- lifecycle (`uninstall|start|stop|restart`): `--json`
Notes:
- `status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, `daemon status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
- On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed.

View File

@ -31,6 +31,7 @@ Notes:
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
## macOS: `launchctl` env overrides

View File

@ -111,7 +111,8 @@ Options:
Notes:
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first.
- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first.
- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives.
- Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy.
- On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files).

View File

@ -783,6 +783,7 @@ Notes:
- `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting.
- `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra".
- `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL.
- If gateway auth SecretRefs are unresolved in the current command path, `gateway status --json` reports `rpc.authWarning` only when probe connectivity/auth fails (warnings are suppressed when probe succeeds).
- On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources.
- `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).

View File

@ -1,17 +1,22 @@
---
title: Sandbox CLI
summary: "Manage sandbox containers and inspect effective sandbox policy"
read_when: "You are managing sandbox containers or debugging sandbox/tool-policy behavior."
summary: "Manage sandbox runtimes and inspect effective sandbox policy"
read_when: "You are managing sandbox runtimes or debugging sandbox/tool-policy behavior."
status: active
---
# Sandbox CLI
Manage Docker-based sandbox containers for isolated agent execution.
Manage sandbox runtimes for isolated agent execution.
## Overview
OpenClaw can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes.
OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox` commands help you inspect and recreate those runtimes after updates or configuration changes.
Today that usually means:
- Docker sandbox containers
- OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"`
## Commands
@ -28,7 +33,7 @@ openclaw sandbox explain --json
### `openclaw sandbox list`
List all sandbox containers with their status and configuration.
List all sandbox runtimes with their status and configuration.
```bash
openclaw sandbox list
@ -38,15 +43,16 @@ openclaw sandbox list --json # JSON output
**Output includes:**
- Container name and status (running/stopped)
- Docker image and whether it matches config
- Runtime name and status
- Backend (`docker`, `openshell`, etc.)
- Config label and whether it matches current config
- Age (time since creation)
- Idle time (time since last use)
- Associated session/agent
### `openclaw sandbox recreate`
Remove sandbox containers to force recreation with updated images/config.
Remove sandbox runtimes to force recreation with updated config.
```bash
openclaw sandbox recreate --all # Recreate all containers
@ -64,11 +70,11 @@ openclaw sandbox recreate --all --force # Skip confirmation
- `--browser`: Only recreate browser containers
- `--force`: Skip confirmation prompt
**Important:** Containers are automatically recreated when the agent is next used.
**Important:** Runtimes are automatically recreated when the agent is next used.
## Use Cases
### After updating Docker images
### After updating a Docker image
```bash
# Pull new image
@ -91,6 +97,21 @@ openclaw sandbox recreate --all
openclaw sandbox recreate --all
```
### After changing OpenShell source, policy, or mode
```bash
# Edit config:
# - agents.defaults.sandbox.backend
# - plugins.entries.openshell.config.from
# - plugins.entries.openshell.config.mode
# - plugins.entries.openshell.config.policy
openclaw sandbox recreate --all
```
For OpenShell `remote` mode, recreate deletes the canonical remote workspace
for that scope. The next run seeds it again from the local workspace.
### After changing setupCommand
```bash
@ -108,16 +129,16 @@ openclaw sandbox recreate --agent alfred
## Why is this needed?
**Problem:** When you update sandbox Docker images or configuration:
**Problem:** When you update sandbox configuration:
- Existing containers continue running with old settings
- Containers are only pruned after 24h of inactivity
- Regularly-used agents keep old containers running indefinitely
- Existing runtimes continue running with old settings
- Runtimes are only pruned after 24h of inactivity
- Regularly-used agents keep old runtimes alive indefinitely
**Solution:** Use `openclaw sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed.
**Solution:** Use `openclaw sandbox recreate` to force removal of old runtimes. They'll be recreated automatically with current settings when next needed.
Tip: prefer `openclaw sandbox recreate` over manual `docker rm`. It uses the
Gateways container naming and avoids mismatches when scope/session keys change.
Tip: prefer `openclaw sandbox recreate` over manual backend-specific cleanup.
It uses the Gateways runtime registry and avoids mismatches when scope/session keys change.
## Configuration
@ -129,6 +150,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand
"defaults": {
"sandbox": {
"mode": "all", // off, non-main, all
"backend": "docker", // docker, openshell
"scope": "agent", // session, agent, shared
"docker": {
"image": "openclaw-sandbox:bookworm-slim",

View File

@ -19,6 +19,8 @@ Related:
```bash
openclaw security audit
openclaw security audit --deep
openclaw security audit --deep --password <password>
openclaw security audit --deep --token <token>
openclaw security audit --fix
openclaw security audit --json
```
@ -40,6 +42,12 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security).
SecretRef behavior:
- `security audit` resolves supported SecretRefs in read-only mode for its targeted paths.
- If a SecretRef is unavailable in the current command path, audit continues and reports `secretDiagnostics` (instead of crashing).
- `--token` and `--password` only override deep-probe auth for that command invocation; they do not rewrite config or SecretRef mappings.
## JSON output
Use `--json` for CI/policy checks:

View File

@ -26,3 +26,4 @@ Notes:
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)).
- Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible.
- If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`.
- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output.

View File

@ -19,6 +19,8 @@ For model selection rules, see [/concepts/models](/concepts/models).
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
OpenClaw merges that output into `models.providers` before writing
`models.json`.
- Provider manifests can declare `providerAuthEnvVars` so generic env-based
auth probes do not need to load plugin runtime.
- Provider plugins can also own provider runtime behavior via
`resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`,
`capabilities`, `prepareExtraParams`, `wrapStreamFn`,

View File

@ -1117,7 +1117,7 @@ See [Typing Indicators](/concepts/typing-indicators).
### `agents.defaults.sandbox`
Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide.
Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide.
```json5
{
@ -1125,6 +1125,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
defaults: {
sandbox: {
mode: "non-main", // off | non-main | all
backend: "docker", // docker | openshell
scope: "agent", // session | agent | shared
workspaceAccess: "none", // none | ro | rw
workspaceRoot: "~/.openclaw/sandboxes",
@ -1199,6 +1200,14 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
<Accordion title="Sandbox details">
**Backend:**
- `docker`: local Docker runtime (default)
- `openshell`: OpenShell runtime
When `backend: "openshell"` is selected, runtime-specific settings move to
`plugins.entries.openshell.config`.
**Workspace access:**
- `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes`
@ -1211,6 +1220,39 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
- `agent`: one container + workspace per agent (default)
- `shared`: shared container and workspace (no cross-session isolation)
**OpenShell plugin config:**
```json5
{
plugins: {
entries: {
openshell: {
enabled: true,
config: {
mode: "mirror", // mirror | remote
from: "openclaw",
remoteWorkspaceDir: "/sandbox",
remoteAgentWorkspaceDir: "/agent",
gateway: "lab", // optional
gatewayEndpoint: "https://lab.example", // optional
policy: "strict", // optional OpenShell policy id
providers: ["openai"], // optional
autoProviders: true,
timeoutSeconds: 120,
},
},
},
},
}
```
**OpenShell mode:**
- `mirror`: seed remote from local before exec, sync back after exec; local workspace stays canonical
- `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical
In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step.
**`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user.
**Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access.
@ -1260,6 +1302,8 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
</Accordion>
Browser sandboxing and `sandbox.docker.binds` are currently Docker-only.
Build images:
```bash

View File

@ -7,7 +7,7 @@ status: active
# Sandboxing
OpenClaw can run **tools inside Docker containers** to reduce blast radius.
OpenClaw can run **tools inside sandbox backends** to reduce blast radius.
This is **optional** and controlled by configuration (`agents.defaults.sandbox` or
`agents.list[].sandbox`). If sandboxing is off, tools run on the host.
The Gateway stays on the host; tool execution runs in an isolated sandbox
@ -54,6 +54,120 @@ Not sandboxed:
- `"agent"`: one container per agent.
- `"shared"`: one container shared by all sandboxed sessions.
## Backend
`agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox:
- `"docker"` (default): local Docker-backed sandbox runtime.
- `"openshell"`: OpenShell-backed sandbox runtime.
OpenShell-specific config lives under `plugins.entries.openshell.config`.
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw",
mode: "remote", // mirror | remote
remoteWorkspaceDir: "/sandbox",
remoteAgentWorkspaceDir: "/agent",
},
},
},
},
}
```
OpenShell modes:
- `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec.
- `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back.
Current OpenShell limitations:
- sandbox browser is not supported yet
- `sandbox.docker.binds` is not supported on the OpenShell backend
- Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend
## OpenShell workspace modes
OpenShell has two workspace models. This is the part that matters most in practice.
### `mirror`
Use `plugins.entries.openshell.config.mode: "mirror"` when you want the **local workspace to stay canonical**.
Behavior:
- Before `exec`, OpenClaw syncs the local workspace into the OpenShell sandbox.
- After `exec`, OpenClaw syncs the remote workspace back to the local workspace.
- File tools still operate through the sandbox bridge, but the local workspace remains the source of truth between turns.
Use this when:
- you edit files locally outside OpenClaw and want those changes to show up in the sandbox automatically
- you want the OpenShell sandbox to behave as much like the Docker backend as possible
- you want the host workspace to reflect sandbox writes after each exec turn
Tradeoff:
- extra sync cost before and after exec
### `remote`
Use `plugins.entries.openshell.config.mode: "remote"` when you want the **OpenShell workspace to become canonical**.
Behavior:
- When the sandbox is first created, OpenClaw seeds the remote workspace from the local workspace once.
- After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace.
- OpenClaw does **not** sync remote changes back into the local workspace after exec.
- Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path.
Important consequences:
- If you edit files on the host outside OpenClaw after the seed step, the remote sandbox will **not** see those changes automatically.
- If the sandbox is recreated, the remote workspace is seeded from the local workspace again.
- With `scope: "agent"` or `scope: "shared"`, that remote workspace is shared at that same scope.
Use this when:
- the sandbox should live primarily on the remote OpenShell side
- you want lower per-turn sync overhead
- you do not want host-local edits to silently overwrite remote sandbox state
Choose `mirror` if you think of the sandbox as a temporary execution environment.
Choose `remote` if you think of the sandbox as the real workspace.
## OpenShell lifecycle
OpenShell sandboxes are still managed through the normal sandbox lifecycle:
- `openclaw sandbox list` shows OpenShell runtimes as well as Docker runtimes
- `openclaw sandbox recreate` deletes the current runtime and lets OpenClaw recreate it on next use
- prune logic is backend-aware too
For `remote` mode, recreate is especially important:
- recreate deletes the canonical remote workspace for that scope
- the next use seeds a fresh remote workspace from the local workspace
For `mirror` mode, recreate mainly resets the remote execution environment
because the local workspace remains canonical anyway.
## Workspace access
`agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**:
@ -62,6 +176,12 @@ Not sandboxed:
- `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`/`apply_patch`).
- `"rw"`: mounts the agent workspace read/write at `/workspace`.
With the OpenShell backend:
- `mirror` mode still uses the local workspace as the canonical source between exec turns
- `remote` mode uses the remote OpenShell workspace as the canonical source after the initial seed
- `workspaceAccess: "ro"` and `"none"` still restrict write behavior the same way
Inbound media is copied into the active sandbox workspace (`media/inbound/*`).
Skills note: the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`,
OpenClaw mirrors eligible skills into the sandbox workspace (`.../skills`) so
@ -116,7 +236,7 @@ Security notes:
## Images + setup
Default image: `openclaw-sandbox:bookworm-slim`
Default Docker image: `openclaw-sandbox:bookworm-slim`
Build it once:
@ -145,7 +265,7 @@ Sandboxed browser image:
scripts/sandbox-browser-setup.sh
```
By default, sandbox containers run with **no network**.
By default, Docker sandbox containers run with **no network**.
Override with `agents.defaults.sandbox.docker.network`.
The bundled sandbox browser image also applies conservative Chromium startup defaults

View File

@ -348,7 +348,7 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R
There are two broad behaviors:
- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable.
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
Read-only behavior:

View File

@ -56,6 +56,9 @@ Optional keys:
- `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`).
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
- `providers` (array): provider ids registered by this plugin.
- `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this
when OpenClaw should resolve provider credentials from env without loading
plugin runtime first.
- `skills` (array): skill directories to load (relative to the plugin root).
- `name` (string): display name for the plugin.
- `description` (string): short plugin summary.
@ -84,6 +87,9 @@ Optional keys:
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker
validation, and similar provider-auth surfaces that should not boot plugin
runtime just to inspect env names.
- Exclusive plugin kinds are selected through `plugins.slots.*`.
- `kind: "memory"` is selected by `plugins.slots.memory`.
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`

View File

@ -0,0 +1,260 @@
---
summary: "Design for an opt-in Firecrawl extension that adds search/scrape value without hardwiring Firecrawl into core defaults"
read_when:
- Designing Firecrawl integration work
- Evaluating web_search/web_fetch plugin seams
- Deciding whether Firecrawl belongs in core or as an extension
title: "Firecrawl Extension Design"
---
# Firecrawl Extension Design
## Goal
Ship Firecrawl as an **opt-in extension** that adds:
- explicit Firecrawl tools for agents,
- optional Firecrawl-backed `web_search` integration,
- self-hosted support,
- stronger security defaults than the current core fallback path,
without pushing Firecrawl into the default setup/onboarding path.
## Why this shape
Recent Firecrawl issues/PRs cluster into three buckets:
1. **Release/schema drift**
- Several releases rejected `tools.web.fetch.firecrawl` even though docs and runtime code supported it.
2. **Security hardening**
- Current `fetchFirecrawlContent()` still posts to the Firecrawl endpoint with raw `fetch()`, while the main web-fetch path uses the SSRF guard.
3. **Product pressure**
- Users want Firecrawl-native search/scrape flows, especially for self-hosted/private setups.
- Maintainers explicitly rejected wiring Firecrawl deeply into core defaults, setup flow, and browser behavior.
That combination argues for an extension, not more Firecrawl-specific logic in the default core path.
## Design principles
- **Opt-in, vendor-scoped**: no auto-enable, no setup hijack, no default tool-profile widening.
- **Extension owns Firecrawl-specific config**: prefer plugin config over growing `tools.web.*` again.
- **Useful on day one**: works even if core `web_search` / `web_fetch` seams stay unchanged.
- **Security-first**: endpoint fetches use the same guarded networking posture as other web tools.
- **Self-hosted-friendly**: config + env fallback, explicit base URL, no hosted-only assumptions.
## Proposed extension
Plugin id: `firecrawl`
### MVP capabilities
Register explicit tools:
- `firecrawl_search`
- `firecrawl_scrape`
Optional later:
- `firecrawl_crawl`
- `firecrawl_map`
Do **not** add Firecrawl browser automation in the first version. That was the part of PR #32543 that pulled Firecrawl too far into core behavior and raised the most maintainership concern.
## Config shape
Use plugin-scoped config:
```json5
{
plugins: {
entries: {
firecrawl: {
enabled: true,
config: {
apiKey: "FIRECRAWL_API_KEY",
baseUrl: "https://api.firecrawl.dev",
timeoutSeconds: 60,
maxAgeMs: 172800000,
proxy: "auto",
storeInCache: true,
onlyMainContent: true,
search: {
enabled: true,
defaultLimit: 5,
sources: ["web"],
categories: [],
scrapeResults: false,
},
scrape: {
formats: ["markdown"],
fallbackForWebFetchLikeUse: false,
},
},
},
},
},
}
```
### Credential resolution
Precedence:
1. `plugins.entries.firecrawl.config.apiKey`
2. `FIRECRAWL_API_KEY`
Base URL precedence:
1. `plugins.entries.firecrawl.config.baseUrl`
2. `FIRECRAWL_BASE_URL`
3. `https://api.firecrawl.dev`
### Compatibility bridge
For the first release, the extension may also **read** existing core config at `tools.web.fetch.firecrawl.*` as a fallback source so existing users do not need to migrate immediately.
Write path stays plugin-local. Do not keep expanding core Firecrawl config surfaces.
## Tool design
### `firecrawl_search`
Inputs:
- `query`
- `limit`
- `sources`
- `categories`
- `scrapeResults`
- `timeoutSeconds`
Behavior:
- Calls Firecrawl `v2/search`
- Returns normalized OpenClaw-friendly result objects:
- `title`
- `url`
- `snippet`
- `source`
- optional `content`
- Wraps result content as untrusted external content
- Cache key includes query + relevant provider params
Why explicit tool first:
- Works today without changing `tools.web.search.provider`
- Avoids current schema/loader constraints
- Gives users Firecrawl value immediately
### `firecrawl_scrape`
Inputs:
- `url`
- `formats`
- `onlyMainContent`
- `maxAgeMs`
- `proxy`
- `storeInCache`
- `timeoutSeconds`
Behavior:
- Calls Firecrawl `v2/scrape`
- Returns markdown/text plus metadata:
- `title`
- `finalUrl`
- `status`
- `warning`
- Wraps extracted content the same way `web_fetch` does
- Shares cache semantics with web tool expectations where practical
Why explicit scrape tool:
- Sidesteps the unresolved `Readability -> Firecrawl -> basic HTML cleanup` ordering bug in core `web_fetch`
- Gives users a deterministic “always use Firecrawl” path for JS-heavy/bot-protected sites
## What the extension should not do
- No auto-adding `browser`, `web_search`, or `web_fetch` to `tools.alsoAllow`
- No default onboarding step in `openclaw setup`
- No Firecrawl-specific browser session lifecycle in core
- No change to built-in `web_fetch` fallback semantics in the extension MVP
## Phase plan
### Phase 1: extension-only, no core schema changes
Implement:
- `extensions/firecrawl/`
- plugin config schema
- `firecrawl_search`
- `firecrawl_scrape`
- tests for config resolution, endpoint selection, caching, error handling, and SSRF guard usage
This phase is enough to ship real user value.
### Phase 2: optional `web_search` provider integration
Support `tools.web.search.provider = "firecrawl"` only after fixing two core constraints:
1. `src/plugins/web-search-providers.ts` must load configured/installed web-search-provider plugins instead of a hardcoded bundled list.
2. `src/config/types.tools.ts` and `src/config/zod-schema.agent-runtime.ts` must stop hardcoding the provider enum in a way that blocks plugin-registered ids.
Recommended shape:
- keep built-in providers documented,
- allow any registered plugin provider id at runtime,
- validate provider-specific config via the provider plugin or a generic provider bag.
### Phase 3: optional `web_fetch` provider seam
Do this only if maintainers want vendor-specific fetch backends to participate in `web_fetch`.
Needed core addition:
- `registerWebFetchProvider` or equivalent fetch-backend seam
Without that seam, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`.
## Security requirements
The extension must treat Firecrawl as a **trusted operator-configured endpoint**, but still harden transport:
- Use SSRF-guarded fetch for the Firecrawl endpoint call, not raw `fetch()`
- Preserve self-hosted/private-network compatibility using the same trusted-web-tools endpoint policy used elsewhere
- Never log the API key
- Keep endpoint/base URL resolution explicit and predictable
- Treat Firecrawl-returned content as untrusted external content
This mirrors the intent behind the SSRF hardening PRs without assuming Firecrawl is a hostile multi-tenant surface.
## Why not a skill
The repo already closed a Firecrawl skill PR in favor of ClawHub distribution. That is fine for optional user-installed prompt workflows, but it does not solve:
- deterministic tool availability,
- provider-grade config/credential handling,
- self-hosted endpoint support,
- caching,
- stable typed outputs,
- security review on network behavior.
This belongs as an extension, not a prompt-only skill.
## Success criteria
- Users can install/enable one extension and get reliable Firecrawl search/scrape without touching core defaults.
- Self-hosted Firecrawl works with config/env fallback.
- Extension endpoint fetches use guarded networking.
- No new Firecrawl-specific core onboarding/default behavior.
- Core can later adopt plugin-native `web_search` / `web_fetch` seams without redesigning the extension.
## Recommended implementation order
1. Build `firecrawl_scrape`
2. Build `firecrawl_search`
3. Add docs and examples
4. If desired, generalize `web_search` provider loading so the extension can back `web_search`
5. Only then consider a true `web_fetch` provider seam

View File

@ -1,27 +1,71 @@
---
summary: "Firecrawl fallback for web_fetch (anti-bot + cached extraction)"
summary: "Firecrawl search, scrape, and web_fetch fallback"
read_when:
- You want Firecrawl-backed web extraction
- You need a Firecrawl API key
- You want Firecrawl as a web_search provider
- You want anti-bot extraction for web_fetch
title: "Firecrawl"
---
# Firecrawl
OpenClaw can use **Firecrawl** as a fallback extractor for `web_fetch`. It is a hosted
content extraction service that supports bot circumvention and caching, which helps
with JS-heavy sites or pages that block plain HTTP fetches.
OpenClaw can use **Firecrawl** in three ways:
- as the `web_search` provider
- as explicit plugin tools: `firecrawl_search` and `firecrawl_scrape`
- as a fallback extractor for `web_fetch`
It is a hosted extraction/search service that supports bot circumvention and caching,
which helps with JS-heavy sites or pages that block plain HTTP fetches.
## Get an API key
1. Create a Firecrawl account and generate an API key.
2. Store it in config or set `FIRECRAWL_API_KEY` in the gateway environment.
## Configure Firecrawl
## Configure Firecrawl search
```json5
{
plugins: {
entries: {
firecrawl: {
enabled: true,
},
},
},
tools: {
web: {
search: {
provider: "firecrawl",
firecrawl: {
apiKey: "FIRECRAWL_API_KEY_HERE",
baseUrl: "https://api.firecrawl.dev",
},
},
},
},
}
```
Notes:
- Choosing Firecrawl in onboarding or `openclaw configure --section web` enables the bundled Firecrawl plugin automatically.
- `web_search` with Firecrawl supports `query` and `count`.
- For Firecrawl-specific controls like `sources`, `categories`, or result scraping, use `firecrawl_search`.
## Configure Firecrawl scrape + web_fetch fallback
```json5
{
plugins: {
entries: {
firecrawl: {
enabled: true,
},
},
},
tools: {
web: {
fetch: {
@ -44,6 +88,38 @@ Notes:
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
`firecrawl_scrape` reuses the same `tools.web.fetch.firecrawl.*` settings and env vars.
## Firecrawl plugin tools
### `firecrawl_search`
Use this when you want Firecrawl-specific search controls instead of generic `web_search`.
Core parameters:
- `query`
- `count`
- `sources`
- `categories`
- `scrapeResults`
- `timeoutSeconds`
### `firecrawl_scrape`
Use this for JS-heavy or bot-protected pages where plain `web_fetch` is weak.
Core parameters:
- `url`
- `extractMode`
- `maxChars`
- `onlyMainContent`
- `maxAgeMs`
- `proxy`
- `storeInCache`
- `timeoutSeconds`
## Stealth / bot circumvention
Firecrawl exposes a **proxy mode** parameter for bot circumvention (`basic`, `stealth`, or `auto`).

View File

@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`).
### `web_search`
Search the web using Perplexity, Brave, Gemini, Grok, or Kimi.
Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, or Perplexity.
Core parameters:

View File

@ -217,6 +217,8 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
Provider plugins now have two layers:
- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before
runtime load
- config-time hooks: `catalog` / legacy `discovery`
- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot`
@ -224,6 +226,11 @@ OpenClaw still owns the generic agent loop, failover, transcript handling, and
tool policy. These hooks are the seam for provider-specific behavior without
needing a whole custom inference transport.
Use manifest `providerAuthEnvVars` when the provider has env-based credentials
that generic auth/status/model-picker paths should see without loading plugin
runtime. Keep provider runtime `envVars` for operator-facing hints such as
onboarding labels or OAuth client-id/client-secret setup vars.
### Hook order
For model/provider plugins, OpenClaw uses hooks in this rough order:
@ -769,7 +776,7 @@ Security note: `openclaw plugins install` installs plugin dependencies with
trees "pure JS/TS" and avoid packages that require `postinstall` builds.
Optional: `openclaw.setupEntry` can point at a lightweight setup-only module.
When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or
When OpenClaw needs setup surfaces for a disabled channel plugin, or
when a channel plugin is enabled but still unconfigured, it loads `setupEntry`
instead of the full plugin entry. This keeps startup and onboarding lighter
when your main plugin entry also wires tools, hooks, or other runtime-only
@ -777,7 +784,7 @@ code.
### Channel catalog metadata
Channel plugins can advertise onboarding metadata via `openclaw.channel` and
Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and
install hints via `openclaw.install`. This keeps the core catalog data-free.
Example:
@ -1664,7 +1671,7 @@ Recommended packaging:
Publishing contract:
- Plugin `package.json` must include `openclaw.extensions` with one or more entry files.
- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup.
- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup.
- Entry files can be `.js` or `.ts` (jiti loads TS at runtime).
- `openclaw plugins install <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.

View File

@ -1,5 +1,5 @@
---
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, and Perplexity providers)"
read_when:
- You want to enable web_search or web_fetch
- You need provider API key setup
@ -11,7 +11,7 @@ title: "Web Tools"
OpenClaw ships two lightweight web tools:
- `web_search` — Search the web using Brave Search API, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API.
- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API.
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
@ -24,18 +24,20 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
- `web_fetch` does a plain HTTP GET and extracts readable content
(HTML → markdown/text). It does **not** execute JavaScript.
- `web_fetch` is enabled by default (unless explicitly disabled).
- The bundled Firecrawl plugin also adds `firecrawl_search` and `firecrawl_scrape` when enabled.
See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexity) for provider-specific details.
## Choosing a search provider
| Provider | Result shape | Provider-specific filters | Notes | API key |
| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` |
| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` |
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
| Provider | Result shape | Provider-specific filters | Notes | API key |
| ------------------------- | ---------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------- |
| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` |
| **Firecrawl Search** | Structured results with snippets | Use `firecrawl_search` for Firecrawl-specific search options | Best for pairing search with Firecrawl scraping/extraction | `FIRECRAWL_API_KEY` |
| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` |
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
### Auto-detection
@ -46,6 +48,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
3. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
4. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
5. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
6. **Firecrawl**`FIRECRAWL_API_KEY` env var or `tools.web.search.firecrawl.apiKey` config
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
@ -86,6 +89,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path:
- Brave: `tools.web.search.apiKey`
- Firecrawl: `tools.web.search.firecrawl.apiKey`
- Gemini: `tools.web.search.gemini.apiKey`
- Grok: `tools.web.search.grok.apiKey`
- Kimi: `tools.web.search.kimi.apiKey`
@ -96,6 +100,7 @@ All of these fields also support SecretRef objects.
**Via environment:** set provider env vars in the Gateway process environment:
- Brave: `BRAVE_API_KEY`
- Firecrawl: `FIRECRAWL_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
@ -121,6 +126,34 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm
}
```
**Firecrawl Search:**
```json5
{
plugins: {
entries: {
firecrawl: {
enabled: true,
},
},
},
tools: {
web: {
search: {
enabled: true,
provider: "firecrawl",
firecrawl: {
apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set
baseUrl: "https://api.firecrawl.dev",
},
},
},
},
}
```
When you choose Firecrawl in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Firecrawl plugin automatically so `web_search`, `firecrawl_search`, and `firecrawl_scrape` are all available.
**Brave LLM Context mode:**
```json5
@ -234,6 +267,7 @@ Search the web using your configured provider.
- `tools.web.search.enabled` must not be `false` (default: enabled)
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Firecrawl**: `FIRECRAWL_API_KEY` or `tools.web.search.firecrawl.apiKey`
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
@ -260,7 +294,7 @@ Search the web using your configured provider.
### Tool parameters
All parameters work for Brave and for native Perplexity Search API unless noted.
Parameters depend on the selected provider.
Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`.
If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors.
@ -279,6 +313,8 @@ If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_
| `max_tokens` | Total content budget, default 25000 (Perplexity only) |
| `max_tokens_per_page` | Per-page token limit, default 2048 (Perplexity only) |
Firecrawl `web_search` supports `query` and `count`. For Firecrawl-specific controls like `sources`, `categories`, result scraping, or scrape timeout, use `firecrawl_search` from the bundled Firecrawl plugin.
**Examples:**
```javascript

View File

@ -1,6 +1,9 @@
{
"id": "anthropic",
"providers": ["anthropic"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,4 +1,4 @@
import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-flow-helpers.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
@ -27,8 +27,8 @@ async function createBlueBubblesConfigureAdapter() {
}).config.allowFrom ?? [],
},
setup: blueBubblesSetupAdapter,
} as Parameters<typeof buildChannelOnboardingAdapterFromSetupWizard>[0]["plugin"];
return buildChannelOnboardingAdapterFromSetupWizard({
} as Parameters<typeof buildChannelSetupFlowAdapterFromSetupWizard>[0]["plugin"];
return buildChannelSetupFlowAdapterFromSetupWizard({
plugin,
wizard: blueBubblesSetupWizard,
});

View File

@ -1,8 +1,8 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
mergeAllowFromEntries,
resolveOnboardingAccountId,
} from "../../../src/channels/plugins/onboarding/helpers.js";
resolveSetupAccountId,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
@ -55,7 +55,7 @@ async function promptBlueBubblesAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveOnboardingAccountId({
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg),
});
@ -148,7 +148,7 @@ function validateBlueBubblesWebhookPath(value: string): string | undefined {
return undefined;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
const dmPolicy: ChannelSetupDmPolicy = {
label: "BlueBubbles",
channel,
policyKey: "channels.bluebubbles.dmPolicy",

View File

@ -1,6 +1,9 @@
{
"id": "byteplus",
"providers": ["byteplus", "byteplus-plan"],
"providerAuthEnvVars": {
"byteplus": ["BYTEPLUS_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "cloudflare-ai-gateway",
"providers": ["cloudflare-ai-gateway"],
"providerAuthEnvVars": {
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,3 +1,3 @@
import { discordPlugin } from "./src/channel.js";
import { discordSetupPlugin } from "./src/channel.setup.js";
export default { plugin: discordPlugin };
export default { plugin: discordSetupPlugin };

View File

@ -0,0 +1,75 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
createScopedAccountConfigAccessors,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
inspectDiscordAccount,
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
type ChannelPlugin,
type ResolvedDiscordAccount,
} from "openclaw/plugin-sdk/discord";
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
const discordConfigBase = createScopedChannelConfigBase({
sectionKey: "discord",
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
});
const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
}));
export const discordSetupPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
...getChatChannelMeta("discord"),
},
setupWizard: discordSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
...discordConfigAccessors,
},
setup: discordSetupAdapter,
};

View File

@ -1,12 +1,12 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
noteChannelLookupFailure,
noteChannelLookupSummary,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setLegacyChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
} from "../../../src/channels/plugins/onboarding/helpers.js";
setSetupChannelEnabled,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
@ -140,7 +140,7 @@ export const discordSetupAdapter: ChannelSetupAdapter = {
export function createDiscordSetupWizardProxy(
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
) {
const discordDmPolicy: ChannelOnboardingDmPolicy = {
const discordDmPolicy: ChannelSetupDmPolicy = {
label: "Discord",
channel,
policyKey: "channels.discord.dmPolicy",
@ -251,7 +251,7 @@ export function createDiscordSetupWizardProxy(
prompter: { note: (message: string, title?: string) => Promise<void> };
}) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.groupAccess) {
if (!wizard.groupAccess?.resolveAllowlist) {
return entries.map((input) => ({ input, resolved: false }));
}
try {
@ -343,6 +343,6 @@ export function createDiscordSetupWizardProxy(
}),
},
dmPolicy: discordDmPolicy,
disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}

View File

@ -1,14 +1,14 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
noteChannelLookupFailure,
noteChannelLookupSummary,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
promptLegacyChannelAllowFrom,
resolveOnboardingAccountId,
resolveSetupAccountId,
setLegacyChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
} from "../../../src/channels/plugins/onboarding/helpers.js";
setSetupChannelEnabled,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
@ -59,7 +59,7 @@ async function promptDiscordAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveOnboardingAccountId({
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
});
@ -92,7 +92,7 @@ async function promptDiscordAllowFrom(params: {
});
}
const discordDmPolicy: ChannelOnboardingDmPolicy = {
const discordDmPolicy: ChannelSetupDmPolicy = {
label: "Discord",
channel,
policyKey: "channels.discord.dmPolicy",
@ -273,5 +273,5 @@ export const discordSetupWizard: ChannelSetupWizard = {
}),
},
dmPolicy: discordDmPolicy,
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};

View File

@ -25,7 +25,8 @@ import { FeishuConfigSchema } from "./config-schema.js";
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js";
import { feishuSetupAdapter } from "./setup-core.js";
import { feishuSetupWizard } from "./setup-surface.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";

View File

@ -1,9 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { feishuPlugin } from "./channel.js";
const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
const feishuConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
vi.mock("./probe.js", () => ({
probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
@ -56,7 +56,7 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
});
}
const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
const feishuConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});

View File

@ -0,0 +1,7 @@
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { feishuPlugin } from "./channel.js";
export const feishuOnboardingAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});

View File

@ -0,0 +1,48 @@
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import type { FeishuConfig } from "./types.js";
export function setFeishuNamedAccountEnabled(
cfg: OpenClawConfig,
accountId: string,
enabled: boolean,
): OpenClawConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
}
export const feishuSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
};
}
return setFeishuNamedAccountEnabled(cfg, accountId, true);
},
};

View File

@ -1,4 +1,3 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
buildSingleChannelSecretPromptState,
mergeAllowFromEntries,
@ -6,10 +5,10 @@ import {
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitOnboardingEntries,
} from "../../../src/channels/plugins/onboarding/helpers.js";
splitSetupEntries,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import type { SecretInput } from "../../../src/config/types.secrets.js";
@ -18,6 +17,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js";
import { probeFeishu } from "./probe.js";
import { feishuSetupAdapter } from "./setup-core.js";
import type { FeishuConfig } from "./types.js";
const channel = "feishu" as const;
@ -30,30 +30,6 @@ function normalizeString(value: unknown): string | undefined {
return trimmed || undefined;
}
function setFeishuNamedAccountEnabled(
cfg: OpenClawConfig,
accountId: string,
enabled: boolean,
): OpenClawConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
}
function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
@ -139,7 +115,7 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
async function promptFeishuAllowFrom(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note(
@ -160,7 +136,7 @@ async function promptFeishuAllowFrom(params: {
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = splitOnboardingEntries(String(entry));
const parts = splitSetupEntries(String(entry));
if (parts.length === 0) {
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
continue;
@ -201,7 +177,7 @@ async function promptFeishuAppId(params: {
).trim();
}
const feishuDmPolicy: ChannelOnboardingDmPolicy = {
const feishuDmPolicy: ChannelSetupDmPolicy = {
label: "Feishu",
channel,
policyKey: "channels.feishu.dmPolicy",
@ -211,25 +187,7 @@ const feishuDmPolicy: ChannelOnboardingDmPolicy = {
promptAllowFrom: promptFeishuAllowFrom,
};
export const feishuSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
};
}
return setFeishuNamedAccountEnabled(cfg, accountId, true);
},
};
export { feishuSetupAdapter } from "./setup-core.js";
export const feishuSetupWizard: ChannelSetupWizard = {
channel,
@ -500,7 +458,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
});
if (entry) {
const parts = splitOnboardingEntries(String(entry));
const parts = splitSetupEntries(String(entry));
if (parts.length > 0) {
next = setFeishuGroupAllowFrom(next, parts);
}

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { __testing as firecrawlClientTesting } from "./src/firecrawl-client.js";
describe("firecrawl plugin", () => {
it("registers a web search provider and tools", () => {
const tools: Array<{ name: string }> = [];
const webSearchProviders: Array<{ id: string }> = [];
plugin.register?.({
config: {},
registerTool(tool: { name: string }) {
tools.push(tool);
},
registerWebSearchProvider(provider: { id: string }) {
webSearchProviders.push(provider);
},
} as never);
expect(webSearchProviders.map((provider) => provider.id)).toEqual(["firecrawl"]);
expect(tools.map((tool) => tool.name)).toEqual(["firecrawl_search", "firecrawl_scrape"]);
});
it("parses scrape payloads into wrapped external-content results", () => {
const result = firecrawlClientTesting.parseFirecrawlScrapePayload({
payload: {
success: true,
data: {
markdown: "# Hello\n\nWorld",
metadata: {
title: "Example page",
sourceURL: "https://example.com/final",
statusCode: 200,
},
},
},
url: "https://example.com/start",
extractMode: "text",
maxChars: 1000,
});
expect(result.finalUrl).toBe("https://example.com/final");
expect(result.status).toBe(200);
expect(result.extractor).toBe("firecrawl");
expect(typeof result.text).toBe("string");
});
it("extracts search items from flexible Firecrawl payload shapes", () => {
const items = firecrawlClientTesting.resolveSearchItems({
success: true,
data: [
{
title: "Docs",
url: "https://docs.example.com/path",
description: "Reference docs",
markdown: "Body",
},
],
});
expect(items).toEqual([
{
title: "Docs",
url: "https://docs.example.com/path",
description: "Reference docs",
content: "Body",
published: undefined,
siteName: "docs.example.com",
},
]);
});
it("extracts search items from Firecrawl v2 data.web payloads", () => {
const items = firecrawlClientTesting.resolveSearchItems({
success: true,
data: {
web: [
{
title: "API Platform - OpenAI",
url: "https://openai.com/api/",
description: "Build on the OpenAI API platform.",
markdown: "# API Platform",
position: 1,
},
],
},
});
expect(items).toEqual([
{
title: "API Platform - OpenAI",
url: "https://openai.com/api/",
description: "Build on the OpenAI API platform.",
content: "# API Platform",
published: undefined,
siteName: "openai.com",
},
]);
});
});

View File

@ -0,0 +1,20 @@
import type { AnyAgentTool } from "../../src/agents/tools/common.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js";
import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js";
import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js";
const firecrawlPlugin = {
id: "firecrawl",
name: "Firecrawl Plugin",
description: "Bundled Firecrawl search and scrape plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerWebSearchProvider(createFirecrawlWebSearchProvider());
api.registerTool(createFirecrawlSearchTool(api) as AnyAgentTool);
api.registerTool(createFirecrawlScrapeTool(api) as AnyAgentTool);
},
};
export default firecrawlPlugin;

View File

@ -0,0 +1,8 @@
{
"id": "firecrawl",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/firecrawl-plugin",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Firecrawl plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,159 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js";
import { normalizeSecretInput } from "../../../src/utils/normalize-secret-input.js";
export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30;
export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60;
export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
type WebFetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { fetch?: infer Fetch }
? Fetch
: undefined
: undefined;
type FirecrawlSearchConfig =
| {
apiKey?: unknown;
baseUrl?: string;
}
| undefined;
type FirecrawlFetchConfig =
| {
apiKey?: unknown;
baseUrl?: string;
onlyMainContent?: boolean;
maxAgeMs?: number;
timeoutSeconds?: number;
}
| undefined;
function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return undefined;
}
return search as WebSearchConfig;
}
function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig {
const fetch = cfg?.tools?.web?.fetch;
if (!fetch || typeof fetch !== "object") {
return undefined;
}
return fetch as WebFetchConfig;
}
export function resolveFirecrawlSearchConfig(cfg?: OpenClawConfig): FirecrawlSearchConfig {
const search = resolveSearchConfig(cfg);
if (!search || typeof search !== "object") {
return undefined;
}
const firecrawl = "firecrawl" in search ? search.firecrawl : undefined;
if (!firecrawl || typeof firecrawl !== "object") {
return undefined;
}
return firecrawl as FirecrawlSearchConfig;
}
export function resolveFirecrawlFetchConfig(cfg?: OpenClawConfig): FirecrawlFetchConfig {
const fetch = resolveFetchConfig(cfg);
if (!fetch || typeof fetch !== "object") {
return undefined;
}
const firecrawl = "firecrawl" in fetch ? fetch.firecrawl : undefined;
if (!firecrawl || typeof firecrawl !== "object") {
return undefined;
}
return firecrawl as FirecrawlFetchConfig;
}
function normalizeConfiguredSecret(value: unknown, path: string): string | undefined {
return normalizeSecretInput(
normalizeResolvedSecretInputString({
value,
path,
}),
);
}
export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined {
const search = resolveFirecrawlSearchConfig(cfg);
const fetch = resolveFirecrawlFetchConfig(cfg);
return (
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||
undefined
);
}
export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string {
const search = resolveFirecrawlSearchConfig(cfg);
const fetch = resolveFirecrawlFetchConfig(cfg);
const configured =
(typeof search?.baseUrl === "string" ? search.baseUrl.trim() : "") ||
(typeof fetch?.baseUrl === "string" ? fetch.baseUrl.trim() : "") ||
normalizeSecretInput(process.env.FIRECRAWL_BASE_URL) ||
"";
return configured || DEFAULT_FIRECRAWL_BASE_URL;
}
export function resolveFirecrawlOnlyMainContent(cfg?: OpenClawConfig, override?: boolean): boolean {
if (typeof override === "boolean") {
return override;
}
const fetch = resolveFirecrawlFetchConfig(cfg);
if (typeof fetch?.onlyMainContent === "boolean") {
return fetch.onlyMainContent;
}
return true;
}
export function resolveFirecrawlMaxAgeMs(cfg?: OpenClawConfig, override?: number): number {
if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
return Math.floor(override);
}
const fetch = resolveFirecrawlFetchConfig(cfg);
if (
typeof fetch?.maxAgeMs === "number" &&
Number.isFinite(fetch.maxAgeMs) &&
fetch.maxAgeMs >= 0
) {
return Math.floor(fetch.maxAgeMs);
}
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
}
export function resolveFirecrawlScrapeTimeoutSeconds(
cfg?: OpenClawConfig,
override?: number,
): number {
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
return Math.floor(override);
}
const fetch = resolveFirecrawlFetchConfig(cfg);
if (
typeof fetch?.timeoutSeconds === "number" &&
Number.isFinite(fetch.timeoutSeconds) &&
fetch.timeoutSeconds > 0
) {
return Math.floor(fetch.timeoutSeconds);
}
return DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS;
}
export function resolveFirecrawlSearchTimeoutSeconds(override?: number): number {
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
return Math.floor(override);
}
return DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS;
}

View File

@ -0,0 +1,446 @@
import { markdownToText, truncateText } from "../../../src/agents/tools/web-fetch-utils.js";
import { withTrustedWebToolsEndpoint } from "../../../src/agents/tools/web-guarded-fetch.js";
import {
DEFAULT_CACHE_TTL_MINUTES,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
writeCache,
} from "../../../src/agents/tools/web-shared.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { wrapExternalContent, wrapWebContent } from "../../../src/security/external-content.js";
import {
resolveFirecrawlApiKey,
resolveFirecrawlBaseUrl,
resolveFirecrawlMaxAgeMs,
resolveFirecrawlOnlyMainContent,
resolveFirecrawlScrapeTimeoutSeconds,
resolveFirecrawlSearchTimeoutSeconds,
} from "./config.js";
const SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
>();
const SCRAPE_CACHE = new Map<
string,
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
>();
const DEFAULT_SEARCH_COUNT = 5;
const DEFAULT_SCRAPE_MAX_CHARS = 50_000;
const DEFAULT_ERROR_MAX_BYTES = 64_000;
type FirecrawlSearchItem = {
title: string;
url: string;
description?: string;
content?: string;
published?: string;
siteName?: string;
};
export type FirecrawlSearchParams = {
cfg?: OpenClawConfig;
query: string;
count?: number;
timeoutSeconds?: number;
sources?: string[];
categories?: string[];
scrapeResults?: boolean;
};
export type FirecrawlScrapeParams = {
cfg?: OpenClawConfig;
url: string;
extractMode: "markdown" | "text";
maxChars?: number;
onlyMainContent?: boolean;
maxAgeMs?: number;
proxy?: "auto" | "basic" | "stealth";
storeInCache?: boolean;
timeoutSeconds?: number;
};
function resolveEndpoint(baseUrl: string, pathname: "/v2/search" | "/v2/scrape"): string {
const trimmed = baseUrl.trim();
if (!trimmed) {
return new URL(pathname, "https://api.firecrawl.dev").toString();
}
try {
const url = new URL(trimmed);
if (url.pathname && url.pathname !== "/") {
return url.toString();
}
url.pathname = pathname;
return url.toString();
} catch {
return new URL(pathname, "https://api.firecrawl.dev").toString();
}
}
function resolveSiteName(urlRaw: string): string | undefined {
try {
const host = new URL(urlRaw).hostname.replace(/^www\./, "");
return host || undefined;
} catch {
return undefined;
}
}
async function postFirecrawlJson(params: {
baseUrl: string;
pathname: "/v2/search" | "/v2/scrape";
apiKey: string;
body: Record<string, unknown>;
timeoutSeconds: number;
errorLabel: string;
}): Promise<Record<string, unknown>> {
const endpoint = resolveEndpoint(params.baseUrl, params.pathname);
return await withTrustedWebToolsEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params.body),
},
},
async ({ response }) => {
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES });
throw new Error(
`${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`,
);
}
const payload = (await response.json()) as Record<string, unknown>;
if (payload.success === false) {
const error =
typeof payload.error === "string"
? payload.error
: typeof payload.message === "string"
? payload.message
: "unknown error";
throw new Error(`${params.errorLabel} API error: ${error}`);
}
return payload;
},
);
}
function resolveSearchItems(payload: Record<string, unknown>): FirecrawlSearchItem[] {
const candidates = [
payload.data,
payload.results,
(payload.data as { results?: unknown } | undefined)?.results,
(payload.data as { data?: unknown } | undefined)?.data,
(payload.data as { web?: unknown } | undefined)?.web,
(payload.web as { results?: unknown } | undefined)?.results,
];
const rawItems = candidates.find((candidate) => Array.isArray(candidate));
if (!Array.isArray(rawItems)) {
return [];
}
const items: FirecrawlSearchItem[] = [];
for (const entry of rawItems) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
const metadata =
record.metadata && typeof record.metadata === "object"
? (record.metadata as Record<string, unknown>)
: undefined;
const url =
(typeof record.url === "string" && record.url) ||
(typeof record.sourceURL === "string" && record.sourceURL) ||
(typeof record.sourceUrl === "string" && record.sourceUrl) ||
(typeof metadata?.sourceURL === "string" && metadata.sourceURL) ||
"";
if (!url) {
continue;
}
const title =
(typeof record.title === "string" && record.title) ||
(typeof metadata?.title === "string" && metadata.title) ||
"";
const description =
(typeof record.description === "string" && record.description) ||
(typeof record.snippet === "string" && record.snippet) ||
(typeof record.summary === "string" && record.summary) ||
undefined;
const content =
(typeof record.markdown === "string" && record.markdown) ||
(typeof record.content === "string" && record.content) ||
(typeof record.text === "string" && record.text) ||
undefined;
const published =
(typeof record.publishedDate === "string" && record.publishedDate) ||
(typeof record.published === "string" && record.published) ||
(typeof metadata?.publishedTime === "string" && metadata.publishedTime) ||
(typeof metadata?.publishedDate === "string" && metadata.publishedDate) ||
undefined;
items.push({
title,
url,
description,
content,
published,
siteName: resolveSiteName(url),
});
}
return items;
}
function buildSearchPayload(params: {
query: string;
provider: "firecrawl";
items: FirecrawlSearchItem[];
tookMs: number;
scrapeResults: boolean;
}): Record<string, unknown> {
return {
query: params.query,
provider: params.provider,
count: params.items.length,
tookMs: params.tookMs,
externalContent: {
untrusted: true,
source: "web_search",
provider: params.provider,
wrapped: true,
},
results: params.items.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
description: entry.description ? wrapWebContent(entry.description, "web_search") : "",
...(entry.published ? { published: entry.published } : {}),
...(entry.siteName ? { siteName: entry.siteName } : {}),
...(params.scrapeResults && entry.content
? { content: wrapWebContent(entry.content, "web_search") }
: {}),
})),
};
}
export async function runFirecrawlSearch(
params: FirecrawlSearchParams,
): Promise<Record<string, unknown>> {
const apiKey = resolveFirecrawlApiKey(params.cfg);
if (!apiKey) {
throw new Error(
"web_search (firecrawl) needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.search.firecrawl.apiKey.",
);
}
const count =
typeof params.count === "number" && Number.isFinite(params.count)
? Math.max(1, Math.min(10, Math.floor(params.count)))
: DEFAULT_SEARCH_COUNT;
const timeoutSeconds = resolveFirecrawlSearchTimeoutSeconds(params.timeoutSeconds);
const scrapeResults = params.scrapeResults === true;
const sources = Array.isArray(params.sources) ? params.sources.filter(Boolean) : [];
const categories = Array.isArray(params.categories) ? params.categories.filter(Boolean) : [];
const baseUrl = resolveFirecrawlBaseUrl(params.cfg);
const cacheKey = normalizeCacheKey(
JSON.stringify({
type: "firecrawl-search",
q: params.query,
count,
baseUrl,
sources,
categories,
scrapeResults,
}),
);
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const body: Record<string, unknown> = {
query: params.query,
limit: count,
};
if (sources.length > 0) {
body.sources = sources;
}
if (categories.length > 0) {
body.categories = categories;
}
if (scrapeResults) {
body.scrapeOptions = {
formats: ["markdown"],
};
}
const start = Date.now();
const payload = await postFirecrawlJson({
baseUrl,
pathname: "/v2/search",
apiKey,
body,
timeoutSeconds,
errorLabel: "Firecrawl Search",
});
const result = buildSearchPayload({
query: params.query,
provider: "firecrawl",
items: resolveSearchItems(payload),
tookMs: Date.now() - start,
scrapeResults,
});
writeCache(
SEARCH_CACHE,
cacheKey,
result,
resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
);
return result;
}
function resolveScrapeData(payload: Record<string, unknown>): Record<string, unknown> {
const data = payload.data;
if (data && typeof data === "object") {
return data as Record<string, unknown>;
}
return {};
}
export function parseFirecrawlScrapePayload(params: {
payload: Record<string, unknown>;
url: string;
extractMode: "markdown" | "text";
maxChars: number;
}): Record<string, unknown> {
const data = resolveScrapeData(params.payload);
const metadata =
data.metadata && typeof data.metadata === "object"
? (data.metadata as Record<string, unknown>)
: undefined;
const markdown =
(typeof data.markdown === "string" && data.markdown) ||
(typeof data.content === "string" && data.content) ||
"";
if (!markdown) {
throw new Error("Firecrawl scrape returned no content.");
}
const rawText = params.extractMode === "text" ? markdownToText(markdown) : markdown;
const truncated = truncateText(rawText, params.maxChars);
return {
url: params.url,
finalUrl:
(typeof metadata?.sourceURL === "string" && metadata.sourceURL) ||
(typeof data.url === "string" && data.url) ||
params.url,
status:
(typeof metadata?.statusCode === "number" && metadata.statusCode) ||
(typeof data.statusCode === "number" && data.statusCode) ||
undefined,
title:
typeof metadata?.title === "string" && metadata.title
? wrapExternalContent(metadata.title, { source: "web_fetch", includeWarning: false })
: undefined,
extractor: "firecrawl",
extractMode: params.extractMode,
externalContent: {
untrusted: true,
source: "web_fetch",
wrapped: true,
},
truncated: truncated.truncated,
rawLength: rawText.length,
wrappedLength: wrapExternalContent(truncated.text, {
source: "web_fetch",
includeWarning: false,
}).length,
text: wrapExternalContent(truncated.text, {
source: "web_fetch",
includeWarning: false,
}),
warning:
typeof params.payload.warning === "string" && params.payload.warning
? wrapExternalContent(params.payload.warning, {
source: "web_fetch",
includeWarning: false,
})
: undefined,
};
}
export async function runFirecrawlScrape(
params: FirecrawlScrapeParams,
): Promise<Record<string, unknown>> {
const apiKey = resolveFirecrawlApiKey(params.cfg);
if (!apiKey) {
throw new Error(
"firecrawl_scrape needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.fetch.firecrawl.apiKey.",
);
}
const baseUrl = resolveFirecrawlBaseUrl(params.cfg);
const timeoutSeconds = resolveFirecrawlScrapeTimeoutSeconds(params.cfg, params.timeoutSeconds);
const onlyMainContent = resolveFirecrawlOnlyMainContent(params.cfg, params.onlyMainContent);
const maxAgeMs = resolveFirecrawlMaxAgeMs(params.cfg, params.maxAgeMs);
const proxy = params.proxy ?? "auto";
const storeInCache = params.storeInCache ?? true;
const maxChars =
typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0
? Math.floor(params.maxChars)
: DEFAULT_SCRAPE_MAX_CHARS;
const cacheKey = normalizeCacheKey(
JSON.stringify({
type: "firecrawl-scrape",
url: params.url,
extractMode: params.extractMode,
baseUrl,
onlyMainContent,
maxAgeMs,
proxy,
storeInCache,
maxChars,
}),
);
const cached = readCache(SCRAPE_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const payload = await postFirecrawlJson({
baseUrl,
pathname: "/v2/scrape",
apiKey,
timeoutSeconds,
errorLabel: "Firecrawl",
body: {
url: params.url,
formats: ["markdown"],
onlyMainContent,
timeout: timeoutSeconds * 1000,
maxAge: maxAgeMs,
proxy,
storeInCache,
},
});
const result = parseFirecrawlScrapePayload({
payload,
url: params.url,
extractMode: params.extractMode,
maxChars,
});
writeCache(
SCRAPE_CACHE,
cacheKey,
result,
resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
);
return result;
}
export const __testing = {
parseFirecrawlScrapePayload,
resolveSearchItems,
};

View File

@ -0,0 +1,89 @@
import { Type } from "@sinclair/typebox";
import { optionalStringEnum } from "../../../src/agents/schema/typebox.js";
import { jsonResult, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
import { runFirecrawlScrape } from "./firecrawl-client.js";
const FirecrawlScrapeToolSchema = Type.Object(
{
url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }),
extractMode: optionalStringEnum(["markdown", "text"] as const, {
description: 'Extraction mode ("markdown" or "text"). Default: markdown.',
}),
maxChars: Type.Optional(
Type.Number({
description: "Maximum characters to return.",
minimum: 100,
}),
),
onlyMainContent: Type.Optional(
Type.Boolean({
description: "Keep only main content when Firecrawl supports it.",
}),
),
maxAgeMs: Type.Optional(
Type.Number({
description: "Maximum Firecrawl cache age in milliseconds.",
minimum: 0,
}),
),
proxy: optionalStringEnum(["auto", "basic", "stealth"] as const, {
description: 'Firecrawl proxy mode ("auto", "basic", or "stealth").',
}),
storeInCache: Type.Optional(
Type.Boolean({
description: "Whether Firecrawl should store the scrape in its cache.",
}),
),
timeoutSeconds: Type.Optional(
Type.Number({
description: "Timeout in seconds for the Firecrawl scrape request.",
minimum: 1,
}),
),
},
{ additionalProperties: false },
);
export function createFirecrawlScrapeTool(api: OpenClawPluginApi) {
return {
name: "firecrawl_scrape",
label: "Firecrawl Scrape",
description:
"Scrape a page using Firecrawl v2/scrape. Useful for JS-heavy or bot-protected pages where plain web_fetch is weak.",
parameters: FirecrawlScrapeToolSchema,
execute: async (_toolCallId: string, rawParams: Record<string, unknown>) => {
const url = readStringParam(rawParams, "url", { required: true });
const extractMode =
readStringParam(rawParams, "extractMode") === "text" ? "text" : "markdown";
const maxChars = readNumberParam(rawParams, "maxChars", { integer: true });
const maxAgeMs = readNumberParam(rawParams, "maxAgeMs", { integer: true });
const timeoutSeconds = readNumberParam(rawParams, "timeoutSeconds", {
integer: true,
});
const proxyRaw = readStringParam(rawParams, "proxy");
const proxy =
proxyRaw === "basic" || proxyRaw === "stealth" || proxyRaw === "auto"
? proxyRaw
: undefined;
const onlyMainContent =
typeof rawParams.onlyMainContent === "boolean" ? rawParams.onlyMainContent : undefined;
const storeInCache =
typeof rawParams.storeInCache === "boolean" ? rawParams.storeInCache : undefined;
return jsonResult(
await runFirecrawlScrape({
cfg: api.config,
url,
extractMode,
maxChars,
onlyMainContent,
maxAgeMs,
proxy,
storeInCache,
timeoutSeconds,
}),
);
},
};
}

View File

@ -0,0 +1,63 @@
import { Type } from "@sinclair/typebox";
import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js";
import { runFirecrawlSearch } from "./firecrawl-client.js";
const GenericFirecrawlSearchSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
},
{ additionalProperties: false },
);
function getScopedCredentialValue(searchConfig?: Record<string, unknown>): unknown {
const scoped = searchConfig?.firecrawl;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return undefined;
}
return (scoped as Record<string, unknown>).apiKey;
}
function setScopedCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
const scoped = searchConfigTarget.firecrawl;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget.firecrawl = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
}
export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "firecrawl",
label: "Firecrawl Search",
hint: "Structured results with optional result scraping",
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://www.firecrawl.dev/",
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
autoDetectOrder: 60,
getCredentialValue: getScopedCredentialValue,
setCredentialValue: setScopedCredentialValue,
createTool: (ctx) => ({
description:
"Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.",
parameters: GenericFirecrawlSearchSchema,
execute: async (args) =>
await runFirecrawlSearch({
cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "",
count: typeof args.count === "number" ? args.count : undefined,
}),
}),
};
}

View File

@ -0,0 +1,76 @@
import { Type } from "@sinclair/typebox";
import {
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "../../../src/agents/tools/common.js";
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
import { runFirecrawlSearch } from "./firecrawl-client.js";
const FirecrawlSearchToolSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
sources: Type.Optional(
Type.Array(Type.String(), {
description: 'Optional sources list, for example ["web"], ["news"], or ["images"].',
}),
),
categories: Type.Optional(
Type.Array(Type.String(), {
description: 'Optional Firecrawl categories, for example ["github"] or ["research"].',
}),
),
scrapeResults: Type.Optional(
Type.Boolean({
description: "Include scraped result content when Firecrawl returns it.",
}),
),
timeoutSeconds: Type.Optional(
Type.Number({
description: "Timeout in seconds for the Firecrawl Search request.",
minimum: 1,
}),
),
},
{ additionalProperties: false },
);
export function createFirecrawlSearchTool(api: OpenClawPluginApi) {
return {
name: "firecrawl_search",
label: "Firecrawl Search",
description:
"Search the web using Firecrawl v2/search. Can optionally include scraped content from result pages.",
parameters: FirecrawlSearchToolSchema,
execute: async (_toolCallId: string, rawParams: Record<string, unknown>) => {
const query = readStringParam(rawParams, "query", { required: true });
const count = readNumberParam(rawParams, "count", { integer: true });
const timeoutSeconds = readNumberParam(rawParams, "timeoutSeconds", {
integer: true,
});
const sources = readStringArrayParam(rawParams, "sources");
const categories = readStringArrayParam(rawParams, "categories");
const scrapeResults = rawParams.scrapeResults === true;
return jsonResult(
await runFirecrawlSearch({
cfg: api.config,
query,
count,
timeoutSeconds,
sources,
categories,
scrapeResults,
}),
);
},
};
}

View File

@ -8,11 +8,8 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles
import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { coerceSecretRef } from "../../src/config/types.secrets.js";
import { fetchCopilotUsage } from "../../src/infra/provider-usage.fetch.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../../src/providers/github-copilot-token.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
const PROVIDER_ID = "github-copilot";
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];

View File

@ -1,6 +1,9 @@
{
"id": "github-copilot",
"providers": ["github-copilot"],
"providerAuthEnvVars": {
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,8 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deriveCopilotApiBaseUrlFromToken,
resolveCopilotApiToken,
} from "./github-copilot-token.js";
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
describe("github-copilot token", () => {
const loadJsonFile = vi.fn();
@ -58,7 +55,7 @@ describe("github-copilot token", () => {
}),
});
const { resolveCopilotApiToken } = await import("./github-copilot-token.js");
const { resolveCopilotApiToken } = await import("./token.js");
const res = await resolveCopilotApiToken({
githubToken: "gh",

View File

@ -1,6 +1,6 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { resolveStateDir } from "../../src/config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../src/infra/json-file.js";
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";

View File

@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
import { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js";
import {
createProviderUsageFetch,
makeResponse,
} from "../../src/test-utils/provider-usage-fetch.js";
import { fetchCopilotUsage } from "./usage.js";
describe("fetchCopilotUsage", () => {
it("returns HTTP errors for failed requests", async () => {

View File

@ -1,6 +1,9 @@
import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
import {
buildUsageHttpErrorSnapshot,
fetchJson,
} from "../../src/infra/provider-usage.fetch.shared.js";
import { clampPercent, PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "../../src/infra/provider-usage.types.js";
type CopilotUsageResponse = {
quota_snapshots?: {

View File

@ -1,6 +1,6 @@
import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/googlechat";
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { googlechatPlugin } from "./channel.js";
@ -26,7 +26,7 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
};
}
const googlechatConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
const googlechatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: googlechatPlugin,
wizard: googlechatPlugin.setupWizard!,
});

View File

@ -1,10 +1,10 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
addWildcardAllowFrom,
mergeAllowFromEntries,
setTopLevelChannelDmPolicyWithAllowFrom,
splitOnboardingEntries,
} from "../../../src/channels/plugins/onboarding/helpers.js";
splitSetupEntries,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import {
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
@ -48,7 +48,7 @@ function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
async function promptAllowFrom(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
const current = params.cfg.channels?.googlechat?.dm?.allowFrom ?? [];
const entry = await params.prompter.text({
@ -57,7 +57,7 @@ async function promptAllowFrom(params: {
initialValue: current[0] ? String(current[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = splitOnboardingEntries(String(entry));
const parts = splitSetupEntries(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return {
...params.cfg,
@ -76,7 +76,7 @@ async function promptAllowFrom(params: {
};
}
const googlechatDmPolicy: ChannelOnboardingDmPolicy = {
const googlechatDmPolicy: ChannelSetupDmPolicy = {
label: "Google Chat",
channel,
policyKey: "channels.googlechat.dm.policy",

View File

@ -1,6 +1,9 @@
{
"id": "huggingface",
"providers": ["huggingface"],
"providerAuthEnvVars": {
"huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,3 +1,3 @@
import { imessagePlugin } from "./src/channel.js";
import { imessageSetupPlugin } from "./src/channel.setup.js";
export default { plugin: imessagePlugin };
export default { plugin: imessageSetupPlugin };

View File

@ -0,0 +1,99 @@
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
IMessageConfigSchema,
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ResolvedIMessageAccount,
} from "openclaw/plugin-sdk/imessage";
import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js";
async function loadIMessageChannelRuntime() {
return await import("./channel.runtime.js");
}
const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({
imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard,
}));
export const imessageSetupPlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage",
meta: {
...getChatChannelMeta("imessage"),
aliases: ["imsg"],
showConfigured: false,
},
setupWizard: imessageSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "imessage",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "imessage",
accountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) =>
buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "imessage",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
}),
collectWarnings: ({ account, cfg }) =>
collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.imessage !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "iMessage groups",
openScope: "any member",
groupPolicyPath: "channels.imessage.groupPolicy",
groupAllowFromPath: "channels.imessage.groupAllowFrom",
mentionGated: false,
}),
},
setup: imessageSetupAdapter,
};

View File

@ -1,10 +1,10 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
parseOnboardingEntriesAllowingWildcard,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForScopedChannel,
setChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
} from "../../../src/channels/plugins/onboarding/helpers.js";
setSetupChannelEnabled,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
@ -25,7 +25,7 @@ import { normalizeIMessageHandle } from "./targets.js";
const channel = "imessage" as const;
export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } {
return parseOnboardingEntriesAllowingWildcard(raw, (entry) => {
return parseSetupEntriesAllowingWildcard(raw, (entry) => {
const lower = entry.toLowerCase();
if (lower.startsWith("chat_id:")) {
const id = entry.slice("chat_id:".length).trim();
@ -157,7 +157,7 @@ export const imessageSetupAdapter: ChannelSetupAdapter = {
export function createIMessageSetupWizardProxy(
loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>,
) {
const imessageDmPolicy: ChannelOnboardingDmPolicy = {
const imessageDmPolicy: ChannelSetupDmPolicy = {
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
@ -231,6 +231,6 @@ export function createIMessageSetupWizardProxy(
],
},
dmPolicy: imessageDmPolicy,
disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}

View File

@ -1,10 +1,10 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
parseOnboardingEntriesAllowingWildcard,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForScopedChannel,
setChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
} from "../../../src/channels/plugins/onboarding/helpers.js";
setSetupChannelEnabled,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
@ -50,7 +50,7 @@ async function promptIMessageAllowFrom(params: {
});
}
const imessageDmPolicy: ChannelOnboardingDmPolicy = {
const imessageDmPolicy: ChannelSetupDmPolicy = {
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
@ -129,7 +129,7 @@ export const imessageSetupWizard: ChannelSetupWizard = {
],
},
dmPolicy: imessageDmPolicy,
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { imessageSetupAdapter, parseIMessageAllowFromEntries };

View File

@ -1,6 +1,6 @@
import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc";
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { ircPlugin } from "./channel.js";
import type { CoreConfig } from "./types.js";
@ -27,7 +27,7 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
};
}
const ircConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
const ircConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: ircPlugin,
wizard: ircPlugin.setupWizard!,
});

View File

@ -1,7 +1,7 @@
import {
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "../../../src/channels/plugins/onboarding/helpers.js";
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,

View File

@ -1,8 +1,8 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
resolveOnboardingAccountId,
setOnboardingChannelEnabled,
} from "../../../src/channels/plugins/onboarding/helpers.js";
resolveSetupAccountId,
setSetupChannelEnabled,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
@ -165,7 +165,7 @@ async function promptIrcNickServConfig(params: {
});
}
const ircDmPolicy: ChannelOnboardingDmPolicy = {
const ircDmPolicy: ChannelSetupDmPolicy = {
label: "IRC",
channel,
policyKey: "channels.irc.dmPolicy",
@ -176,7 +176,7 @@ const ircDmPolicy: ChannelOnboardingDmPolicy = {
await promptIrcAllowFrom({
cfg: cfg as CoreConfig,
prompter,
accountId: resolveOnboardingAccountId({
accountId: resolveSetupAccountId({
accountId,
defaultAccountId: resolveDefaultIrcAccountId(cfg as CoreConfig),
}),
@ -458,7 +458,7 @@ export const ircSetupWizard: ChannelSetupWizard = {
],
},
dmPolicy: ircDmPolicy,
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { ircSetupAdapter };

View File

@ -1,6 +1,9 @@
{
"id": "kilocode",
"providers": ["kilocode"],
"providerAuthEnvVars": {
"kilocode": ["KILOCODE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "kimi-coding",
"providers": ["kimi-coding"],
"providerAuthEnvVars": {
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -8,6 +8,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "line",
"label": "LINE",

View File

@ -0,0 +1,5 @@
import { lineSetupPlugin } from "./src/channel.setup.js";
export default {
plugin: lineSetupPlugin,
};

View File

@ -0,0 +1,69 @@
import {
buildChannelConfigSchema,
LineConfigSchema,
type ChannelPlugin,
type OpenClawConfig,
type ResolvedLineAccount,
} from "openclaw/plugin-sdk/line";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../../src/line/accounts.js";
import { lineSetupAdapter } from "./setup-core.js";
import { lineSetupWizard } from "./setup-surface.js";
const meta = {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
systemImage: "message.fill",
} as const;
const normalizeLineAllowFrom = (entry: string) => entry.replace(/^line:(?:user:)?/i, "");
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
...meta,
quickstartAllowFrom: true,
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
listAccountIds: (cfg: OpenClawConfig) => listLineAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultLineAccountId(cfg),
isConfigured: (account) =>
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
tokenSource: account.tokenSource ?? undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom,
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => normalizeLineAllowFrom(entry)),
},
setupWizard: lineSetupWizard,
setup: lineSetupAdapter,
};

View File

@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/line";
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
@ -30,7 +30,7 @@ function createPrompter(overrides: Partial<WizardPrompter> = {}): WizardPrompter
};
}
const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
const lineConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: {
id: "line",
meta: { label: "LINE" },
@ -41,7 +41,7 @@ const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom,
},
setup: lineSetupAdapter,
} as Parameters<typeof buildChannelOnboardingAdapterFromSetupWizard>[0]["plugin"],
} as Parameters<typeof buildChannelSetupFlowAdapterFromSetupWizard>[0]["plugin"],
wizard: lineSetupWizard,
});

View File

@ -1,9 +1,9 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
setOnboardingChannelEnabled,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
splitOnboardingEntries,
} from "../../../src/channels/plugins/onboarding/helpers.js";
splitSetupEntries,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { resolveLineAccount } from "../../../src/line/accounts.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
@ -35,7 +35,7 @@ const LINE_ALLOW_FROM_HELP_LINES = [
`Docs: ${formatDocsLink("/channels/line", "channels/line")}`,
];
const lineDmPolicy: ChannelOnboardingDmPolicy = {
const lineDmPolicy: ChannelSetupDmPolicy = {
label: "LINE",
channel,
policyKey: "channels.line.dmPolicy",
@ -169,7 +169,7 @@ export const lineSetupWizard: ChannelSetupWizard = {
placeholder: "U1234567890abcdef1234567890abcdef",
invalidWithoutCredentialNote:
"LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.",
parseInputs: splitOnboardingEntries,
parseInputs: splitSetupEntries,
parseId: parseLineAllowFromId,
resolveEntries: async ({ entries }) =>
entries.map((entry) => {
@ -198,5 +198,5 @@ export const lineSetupWizard: ChannelSetupWizard = {
`Docs: ${formatDocsLink("/channels/line", "channels/line")}`,
],
},
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};

View File

@ -1,12 +1,11 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import {
addWildcardAllowFrom,
buildSingleChannelSecretPromptState,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
setTopLevelChannelGroupPolicy,
} from "../../../src/channels/plugins/onboarding/helpers.js";
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
@ -171,7 +170,79 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
};
}
const matrixDmPolicy: ChannelOnboardingDmPolicy = {
async function resolveMatrixGroupRooms(params: {
cfg: CoreConfig;
entries: string[];
prompter: Pick<WizardPrompter, "note">;
}): Promise<string[]> {
if (params.entries.length === 0) {
return [];
}
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of params.entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
const roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
const resolution = formatResolvedUnresolvedNote({
resolved: resolvedIds,
unresolved,
});
if (resolution) {
await params.prompter.note(resolution, "Matrix rooms");
}
return roomKeys;
} catch (err) {
await params.prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
return params.entries.map((entry) => entry.trim()).filter(Boolean);
}
}
const matrixGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
label: "Matrix rooms",
placeholder: "!roomId:server, #alias:server, Project Room",
currentPolicy: ({ cfg }) => cfg.channels?.matrix?.groupPolicy ?? "allowlist",
currentEntries: ({ cfg }) =>
Object.keys(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms ?? {}),
updatePrompt: ({ cfg }) => Boolean(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms),
setPolicy: ({ cfg, policy }) => setMatrixGroupPolicy(cfg as CoreConfig, policy),
resolveAllowlist: async ({ cfg, entries, prompter }) =>
await resolveMatrixGroupRooms({
cfg: cfg as CoreConfig,
entries,
prompter,
}),
applyAllowlist: ({ cfg, resolved }) =>
setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]),
};
const matrixDmPolicy: ChannelSetupDmPolicy = {
label: "Matrix",
channel,
policyKey: "channels.matrix.dm.policy",
@ -386,72 +457,10 @@ export const matrixSetupWizard: ChannelSetupWizard = {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(existingGroups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of accessConfig.entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
const resolution = formatResolvedUnresolvedNote({
resolved: resolvedIds,
unresolved,
});
if (resolution) {
await prompter.note(resolution, "Matrix rooms");
}
} catch (err) {
await prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixGroupRooms(next, roomKeys);
}
}
return { cfg: next };
},
dmPolicy: matrixDmPolicy,
groupAccess: matrixGroupAccess,
disable: (cfg) => ({
...(cfg as CoreConfig),
channels: {

View File

@ -11,6 +11,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "mattermost",
"label": "Mattermost",

View File

@ -0,0 +1,5 @@
import { mattermostPlugin } from "./src/channel.js";
export default {
plugin: mattermostPlugin,
};

View File

@ -1,7 +1,13 @@
import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput } from "openclaw/plugin-sdk/mattermost";
import {
applySetupAccountConfigPatch,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { listMattermostAccountIds } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
isMattermostConfigured,
mattermostSetupAdapter,

View File

@ -175,6 +175,7 @@ const minimaxPlugin = {
id: PORTAL_PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/minimax",
envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
catalog: {
run: async (ctx) => resolvePortalCatalog(ctx),
},

View File

@ -1,6 +1,10 @@
{
"id": "minimax",
"providers": ["minimax", "minimax-portal"],
"providerAuthEnvVars": {
"minimax": ["MINIMAX_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "mistral",
"providers": ["mistral"],
"providerAuthEnvVars": {
"mistral": ["MISTRAL_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "modelstudio",
"providers": ["modelstudio"],
"providerAuthEnvVars": {
"modelstudio": ["MODELSTUDIO_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "moonshot",
"providers": ["moonshot"],
"providerAuthEnvVars": {
"moonshot": ["MOONSHOT_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,12 +1,11 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import {
mergeAllowFromEntries,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitOnboardingEntries,
} from "../../../src/channels/plugins/onboarding/helpers.js";
splitSetupEntries,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js";
@ -94,7 +93,7 @@ async function promptMSTeamsAllowFrom(params: {
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = splitOnboardingEntries(String(entry));
const parts = splitSetupEntries(String(entry));
if (parts.length === 0) {
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
continue;
@ -191,7 +190,97 @@ function setMSTeamsTeamsAllowlist(
};
}
const msteamsDmPolicy: ChannelOnboardingDmPolicy = {
function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] {
return Object.entries(cfg.channels?.msteams?.teams ?? {}).flatMap(([teamKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) {
return [teamKey];
}
return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
});
}
async function resolveMSTeamsGroupAllowlist(params: {
cfg: OpenClawConfig;
entries: string[];
prompter: Pick<WizardPrompter, "note">;
}): Promise<Array<{ teamKey: string; channelKey?: string }>> {
let resolvedEntries = params.entries
.map((entry) => parseMSTeamsTeamEntry(entry))
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
if (params.entries.length === 0 || !resolveMSTeamsCredentials(params.cfg.channels?.msteams)) {
return resolvedEntries;
}
try {
const lookups = await resolveMSTeamsChannelAllowlist({
cfg: params.cfg,
entries: params.entries,
});
const resolvedChannels = lookups.filter(
(entry) => entry.resolved && entry.teamId && entry.channelId,
);
const resolvedTeams = lookups.filter(
(entry) => entry.resolved && entry.teamId && !entry.channelId,
);
const unresolved = lookups.filter((entry) => !entry.resolved).map((entry) => entry.input);
resolvedEntries = [
...resolvedChannels.map((entry) => ({
teamKey: entry.teamId as string,
channelKey: entry.channelId as string,
})),
...resolvedTeams.map((entry) => ({
teamKey: entry.teamId as string,
})),
...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean),
] as Array<{ teamKey: string; channelKey?: string }>;
const summary: string[] = [];
if (resolvedChannels.length > 0) {
summary.push(
`Resolved channels: ${resolvedChannels
.map((entry) => entry.channelId)
.filter(Boolean)
.join(", ")}`,
);
}
if (resolvedTeams.length > 0) {
summary.push(
`Resolved teams: ${resolvedTeams
.map((entry) => entry.teamId)
.filter(Boolean)
.join(", ")}`,
);
}
if (unresolved.length > 0) {
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
}
if (summary.length > 0) {
await params.prompter.note(summary.join("\n"), "MS Teams channels");
}
return resolvedEntries;
} catch (err) {
await params.prompter.note(
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
"MS Teams channels",
);
return resolvedEntries;
}
}
const msteamsGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
label: "MS Teams channels",
placeholder: "Team Name/Channel Name, teamId/conversationId",
currentPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy ?? "allowlist",
currentEntries: ({ cfg }) => listMSTeamsGroupEntries(cfg),
updatePrompt: ({ cfg }) => Boolean(cfg.channels?.msteams?.teams),
setPolicy: ({ cfg, policy }) => setMSTeamsGroupPolicy(cfg, policy),
resolveAllowlist: async ({ cfg, entries, prompter }) =>
await resolveMSTeamsGroupAllowlist({ cfg, entries, prompter }),
applyAllowlist: ({ cfg, resolved }) =>
setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>),
};
const msteamsDmPolicy: ChannelSetupDmPolicy = {
label: "MS Teams",
channel,
policyKey: "channels.msteams.dmPolicy",
@ -290,96 +379,10 @@ export const msteamsSetupWizard: ChannelSetupWizard = {
};
}
const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap(
([teamKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) {
return [teamKey];
}
return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
},
);
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "MS Teams channels",
currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist",
currentEntries,
placeholder: "Team Name/Channel Name, teamId/conversationId",
updatePrompt: Boolean(next.channels?.msteams?.teams),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMSTeamsGroupPolicy(next, accessConfig.policy);
} else {
let entries = accessConfig.entries
.map((entry) => parseMSTeamsTeamEntry(entry))
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) {
try {
const resolvedEntries = await resolveMSTeamsChannelAllowlist({
cfg: next,
entries: accessConfig.entries,
});
const resolvedChannels = resolvedEntries.filter(
(entry) => entry.resolved && entry.teamId && entry.channelId,
);
const resolvedTeams = resolvedEntries.filter(
(entry) => entry.resolved && entry.teamId && !entry.channelId,
);
const unresolved = resolvedEntries
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
entries = [
...resolvedChannels.map((entry) => ({
teamKey: entry.teamId as string,
channelKey: entry.channelId as string,
})),
...resolvedTeams.map((entry) => ({
teamKey: entry.teamId as string,
})),
...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean),
] as Array<{ teamKey: string; channelKey?: string }>;
if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) {
const summary: string[] = [];
if (resolvedChannels.length > 0) {
summary.push(
`Resolved channels: ${resolvedChannels
.map((entry) => entry.channelId)
.filter(Boolean)
.join(", ")}`,
);
}
if (resolvedTeams.length > 0) {
summary.push(
`Resolved teams: ${resolvedTeams
.map((entry) => entry.teamId)
.filter(Boolean)
.join(", ")}`,
);
}
if (unresolved.length > 0) {
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
}
await prompter.note(summary.join("\n"), "MS Teams channels");
}
} catch (err) {
await prompter.note(
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
"MS Teams channels",
);
}
}
next = setMSTeamsGroupPolicy(next, "allowlist");
next = setMSTeamsTeamsAllowlist(next, entries);
}
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy: msteamsDmPolicy,
groupAccess: msteamsGroupAccess,
disable: (cfg) => ({
...cfg,
channels: {

View File

@ -1,10 +1,10 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
mergeAllowFromEntries,
resolveOnboardingAccountId,
setOnboardingChannelEnabled,
resolveSetupAccountId,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "../../../src/channels/plugins/onboarding/helpers.js";
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,
@ -163,7 +163,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveOnboardingAccountId({
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig),
});
@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: {
});
}
const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = {
const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",

View File

@ -1,10 +1,10 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
mergeAllowFromEntries,
resolveOnboardingAccountId,
setOnboardingChannelEnabled,
resolveSetupAccountId,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "../../../src/channels/plugins/onboarding/helpers.js";
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
@ -85,7 +85,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveOnboardingAccountId({
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig),
});
@ -96,7 +96,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: {
});
}
const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = {
const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
@ -272,7 +272,7 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
},
],
dmPolicy: nextcloudTalkDmPolicy,
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { nextcloudTalkSetupAdapter };

View File

@ -11,6 +11,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "nostr",
"label": "Nostr",

View File

@ -0,0 +1,5 @@
import { nostrPlugin } from "./src/channel.js";
export default {
plugin: nostrPlugin,
};

View File

@ -17,6 +17,7 @@ import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
import type { ProfilePublishResult } from "./nostr-profile.js";
import { getNostrRuntime } from "./runtime.js";
import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js";
import {
listNostrAccountIds,
resolveDefaultNostrAccountId,
@ -47,6 +48,8 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
},
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
setup: nostrSetupAdapter,
setupWizard: nostrSetupWizard,
config: {
listAccountIds: (cfg) => listNostrAccountIds(cfg),

View File

@ -0,0 +1,67 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr";
import { describe, expect, it, vi } from "vitest";
import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { nostrPlugin } from "./channel.js";
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
return first.value;
}) as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
}
const nostrConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({
plugin: nostrPlugin,
wizard: nostrPlugin.setupWizard!,
});
describe("nostr setup wizard", () => {
it("configures a private key and relay URLs", async () => {
const prompter = createPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Nostr private key (nsec... or hex)") {
return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
}
if (message === "Relay URLs (comma-separated, optional)") {
return "wss://relay.damus.io, wss://relay.primal.net";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
});
const result = await nostrConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime: createRuntimeEnv(),
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.nostr?.enabled).toBe(true);
expect(result.cfg.channels?.nostr?.privateKey).toBe(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
expect(result.cfg.channels?.nostr?.relays).toEqual([
"wss://relay.damus.io",
"wss://relay.primal.net",
]);
});
});

View File

@ -0,0 +1,297 @@
import {
mergeAllowFromEntries,
parseSetupEntriesWithParser,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
splitSetupEntries,
} from "../../../src/channels/plugins/setup-flow-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { DEFAULT_RELAYS, getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js";
import { resolveNostrAccount } from "./types.js";
const channel = "nostr" as const;
const NOSTR_SETUP_HELP_LINES = [
"Use a Nostr private key in nsec or 64-character hex format.",
"Relay URLs are optional. Leave blank to keep the default relay set.",
"Env vars supported: NOSTR_PRIVATE_KEY (default account only).",
`Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
];
const NOSTR_ALLOW_FROM_HELP_LINES = [
"Allowlist Nostr DMs by npub or hex pubkey.",
"Examples:",
"- npub1...",
"- nostr:npub1...",
"- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
];
function patchNostrConfig(params: {
cfg: OpenClawConfig;
patch: Record<string, unknown>;
clearFields?: string[];
enabled?: boolean;
}): OpenClawConfig {
const existing = (params.cfg.channels?.nostr ?? {}) as Record<string, unknown>;
const nextNostr = { ...existing };
for (const field of params.clearFields ?? []) {
delete nextNostr[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
nostr: {
...nextNostr,
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
},
};
}
function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
});
}
function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
return setTopLevelChannelAllowFrom({
cfg,
channel,
allowFrom,
});
}
function parseRelayUrls(raw: string): { relays: string[]; error?: string } {
const entries = splitSetupEntries(raw);
const relays: string[] = [];
for (const entry of entries) {
try {
const parsed = new URL(entry);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` };
}
} catch {
return { relays: [], error: `Invalid relay URL: ${entry}` };
}
relays.push(entry);
}
return { relays: [...new Set(relays)] };
}
function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } {
return parseSetupEntriesWithParser(raw, (entry) => {
const cleaned = entry.replace(/^nostr:/i, "").trim();
try {
return { value: normalizePubkey(cleaned) };
} catch {
return { error: `Invalid Nostr pubkey: ${entry}` };
}
});
}
async function promptNostrAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
}): Promise<OpenClawConfig> {
const existing = params.cfg.channels?.nostr?.allowFrom ?? [];
await params.prompter.note(NOSTR_ALLOW_FROM_HELP_LINES.join("\n"), "Nostr allowlist");
const entry = await params.prompter.text({
message: "Nostr allowFrom",
placeholder: "npub1..., 0123abcd...",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
return parseNostrAllowFrom(raw).error;
},
});
const parsed = parseNostrAllowFrom(String(entry));
return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries));
}
const nostrDmPolicy: ChannelSetupDmPolicy = {
label: "Nostr",
channel,
policyKey: "channels.nostr.dmPolicy",
allowFromKey: "channels.nostr.allowFrom",
getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy),
promptAllowFrom: promptNostrAllowFrom,
};
export const nostrSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountName: ({ cfg, name }) =>
patchNostrConfig({
cfg,
patch: name?.trim() ? { name: name.trim() } : {},
}),
validateInput: ({ input }) => {
const typedInput = input as {
useEnv?: boolean;
privateKey?: string;
relayUrls?: string;
};
if (!typedInput.useEnv) {
const privateKey = typedInput.privateKey?.trim();
if (!privateKey) {
return "Nostr requires --private-key or --use-env.";
}
try {
getPublicKeyFromPrivate(privateKey);
} catch {
return "Nostr private key must be valid nsec or 64-character hex.";
}
}
if (typedInput.relayUrls?.trim()) {
return parseRelayUrls(typedInput.relayUrls).error ?? null;
}
return null;
},
applyAccountConfig: ({ cfg, input }) => {
const typedInput = input as {
useEnv?: boolean;
privateKey?: string;
relayUrls?: string;
};
const relayResult = typedInput.relayUrls?.trim()
? parseRelayUrls(typedInput.relayUrls)
: { relays: [] };
return patchNostrConfig({
cfg,
enabled: true,
clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
patch: {
...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }),
...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}),
},
});
},
};
export const nostrSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs private key",
configuredHint: "configured",
unconfiguredHint: "needs private key",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured,
resolveStatusLines: ({ cfg, configured }) => {
const account = resolveNostrAccount({ cfg });
return [
`Nostr: ${configured ? "configured" : "needs private key"}`,
`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`,
];
},
},
introNote: {
title: "Nostr setup",
lines: NOSTR_SETUP_HELP_LINES,
},
envShortcut: {
prompt: "NOSTR_PRIVATE_KEY detected. Use env var?",
preferredEnvVar: "NOSTR_PRIVATE_KEY",
isAvailable: ({ cfg, accountId }) =>
accountId === DEFAULT_ACCOUNT_ID &&
Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) &&
!resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(),
apply: async ({ cfg }) =>
patchNostrConfig({
cfg,
enabled: true,
clearFields: ["privateKey"],
patch: {},
}),
},
credentials: [
{
inputKey: "privateKey",
providerHint: channel,
credentialLabel: "private key",
preferredEnvVar: "NOSTR_PRIVATE_KEY",
helpTitle: "Nostr private key",
helpLines: NOSTR_SETUP_HELP_LINES,
envPrompt: "NOSTR_PRIVATE_KEY detected. Use env var?",
keepPrompt: "Nostr private key already configured. Keep it?",
inputPrompt: "Nostr private key (nsec... or hex)",
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }) => {
const account = resolveNostrAccount({ cfg, accountId });
return {
accountConfigured: account.configured,
hasConfiguredValue: Boolean(account.config.privateKey?.trim()),
resolvedValue: account.config.privateKey?.trim(),
envValue: process.env.NOSTR_PRIVATE_KEY?.trim(),
};
},
applyUseEnv: async ({ cfg }) =>
patchNostrConfig({
cfg,
enabled: true,
clearFields: ["privateKey"],
patch: {},
}),
applySet: async ({ cfg, resolvedValue }) =>
patchNostrConfig({
cfg,
enabled: true,
patch: { privateKey: resolvedValue },
}),
},
],
textInputs: [
{
inputKey: "relayUrls",
message: "Relay URLs (comma-separated, optional)",
placeholder: DEFAULT_RELAYS.join(", "),
required: false,
applyEmptyValue: true,
helpTitle: "Nostr relays",
helpLines: ["Use ws:// or wss:// relay URLs.", "Leave blank to keep the default relay set."],
currentValue: ({ cfg, accountId }) => {
const account = resolveNostrAccount({ cfg, accountId });
const relays =
cfg.channels?.nostr?.relays && cfg.channels.nostr.relays.length > 0 ? account.relays : [];
return relays.join(", ");
},
keepPrompt: (value) => `Relay URLs set (${value}). Keep them?`,
validate: ({ value }) => parseRelayUrls(value).error,
applySet: async ({ cfg, value }) => {
const relayResult = parseRelayUrls(value);
return patchNostrConfig({
cfg,
enabled: true,
clearFields: relayResult.relays.length > 0 ? undefined : ["relays"],
patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {},
});
},
},
],
dmPolicy: nostrDmPolicy,
disable: (cfg) =>
patchNostrConfig({
cfg,
patch: { enabled: false },
}),
};

View File

@ -1,6 +1,9 @@
{
"id": "nvidia",
"providers": ["nvidia"],
"providerAuthEnvVars": {
"nvidia": ["NVIDIA_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "ollama",
"providers": ["ollama"],
"providerAuthEnvVars": {
"ollama": ["OLLAMA_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "openai",
"providers": ["openai", "openai-codex"],
"providerAuthEnvVars": {
"openai": ["OPENAI_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "opencode-go",
"providers": ["opencode-go"],
"providerAuthEnvVars": {
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "opencode",
"providers": ["opencode"],
"providerAuthEnvVars": {
"opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,6 +1,9 @@
{
"id": "openrouter",
"providers": ["openrouter"],
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -0,0 +1,30 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { registerSandboxBackend } from "openclaw/plugin-sdk/core";
import {
createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager,
} from "./src/backend.js";
import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./src/config.js";
const plugin = {
id: "openshell",
name: "OpenShell Sandbox",
description: "OpenShell-backed sandbox runtime for agent exec and file tools.",
configSchema: createOpenShellPluginConfigSchema(),
register(api: OpenClawPluginApi) {
if (api.registrationMode !== "full") {
return;
}
const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
registerSandboxBackend("openshell", {
factory: createOpenShellSandboxBackendFactory({
pluginConfig,
}),
manager: createOpenShellSandboxBackendManager({
pluginConfig,
}),
});
},
};
export default plugin;

View File

@ -0,0 +1,99 @@
{
"id": "openshell",
"name": "OpenShell Sandbox",
"description": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"command": {
"type": "string"
},
"gateway": {
"type": "string"
},
"gatewayEndpoint": {
"type": "string"
},
"from": {
"type": "string"
},
"policy": {
"type": "string"
},
"providers": {
"type": "array",
"items": {
"type": "string"
}
},
"gpu": {
"type": "boolean"
},
"autoProviders": {
"type": "boolean"
},
"remoteWorkspaceDir": {
"type": "string"
},
"remoteAgentWorkspaceDir": {
"type": "string"
},
"timeoutSeconds": {
"type": "number",
"minimum": 1
}
}
},
"uiHints": {
"command": {
"label": "OpenShell Command",
"help": "Path or command name for the openshell CLI."
},
"gateway": {
"label": "Gateway Name",
"help": "Optional OpenShell gateway name passed as --gateway."
},
"gatewayEndpoint": {
"label": "Gateway Endpoint",
"help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint."
},
"from": {
"label": "Sandbox Source",
"help": "OpenShell sandbox source for first-time create. Defaults to openclaw."
},
"policy": {
"label": "Policy File",
"help": "Optional path to a custom OpenShell sandbox policy YAML."
},
"providers": {
"label": "Providers",
"help": "Provider names to attach when a sandbox is created."
},
"gpu": {
"label": "GPU",
"help": "Request GPU resources when creating the sandbox.",
"advanced": true
},
"autoProviders": {
"label": "Auto-create Providers",
"help": "When enabled, pass --auto-providers during sandbox create.",
"advanced": true
},
"remoteWorkspaceDir": {
"label": "Remote Workspace Dir",
"help": "Primary writable workspace inside the OpenShell sandbox.",
"advanced": true
},
"remoteAgentWorkspaceDir": {
"label": "Remote Agent Dir",
"help": "Mirror path for the real agent workspace when workspaceAccess is read-only.",
"advanced": true
},
"timeoutSeconds": {
"label": "Command Timeout Seconds",
"help": "Timeout for openshell CLI operations such as create/upload/download.",
"advanced": true
}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/openshell-sandbox",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw OpenShell sandbox backend",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,117 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const cliMocks = vi.hoisted(() => ({
runOpenShellCli: vi.fn(),
}));
vi.mock("./cli.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./cli.js")>();
return {
...actual,
runOpenShellCli: cliMocks.runOpenShellCli,
};
});
import { createOpenShellSandboxBackendManager } from "./backend.js";
import { resolveOpenShellPluginConfig } from "./config.js";
describe("openshell backend manager", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("checks runtime status with config override from OpenClaw config", async () => {
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "{}",
stderr: "",
});
const manager = createOpenShellSandboxBackendManager({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
from: "openclaw",
}),
});
const result = await manager.describeRuntime({
entry: {
containerName: "openclaw-session-1234",
backendId: "openshell",
runtimeLabel: "openclaw-session-1234",
sessionKey: "agent:main",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "custom-source",
configLabelKind: "Source",
},
config: {
plugins: {
entries: {
openshell: {
enabled: true,
config: {
command: "openshell",
from: "custom-source",
},
},
},
},
},
});
expect(result).toEqual({
running: true,
actualConfigLabel: "custom-source",
configLabelMatch: true,
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: expect.objectContaining({
sandboxName: "openclaw-session-1234",
config: expect.objectContaining({
from: "custom-source",
}),
}),
args: ["sandbox", "get", "openclaw-session-1234"],
});
});
it("removes runtimes via openshell sandbox delete", async () => {
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "",
stderr: "",
});
const manager = createOpenShellSandboxBackendManager({
pluginConfig: resolveOpenShellPluginConfig({
command: "/usr/local/bin/openshell",
gateway: "lab",
}),
});
await manager.removeRuntime({
entry: {
containerName: "openclaw-session-5678",
backendId: "openshell",
runtimeLabel: "openclaw-session-5678",
sessionKey: "agent:main",
createdAtMs: 1,
lastUsedAtMs: 1,
image: "openclaw",
configLabelKind: "Source",
},
});
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
context: expect.objectContaining({
sandboxName: "openclaw-session-5678",
config: expect.objectContaining({
command: "/usr/local/bin/openshell",
gateway: "lab",
}),
}),
args: ["sandbox", "delete", "openclaw-session-5678"],
});
});
});

View File

@ -0,0 +1,488 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
CreateSandboxBackendParams,
OpenClawConfig,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendFactory,
SandboxBackendHandle,
SandboxBackendManager,
} from "openclaw/plugin-sdk/core";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core";
import {
buildExecRemoteCommand,
buildRemoteCommand,
createOpenShellSshSession,
disposeOpenShellSshSession,
runOpenShellCli,
runOpenShellSshCommand,
type OpenShellExecContext,
type OpenShellSshSession,
} from "./cli.js";
import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js";
import { createOpenShellFsBridge } from "./fs-bridge.js";
import { replaceDirectoryContents } from "./mirror.js";
import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js";
type CreateOpenShellSandboxBackendFactoryParams = {
pluginConfig: ResolvedOpenShellPluginConfig;
};
type PendingExec = {
sshSession: OpenShellSshSession;
};
export type OpenShellSandboxBackend = SandboxBackendHandle & {
mode: "mirror" | "remote";
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
runRemoteShellScript(params: SandboxBackendCommandParams): Promise<SandboxBackendCommandResult>;
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};
export function createOpenShellSandboxBackendFactory(
params: CreateOpenShellSandboxBackendFactoryParams,
): SandboxBackendFactory {
return async (createParams) =>
await createOpenShellSandboxBackend({
...params,
createParams,
});
}
export function createOpenShellSandboxBackendManager(params: {
pluginConfig: ResolvedOpenShellPluginConfig;
}): SandboxBackendManager {
return {
async describeRuntime({ entry, config }) {
const execContext: OpenShellExecContext = {
config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
sandboxName: entry.containerName,
};
const result = await runOpenShellCli({
context: execContext,
args: ["sandbox", "get", entry.containerName],
});
const configuredSource = execContext.config.from;
return {
running: result.code === 0,
actualConfigLabel: entry.image,
configLabelMatch: entry.image === configuredSource,
};
},
async removeRuntime({ entry }) {
const execContext: OpenShellExecContext = {
config: params.pluginConfig,
sandboxName: entry.containerName,
};
await runOpenShellCli({
context: execContext,
args: ["sandbox", "delete", entry.containerName],
});
},
};
}
async function createOpenShellSandboxBackend(params: {
pluginConfig: ResolvedOpenShellPluginConfig;
createParams: CreateSandboxBackendParams;
}): Promise<OpenShellSandboxBackend> {
if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) {
throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds.");
}
const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey);
const execContext: OpenShellExecContext = {
config: params.pluginConfig,
sandboxName,
};
const impl = new OpenShellSandboxBackendImpl({
createParams: params.createParams,
execContext,
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
});
return {
id: "openshell",
runtimeId: sandboxName,
runtimeLabel: sandboxName,
workdir: params.pluginConfig.remoteWorkspaceDir,
env: params.createParams.cfg.docker.env,
mode: params.pluginConfig.mode,
configLabel: params.pluginConfig.from,
configLabelKind: "Source",
buildExecSpec: async ({ command, workdir, env, usePty }) => {
const pending = await impl.prepareExec({ command, workdir, env, usePty });
return {
argv: pending.argv,
env: process.env,
stdinMode: "pipe-open",
finalizeToken: pending.token,
};
},
finalizeExec: async ({ token }) => {
await impl.finalizeExec(token as PendingExec | undefined);
},
runShellCommand: async (command) => await impl.runRemoteShellScript(command),
createFsBridge: ({ sandbox }) =>
params.pluginConfig.mode === "remote"
? createOpenShellRemoteFsBridge({
sandbox,
backend: impl.asHandle(),
})
: createOpenShellFsBridge({
sandbox,
backend: impl.asHandle(),
}),
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command),
syncLocalPathToRemote: async (localPath, remotePath) =>
await impl.syncLocalPathToRemote(localPath, remotePath),
};
}
class OpenShellSandboxBackendImpl {
private ensurePromise: Promise<void> | null = null;
private remoteSeedPending = false;
constructor(
private readonly params: {
createParams: CreateSandboxBackendParams;
execContext: OpenShellExecContext;
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
},
) {}
asHandle(): OpenShellSandboxBackend {
const self = this;
return {
id: "openshell",
runtimeId: this.params.execContext.sandboxName,
runtimeLabel: this.params.execContext.sandboxName,
workdir: this.params.remoteWorkspaceDir,
env: this.params.createParams.cfg.docker.env,
mode: this.params.execContext.config.mode,
configLabel: this.params.execContext.config.from,
configLabelKind: "Source",
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
buildExecSpec: async ({ command, workdir, env, usePty }) => {
const pending = await self.prepareExec({ command, workdir, env, usePty });
return {
argv: pending.argv,
env: process.env,
stdinMode: "pipe-open",
finalizeToken: pending.token,
};
},
finalizeExec: async ({ token }) => {
await self.finalizeExec(token as PendingExec | undefined);
},
runShellCommand: async (command) => await self.runRemoteShellScript(command),
createFsBridge: ({ sandbox }) =>
this.params.execContext.config.mode === "remote"
? createOpenShellRemoteFsBridge({
sandbox,
backend: self.asHandle(),
})
: createOpenShellFsBridge({
sandbox,
backend: self.asHandle(),
}),
runRemoteShellScript: async (command) => await self.runRemoteShellScript(command),
syncLocalPathToRemote: async (localPath, remotePath) =>
await self.syncLocalPathToRemote(localPath, remotePath),
};
}
async prepareExec(params: {
command: string;
workdir?: string;
env: Record<string, string>;
usePty: boolean;
}): Promise<{ argv: string[]; token: PendingExec }> {
await this.ensureSandboxExists();
if (this.params.execContext.config.mode === "mirror") {
await this.syncWorkspaceToRemote();
} else {
await this.maybeSeedRemoteWorkspace();
}
const sshSession = await createOpenShellSshSession({
context: this.params.execContext,
});
const remoteCommand = buildExecRemoteCommand({
command: params.command,
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
env: params.env,
});
return {
argv: [
"ssh",
"-F",
sshSession.configPath,
...(params.usePty
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
: ["-T", "-o", "RequestTTY=no"]),
sshSession.host,
remoteCommand,
],
token: { sshSession },
};
}
async finalizeExec(token?: PendingExec): Promise<void> {
try {
if (this.params.execContext.config.mode === "mirror") {
await this.syncWorkspaceFromRemote();
}
} finally {
if (token?.sshSession) {
await disposeOpenShellSshSession(token.sshSession);
}
}
}
async runRemoteShellScript(
params: SandboxBackendCommandParams,
): Promise<SandboxBackendCommandResult> {
await this.ensureSandboxExists();
await this.maybeSeedRemoteWorkspace();
return await this.runRemoteShellScriptInternal(params);
}
private async runRemoteShellScriptInternal(
params: SandboxBackendCommandParams,
): Promise<SandboxBackendCommandResult> {
const session = await createOpenShellSshSession({
context: this.params.execContext,
});
try {
return await runOpenShellSshCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
params.script,
"openclaw-openshell-fs",
...(params.args ?? []),
]),
stdin: params.stdin,
allowFailure: params.allowFailure,
signal: params.signal,
});
} finally {
await disposeOpenShellSshSession(session);
}
}
async syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void> {
await this.ensureSandboxExists();
await this.maybeSeedRemoteWorkspace();
const stats = await fs.lstat(localPath).catch(() => null);
if (!stats) {
await this.runRemoteShellScript({
script: 'rm -rf -- "$1"',
args: [remotePath],
allowFailure: true,
});
return;
}
if (stats.isDirectory()) {
await this.runRemoteShellScript({
script: 'mkdir -p -- "$1"',
args: [remotePath],
});
return;
}
await this.runRemoteShellScript({
script: 'mkdir -p -- "$(dirname -- "$1")"',
args: [remotePath],
});
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"upload",
"--no-git-ignore",
this.params.execContext.sandboxName,
localPath,
path.posix.dirname(remotePath),
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
}
}
private async ensureSandboxExists(): Promise<void> {
if (this.ensurePromise) {
return await this.ensurePromise;
}
this.ensurePromise = this.ensureSandboxExistsInner();
try {
await this.ensurePromise;
} catch (error) {
this.ensurePromise = null;
throw error;
}
}
private async ensureSandboxExistsInner(): Promise<void> {
const getResult = await runOpenShellCli({
context: this.params.execContext,
args: ["sandbox", "get", this.params.execContext.sandboxName],
cwd: this.params.createParams.workspaceDir,
});
if (getResult.code === 0) {
return;
}
const createArgs = [
"sandbox",
"create",
"--name",
this.params.execContext.sandboxName,
"--from",
this.params.execContext.config.from,
...(this.params.execContext.config.policy
? ["--policy", this.params.execContext.config.policy]
: []),
...(this.params.execContext.config.gpu ? ["--gpu"] : []),
...(this.params.execContext.config.autoProviders
? ["--auto-providers"]
: ["--no-auto-providers"]),
...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
"--",
"true",
];
const createResult = await runOpenShellCli({
context: this.params.execContext,
args: createArgs,
cwd: this.params.createParams.workspaceDir,
timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 300_000),
});
if (createResult.code !== 0) {
throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
}
this.remoteSeedPending = true;
}
private async syncWorkspaceToRemote(): Promise<void> {
await this.runRemoteShellScriptInternal({
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
args: [this.params.remoteWorkspaceDir],
});
await this.uploadPathToRemote(
this.params.createParams.workspaceDir,
this.params.remoteWorkspaceDir,
);
if (
this.params.createParams.cfg.workspaceAccess !== "none" &&
path.resolve(this.params.createParams.agentWorkspaceDir) !==
path.resolve(this.params.createParams.workspaceDir)
) {
await this.runRemoteShellScriptInternal({
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
args: [this.params.remoteAgentWorkspaceDir],
});
await this.uploadPathToRemote(
this.params.createParams.agentWorkspaceDir,
this.params.remoteAgentWorkspaceDir,
);
}
}
private async syncWorkspaceFromRemote(): Promise<void> {
const tmpDir = await fs.mkdtemp(
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"),
);
try {
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"download",
this.params.execContext.sandboxName,
this.params.remoteWorkspaceDir,
tmpDir,
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox download failed");
}
await replaceDirectoryContents({
sourceDir: tmpDir,
targetDir: this.params.createParams.workspaceDir,
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
private async uploadPathToRemote(localPath: string, remotePath: string): Promise<void> {
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"upload",
"--no-git-ignore",
this.params.execContext.sandboxName,
localPath,
remotePath,
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
}
}
private async maybeSeedRemoteWorkspace(): Promise<void> {
if (!this.remoteSeedPending) {
return;
}
this.remoteSeedPending = false;
try {
await this.syncWorkspaceToRemote();
} catch (error) {
this.remoteSeedPending = true;
throw error;
}
}
}
function resolveOpenShellPluginConfigFromConfig(
config: OpenClawConfig,
fallback: ResolvedOpenShellPluginConfig,
): ResolvedOpenShellPluginConfig {
const pluginConfig = config.plugins?.entries?.openshell?.config;
if (!pluginConfig) {
return fallback;
}
return resolveOpenShellPluginConfig(pluginConfig);
}
function buildOpenShellSandboxName(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
const safe = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
const hash = Array.from(trimmed).reduce(
(acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0,
5381,
);
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
}
function resolveOpenShellTmpRoot(): string {
return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir());
}

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildExecRemoteCommand, buildOpenShellBaseArgv, shellEscape } from "./cli.js";
import { resolveOpenShellPluginConfig } from "./config.js";
describe("openshell cli helpers", () => {
it("builds base argv with gateway overrides", () => {
const config = resolveOpenShellPluginConfig({
command: "/usr/local/bin/openshell",
gateway: "lab",
gatewayEndpoint: "https://lab.example",
});
expect(buildOpenShellBaseArgv(config)).toEqual([
"/usr/local/bin/openshell",
"--gateway",
"lab",
"--gateway-endpoint",
"https://lab.example",
]);
});
it("shell escapes single quotes", () => {
expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`);
});
it("wraps exec commands with env and workdir", () => {
const command = buildExecRemoteCommand({
command: "pwd && printenv TOKEN",
workdir: "/sandbox/project",
env: {
TOKEN: "abc 123",
},
});
expect(command).toContain(`'env'`);
expect(command).toContain(`'TOKEN=abc 123'`);
expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`);
});
});

View File

@ -0,0 +1,166 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
} from "openclaw/plugin-sdk/core";
import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core";
import type { ResolvedOpenShellPluginConfig } from "./config.js";
export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig;
sandboxName: string;
timeoutMs?: number;
};
export type OpenShellSshSession = {
configPath: string;
host: string;
};
export type OpenShellRunSshCommandParams = {
session: OpenShellSshSession;
remoteCommand: string;
stdin?: Buffer | string;
allowFailure?: boolean;
signal?: AbortSignal;
tty?: boolean;
};
export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] {
const argv = [config.command];
if (config.gateway) {
argv.push("--gateway", config.gateway);
}
if (config.gatewayEndpoint) {
argv.push("--gateway-endpoint", config.gatewayEndpoint);
}
return argv;
}
export function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
export function buildRemoteCommand(argv: string[]): string {
return argv.map((entry) => shellEscape(entry)).join(" ");
}
export async function runOpenShellCli(params: {
context: OpenShellExecContext;
args: string[];
cwd?: string;
timeoutMs?: number;
}): Promise<{ code: number; stdout: string; stderr: string }> {
return await runPluginCommandWithTimeout({
argv: [...buildOpenShellBaseArgv(params.context.config), ...params.args],
cwd: params.cwd,
timeoutMs: params.timeoutMs ?? params.context.timeoutMs ?? params.context.config.timeoutMs,
env: process.env,
});
}
export async function createOpenShellSshSession(params: {
context: OpenShellExecContext;
}): Promise<OpenShellSshSession> {
const result = await runOpenShellCli({
context: params.context,
args: ["sandbox", "ssh-config", params.context.sandboxName],
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed");
}
const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m);
const host = hostMatch?.[1]?.trim();
if (!host) {
throw new Error("Failed to parse openshell ssh-config output.");
}
const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir();
await fs.mkdir(tmpRoot, { recursive: true });
const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-"));
const configPath = path.join(configDir, "config");
await fs.writeFile(configPath, result.stdout, "utf8");
return { configPath, host };
}
export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise<void> {
await fs.rm(path.dirname(session.configPath), { recursive: true, force: true });
}
export async function runOpenShellSshCommand(
params: OpenShellRunSshCommandParams,
): Promise<SandboxBackendCommandResult> {
const argv = [
"ssh",
"-F",
params.session.configPath,
...(params.tty
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
: ["-T", "-o", "RequestTTY=no"]),
params.session.host,
params.remoteCommand,
];
const result = await new Promise<SandboxBackendCommandResult>((resolve, reject) => {
const child = spawn(argv[0]!, argv.slice(1), {
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
signal: params.signal,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.on("error", reject);
child.on("close", (code) => {
const stdout = Buffer.concat(stdoutChunks);
const stderr = Buffer.concat(stderrChunks);
const exitCode = code ?? 0;
if (exitCode !== 0 && !params.allowFailure) {
const error = Object.assign(
new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`),
{
code: exitCode,
stdout,
stderr,
},
);
reject(error);
return;
}
resolve({ stdout, stderr, code: exitCode });
});
if (params.stdin !== undefined) {
child.stdin.end(params.stdin);
return;
}
child.stdin.end();
});
return result;
}
export function buildExecRemoteCommand(params: {
command: string;
workdir?: string;
env: Record<string, string>;
}): string {
const body = params.workdir
? `cd ${shellEscape(params.workdir)} && ${params.command}`
: params.command;
const argv =
Object.keys(params.env).length > 0
? [
"env",
...Object.entries(params.env).map(([key, value]) => `${key}=${value}`),
"/bin/sh",
"-c",
body,
]
: ["/bin/sh", "-c", body];
return buildRemoteCommand(argv);
}

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveOpenShellPluginConfig } from "./config.js";
describe("openshell plugin config", () => {
it("applies defaults", () => {
expect(resolveOpenShellPluginConfig(undefined)).toEqual({
mode: "mirror",
command: "openshell",
gateway: undefined,
gatewayEndpoint: undefined,
from: "openclaw",
policy: undefined,
providers: [],
gpu: false,
autoProviders: true,
remoteWorkspaceDir: "/sandbox",
remoteAgentWorkspaceDir: "/agent",
timeoutMs: 120_000,
});
});
it("accepts remote mode", () => {
expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote");
});
it("rejects relative remote paths", () => {
expect(() =>
resolveOpenShellPluginConfig({
remoteWorkspaceDir: "sandbox",
}),
).toThrow("OpenShell remote path must be absolute");
});
it("rejects unknown mode", () => {
expect(() =>
resolveOpenShellPluginConfig({
mode: "bogus",
}),
).toThrow("mode must be one of mirror, remote");
});
});

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