Merge branch 'main' into vincentkoc-code/slack-plugin-interactive-dedupe
This commit is contained in:
commit
81c8e66f61
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
Gateway’s container naming and avoids mismatches when scope/session keys change.
|
||||
Tip: prefer `openclaw sandbox recreate` over manual backend-specific cleanup.
|
||||
It uses the Gateway’s 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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
260
docs/refactor/firecrawl-extension.md
Normal file
260
docs/refactor/firecrawl-extension.md
Normal 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
|
||||
@ -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`).
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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.*`.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "anthropic",
|
||||
"providers": ["anthropic"],
|
||||
"providerAuthEnvVars": {
|
||||
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "byteplus",
|
||||
"providers": ["byteplus", "byteplus-plan"],
|
||||
"providerAuthEnvVars": {
|
||||
"byteplus": ["BYTEPLUS_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
75
extensions/discord/src/channel.setup.ts
Normal file
75
extensions/discord/src/channel.setup.ts
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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!,
|
||||
});
|
||||
|
||||
@ -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!,
|
||||
});
|
||||
|
||||
7
extensions/feishu/src/onboarding.ts
Normal file
7
extensions/feishu/src/onboarding.ts
Normal 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!,
|
||||
});
|
||||
48
extensions/feishu/src/setup-core.ts
Normal file
48
extensions/feishu/src/setup-core.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
|
||||
100
extensions/firecrawl/index.test.ts
Normal file
100
extensions/firecrawl/index.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
20
extensions/firecrawl/index.ts
Normal file
20
extensions/firecrawl/index.ts
Normal 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;
|
||||
8
extensions/firecrawl/openclaw.plugin.json
Normal file
8
extensions/firecrawl/openclaw.plugin.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/firecrawl/package.json
Normal file
12
extensions/firecrawl/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
159
extensions/firecrawl/src/config.ts
Normal file
159
extensions/firecrawl/src/config.ts
Normal 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;
|
||||
}
|
||||
446
extensions/firecrawl/src/firecrawl-client.ts
Normal file
446
extensions/firecrawl/src/firecrawl-client.ts
Normal 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,
|
||||
};
|
||||
89
extensions/firecrawl/src/firecrawl-scrape-tool.ts
Normal file
89
extensions/firecrawl/src/firecrawl-scrape-tool.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
63
extensions/firecrawl/src/firecrawl-search-provider.ts
Normal file
63
extensions/firecrawl/src/firecrawl-search-provider.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
76
extensions/firecrawl/src/firecrawl-search-tool.ts
Normal file
76
extensions/firecrawl/src/firecrawl-search-tool.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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"];
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
@ -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";
|
||||
|
||||
@ -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 () => {
|
||||
@ -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?: {
|
||||
@ -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!,
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "huggingface",
|
||||
"providers": ["huggingface"],
|
||||
"providerAuthEnvVars": {
|
||||
"huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -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 };
|
||||
|
||||
99
extensions/imessage/src/channel.setup.ts
Normal file
99
extensions/imessage/src/channel.setup.ts
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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!,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "kilocode",
|
||||
"providers": ["kilocode"],
|
||||
"providerAuthEnvVars": {
|
||||
"kilocode": ["KILOCODE_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "kimi-coding",
|
||||
"providers": ["kimi-coding"],
|
||||
"providerAuthEnvVars": {
|
||||
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "line",
|
||||
"label": "LINE",
|
||||
|
||||
5
extensions/line/setup-entry.ts
Normal file
5
extensions/line/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { lineSetupPlugin } from "./src/channel.setup.js";
|
||||
|
||||
export default {
|
||||
plugin: lineSetupPlugin,
|
||||
};
|
||||
69
extensions/line/src/channel.setup.ts
Normal file
69
extensions/line/src/channel.setup.ts
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "mattermost",
|
||||
"label": "Mattermost",
|
||||
|
||||
5
extensions/mattermost/setup-entry.ts
Normal file
5
extensions/mattermost/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { mattermostPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: mattermostPlugin,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "mistral",
|
||||
"providers": ["mistral"],
|
||||
"providerAuthEnvVars": {
|
||||
"mistral": ["MISTRAL_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "modelstudio",
|
||||
"providers": ["modelstudio"],
|
||||
"providerAuthEnvVars": {
|
||||
"modelstudio": ["MODELSTUDIO_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "moonshot",
|
||||
"providers": ["moonshot"],
|
||||
"providerAuthEnvVars": {
|
||||
"moonshot": ["MOONSHOT_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "nostr",
|
||||
"label": "Nostr",
|
||||
|
||||
5
extensions/nostr/setup-entry.ts
Normal file
5
extensions/nostr/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { nostrPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: nostrPlugin,
|
||||
};
|
||||
@ -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),
|
||||
|
||||
67
extensions/nostr/src/setup-surface.test.ts
Normal file
67
extensions/nostr/src/setup-surface.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
297
extensions/nostr/src/setup-surface.ts
Normal file
297
extensions/nostr/src/setup-surface.ts
Normal 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 },
|
||||
}),
|
||||
};
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "nvidia",
|
||||
"providers": ["nvidia"],
|
||||
"providerAuthEnvVars": {
|
||||
"nvidia": ["NVIDIA_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "ollama",
|
||||
"providers": ["ollama"],
|
||||
"providerAuthEnvVars": {
|
||||
"ollama": ["OLLAMA_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "openai",
|
||||
"providers": ["openai", "openai-codex"],
|
||||
"providerAuthEnvVars": {
|
||||
"openai": ["OPENAI_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "opencode",
|
||||
"providers": ["opencode"],
|
||||
"providerAuthEnvVars": {
|
||||
"opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"id": "openrouter",
|
||||
"providers": ["openrouter"],
|
||||
"providerAuthEnvVars": {
|
||||
"openrouter": ["OPENROUTER_API_KEY"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
30
extensions/openshell/index.ts
Normal file
30
extensions/openshell/index.ts
Normal 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;
|
||||
99
extensions/openshell/openclaw.plugin.json
Normal file
99
extensions/openshell/openclaw.plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
extensions/openshell/package.json
Normal file
12
extensions/openshell/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
117
extensions/openshell/src/backend.test.ts
Normal file
117
extensions/openshell/src/backend.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
488
extensions/openshell/src/backend.ts
Normal file
488
extensions/openshell/src/backend.ts
Normal 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());
|
||||
}
|
||||
37
extensions/openshell/src/cli.test.ts
Normal file
37
extensions/openshell/src/cli.test.ts
Normal 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'`);
|
||||
});
|
||||
});
|
||||
166
extensions/openshell/src/cli.ts
Normal file
166
extensions/openshell/src/cli.ts
Normal 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);
|
||||
}
|
||||
41
extensions/openshell/src/config.test.ts
Normal file
41
extensions/openshell/src/config.test.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user