diff --git a/CHANGELOG.md b/CHANGELOG.md index f46e450d164..07937512400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 3368933d6c4..760704b589f 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -40,6 +40,15 @@ openclaw plugins install --link /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): diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 89e96b318a3..aae655f27b7 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -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 --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 --url ` 5. Restart gateway and send a DM to the Synology Chat bot. Minimal config: diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 654fbef5fa9..96b9ef33f8c 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -30,10 +30,11 @@ openclaw channels logs --channel all ```bash openclaw channels add --channel telegram --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: diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 8f6042e7400..f21c3930ece 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -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 `, `--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. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 90e5fa7d7a2..4718135ee68 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -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 diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 16b05baefce..d36fbde6c35 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -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). diff --git a/docs/cli/index.md b/docs/cli/index.md index fbc0bf1378f..f99b04efece 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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). diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e8e4614a9ff..5ebac698175 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -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", diff --git a/docs/cli/security.md b/docs/cli/security.md index cc705b31a30..76a7ae75976 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -19,6 +19,8 @@ Related: ```bash openclaw security audit openclaw security audit --deep +openclaw security audit --deep --password +openclaw security audit --deep --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: diff --git a/docs/cli/status.md b/docs/cli/status.md index 856c341b036..770bf6ab50d 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -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. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 23fe7edcd1d..aa4b90fd41f 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -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`, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 78e58edc085..951f99f1165 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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 +**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 +Browser sandboxing and `sandbox.docker.binds` are currently Docker-only. + Build images: ```bash diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index d62af2f4f7d..db40b802832 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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 diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 93cd508d4f1..379e4a527d4 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -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: diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 9c266744b71..01d5e0d3578 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` diff --git a/docs/refactor/firecrawl-extension.md b/docs/refactor/firecrawl-extension.md new file mode 100644 index 00000000000..e25e010e7b1 --- /dev/null +++ b/docs/refactor/firecrawl-extension.md @@ -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 diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index 2cd90a06bf5..901890dfb0a 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -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`). diff --git a/docs/tools/index.md b/docs/tools/index.md index bdd9b78456f..dbca6cd26bf 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 3987ff6a7eb..c39401bebfc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/docs/tools/web.md b/docs/tools/web.md index a2aa1d37bfd..7cc67c07710 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -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 diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 5342e849e52..aec972801f8 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "anthropic", "providers": ["anthropic"], + "providerAuthEnvVars": { + "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 930fa29a64e..bea84e6cd2f 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -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, diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts index bc9c93735b7..5093c757b06 100644 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -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[0]["plugin"]; - return buildChannelOnboardingAdapterFromSetupWizard({ + } as Parameters[0]["plugin"]; + return buildChannelSetupFlowAdapterFromSetupWizard({ plugin, wizard: blueBubblesSetupWizard, }); diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index f4ee2d98db4..a331aec7d43 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -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 { - 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", diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index 8885280bf32..abef4351a48 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "byteplus", "providers": ["byteplus", "byteplus-plan"], + "providerAuthEnvVars": { + "byteplus": ["BYTEPLUS_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json index fc7a41f77bb..ca7810e1fd2 100644 --- a/extensions/cloudflare-ai-gateway/openclaw.plugin.json +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -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, diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index 56673347d64..329a9376c9f 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -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 }; diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts new file mode 100644 index 00000000000..ac79acf443e --- /dev/null +++ b/extensions/discord/src/channel.setup.ts @@ -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 = { + 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, +}; diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index cec63dd01ec..f130888cc2b 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -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 }; }) => { 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; } diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 610b79a5efa..36382eae756 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -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 { - 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), }; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index b5757419b7b..a9aed9f870d 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -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"; diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/onboarding.status.test.ts index 4f3b853a1e2..94488a72bfa 100644 --- a/extensions/feishu/src/onboarding.status.test.ts +++ b/extensions/feishu/src/onboarding.status.test.ts @@ -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!, }); diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index 2a444964442..f46aef482ba 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -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!, }); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 00000000000..ae247b30f76 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -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!, +}); diff --git a/extensions/feishu/src/setup-core.ts b/extensions/feishu/src/setup-core.ts new file mode 100644 index 00000000000..ada8ef79933 --- /dev/null +++ b/extensions/feishu/src/setup-core.ts @@ -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); + }, +}; diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 1191a08e4e9..1c0f966e01e 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -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>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; }): Promise { 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); } diff --git a/extensions/firecrawl/index.test.ts b/extensions/firecrawl/index.test.ts new file mode 100644 index 00000000000..084d3c0c055 --- /dev/null +++ b/extensions/firecrawl/index.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/extensions/firecrawl/index.ts b/extensions/firecrawl/index.ts new file mode 100644 index 00000000000..42bd1a3252f --- /dev/null +++ b/extensions/firecrawl/index.ts @@ -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; diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json new file mode 100644 index 00000000000..52289f0711a --- /dev/null +++ b/extensions/firecrawl/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "firecrawl", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/firecrawl/package.json b/extensions/firecrawl/package.json new file mode 100644 index 00000000000..e891b8293ba --- /dev/null +++ b/extensions/firecrawl/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/firecrawl-plugin", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Firecrawl plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts new file mode 100644 index 00000000000..808b81891f1 --- /dev/null +++ b/extensions/firecrawl/src/config.ts @@ -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["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +type WebFetchConfig = NonNullable["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; +} diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts new file mode 100644 index 00000000000..2929f2f9dde --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -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; expiresAt: number; insertedAt: number } +>(); +const SCRAPE_CACHE = new Map< + string, + { value: Record; 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; + timeoutSeconds: number; + errorLabel: string; +}): Promise> { + 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; + 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): 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; + const metadata = + record.metadata && typeof record.metadata === "object" + ? (record.metadata as Record) + : 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 { + 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> { + 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 = { + 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): Record { + const data = payload.data; + if (data && typeof data === "object") { + return data as Record; + } + return {}; +} + +export function parseFirecrawlScrapePayload(params: { + payload: Record; + url: string; + extractMode: "markdown" | "text"; + maxChars: number; +}): Record { + const data = resolveScrapeData(params.payload); + const metadata = + data.metadata && typeof data.metadata === "object" + ? (data.metadata as Record) + : 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> { + 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, +}; diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.ts new file mode 100644 index 00000000000..509b3d5fbd6 --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.ts @@ -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) => { + 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, + }), + ); + }, + }; +} diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts new file mode 100644 index 00000000000..60489e9618e --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -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): unknown { + const scoped = searchConfig?.firecrawl; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined; + } + return (scoped as Record).apiKey; +} + +function setScopedCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + const scoped = searchConfigTarget.firecrawl; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + searchConfigTarget.firecrawl = { apiKey: value }; + return; + } + (scoped as Record).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, + }), + }), + }; +} diff --git a/extensions/firecrawl/src/firecrawl-search-tool.ts b/extensions/firecrawl/src/firecrawl-search-tool.ts new file mode 100644 index 00000000000..f2f133fd7ec --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-tool.ts @@ -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) => { + 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, + }), + ); + }, + }; +} diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 19114472830..038ed70aec9 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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"]; diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index ec3f8690eee..a6cb5b7f4b5 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -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, diff --git a/src/providers/github-copilot-token.test.ts b/extensions/github-copilot/token.test.ts similarity index 91% rename from src/providers/github-copilot-token.test.ts rename to extensions/github-copilot/token.test.ts index 4f7664364a0..8aa489e7a8b 100644 --- a/src/providers/github-copilot-token.test.ts +++ b/extensions/github-copilot/token.test.ts @@ -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", diff --git a/src/providers/github-copilot-token.ts b/extensions/github-copilot/token.ts similarity index 97% rename from src/providers/github-copilot-token.ts rename to extensions/github-copilot/token.ts index a5d9a6b1e8e..afb1eb03b61 100644 --- a/src/providers/github-copilot-token.ts +++ b/extensions/github-copilot/token.ts @@ -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"; diff --git a/src/infra/provider-usage.fetch.copilot.test.ts b/extensions/github-copilot/usage.test.ts similarity index 93% rename from src/infra/provider-usage.fetch.copilot.test.ts rename to extensions/github-copilot/usage.test.ts index 0abfd5f782f..b4044c7f5f9 100644 --- a/src/infra/provider-usage.fetch.copilot.test.ts +++ b/extensions/github-copilot/usage.test.ts @@ -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 () => { diff --git a/src/infra/provider-usage.fetch.copilot.ts b/extensions/github-copilot/usage.ts similarity index 83% rename from src/infra/provider-usage.fetch.copilot.ts rename to extensions/github-copilot/usage.ts index 40d4adcd3aa..9035027890c 100644 --- a/src/infra/provider-usage.fetch.copilot.ts +++ b/extensions/github-copilot/usage.ts @@ -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?: { diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts index ab09435f67e..4be1a1bbff0 100644 --- a/extensions/googlechat/src/setup-surface.test.ts +++ b/extensions/googlechat/src/setup-surface.test.ts @@ -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 { }; } -const googlechatConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const googlechatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: googlechatPlugin, wizard: googlechatPlugin.setupWizard!, }); diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 64fe7837fa3..9b18d2fad4f 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -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>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; }): Promise { 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", diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index 4b68bcedb26..67a34124d0a 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "huggingface", "providers": ["huggingface"], + "providerAuthEnvVars": { + "huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts index 4b0cc6203e2..6b4c642d0ae 100644 --- a/extensions/imessage/setup-entry.ts +++ b/extensions/imessage/setup-entry.ts @@ -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 }; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts new file mode 100644 index 00000000000..075e50f0dda --- /dev/null +++ b/extensions/imessage/src/channel.setup.ts @@ -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 = { + 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, +}; diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 69a8072bd59..0beb217f305 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -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; } diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 90fcf648e60..722cdb172c4 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -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 }; diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index 38738d1e484..883f15fe1b1 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -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 { }; } -const ircConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const ircConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: ircPlugin, wizard: ircPlugin.setupWizard!, }); diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 45f9041f973..d1603dee476 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -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, diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index 63a7bec920b..bde9f603593 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -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 }; diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index ec078c33ab7..6e3e39aec27 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "kilocode", "providers": ["kilocode"], + "providerAuthEnvVars": { + "kilocode": ["KILOCODE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index 8874fb6501b..0664e7ae6df 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "kimi-coding", "providers": ["kimi-coding"], + "providerAuthEnvVars": { + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/line/package.json b/extensions/line/package.json index 85bfac7f0ac..3fa098460d6 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -8,6 +8,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "line", "label": "LINE", diff --git a/extensions/line/setup-entry.ts b/extensions/line/setup-entry.ts new file mode 100644 index 00000000000..ca25d243155 --- /dev/null +++ b/extensions/line/setup-entry.ts @@ -0,0 +1,5 @@ +import { lineSetupPlugin } from "./src/channel.setup.js"; + +export default { + plugin: lineSetupPlugin, +}; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts new file mode 100644 index 00000000000..71a1d87c45d --- /dev/null +++ b/extensions/line/src/channel.setup.ts @@ -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 = { + 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, +}; diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index 9fbddc19675..01a3024fc3a 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -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 }; } -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[0]["plugin"], + } as Parameters[0]["plugin"], wizard: lineSetupWizard, }); diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 37167723cf7..705c89a44f9 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -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), }; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index e01e0d57750..0dcff40fb38 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -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; +}): Promise { + 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 = { + 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: { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 17f8add1b1f..3c414f52f29 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "mattermost", "label": "Mattermost", diff --git a/extensions/mattermost/setup-entry.ts b/extensions/mattermost/setup-entry.ts new file mode 100644 index 00000000000..64c02fcbe9d --- /dev/null +++ b/extensions/mattermost/setup-entry.ts @@ -0,0 +1,5 @@ +import { mattermostPlugin } from "./src/channel.js"; + +export default { + plugin: mattermostPlugin, +}; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index 2877541bba9..13b69542d02 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -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, diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 969868986f0..e99f5bf15b2 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -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), }, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 32d8be58bf5..8934580b36b 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -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, diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index dd38282811b..480c09417d0 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "mistral", "providers": ["mistral"], + "providerAuthEnvVars": { + "mistral": ["MISTRAL_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/modelstudio/openclaw.plugin.json b/extensions/modelstudio/openclaw.plugin.json index 1a8d9e71c75..5cc87ad1b54 100644 --- a/extensions/modelstudio/openclaw.plugin.json +++ b/extensions/modelstudio/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "modelstudio", "providers": ["modelstudio"], + "providerAuthEnvVars": { + "modelstudio": ["MODELSTUDIO_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index e02cb3d21c5..542ae46fead 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "moonshot", "providers": ["moonshot"], + "providerAuthEnvVars": { + "moonshot": ["MOONSHOT_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index f8db90e5079..8336e0ae976 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -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; +}): Promise> { + 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 = { + 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: { diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 9deafc5f71a..61ef7e47a85 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -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 { - 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", diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 4fcb874b5d3..64c0fc5a7a1 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -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 { - 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 }; diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 19ef7cc03e7..991bd54f3d4 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "nostr", "label": "Nostr", diff --git a/extensions/nostr/setup-entry.ts b/extensions/nostr/setup-entry.ts new file mode 100644 index 00000000000..8884a71cc80 --- /dev/null +++ b/extensions/nostr/setup-entry.ts @@ -0,0 +1,5 @@ +import { nostrPlugin } from "./src/channel.js"; + +export default { + plugin: nostrPlugin, +}; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 937c698bd47..21dfce3a9da 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -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 = { }, reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), + setup: nostrSetupAdapter, + setupWizard: nostrSetupWizard, config: { listAccountIds: (cfg) => listNostrAccountIds(cfg), diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts new file mode 100644 index 00000000000..0bd1b3f29a3 --- /dev/null +++ b/extensions/nostr/src/setup-surface.test.ts @@ -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 { + 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", + ]); + }); +}); diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts new file mode 100644 index 00000000000..800b2705258 --- /dev/null +++ b/extensions/nostr/src/setup-surface.ts @@ -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; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const existing = (params.cfg.channels?.nostr ?? {}) as Record; + 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 { + 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 }, + }), +}; diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index 268bfa2dafd..3b46534911b 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "nvidia", "providers": ["nvidia"], + "providerAuthEnvVars": { + "nvidia": ["NVIDIA_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 3df1002d1ac..b644e105b84 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "ollama", "providers": ["ollama"], + "providerAuthEnvVars": { + "ollama": ["OLLAMA_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 480e80a59ce..4b0ae0efc31 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "openai", "providers": ["openai", "openai-codex"], + "providerAuthEnvVars": { + "openai": ["OPENAI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 09d48bcf314..d264f4acdb6 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -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, diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index f61e9b99b67..68608e6abd1 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "opencode", "providers": ["opencode"], + "providerAuthEnvVars": { + "opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index 7e7840cb1c9..84069b8129b 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "openrouter", "providers": ["openrouter"], + "providerAuthEnvVars": { + "openrouter": ["OPENROUTER_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts new file mode 100644 index 00000000000..910abe31b44 --- /dev/null +++ b/extensions/openshell/index.ts @@ -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; diff --git a/extensions/openshell/openclaw.plugin.json b/extensions/openshell/openclaw.plugin.json new file mode 100644 index 00000000000..cf3f9ad5579 --- /dev/null +++ b/extensions/openshell/openclaw.plugin.json @@ -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 + } + } +} diff --git a/extensions/openshell/package.json b/extensions/openshell/package.json new file mode 100644 index 00000000000..464c749ea34 --- /dev/null +++ b/extensions/openshell/package.json @@ -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" + ] + } +} diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts new file mode 100644 index 00000000000..2999599c648 --- /dev/null +++ b/extensions/openshell/src/backend.test.ts @@ -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(); + 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"], + }); + }); +}); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts new file mode 100644 index 00000000000..85c3d415904 --- /dev/null +++ b/extensions/openshell/src/backend.ts @@ -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; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; +}; + +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 { + 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 | 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; + 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 { + try { + if (this.params.execContext.config.mode === "mirror") { + await this.syncWorkspaceFromRemote(); + } + } finally { + if (token?.sshSession) { + await disposeOpenShellSshSession(token.sshSession); + } + } + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureSandboxExists(); + await this.maybeSeedRemoteWorkspace(); + return await this.runRemoteShellScriptInternal(params); + } + + private async runRemoteShellScriptInternal( + params: SandboxBackendCommandParams, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); +} diff --git a/extensions/openshell/src/cli.test.ts b/extensions/openshell/src/cli.test.ts new file mode 100644 index 00000000000..d039a571ebc --- /dev/null +++ b/extensions/openshell/src/cli.test.ts @@ -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'`); + }); +}); diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts new file mode 100644 index 00000000000..8f9808b5164 --- /dev/null +++ b/extensions/openshell/src/cli.ts @@ -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 { + 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 { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runOpenShellSshCommand( + params: OpenShellRunSshCommandParams, +): Promise { + 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((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 { + 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); +} diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts new file mode 100644 index 00000000000..f46fec1cd46 --- /dev/null +++ b/extensions/openshell/src/config.test.ts @@ -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"); + }); +}); diff --git a/extensions/openshell/src/config.ts b/extensions/openshell/src/config.ts new file mode 100644 index 00000000000..58b40180cd9 --- /dev/null +++ b/extensions/openshell/src/config.ts @@ -0,0 +1,236 @@ +import path from "node:path"; +import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; + +export type OpenShellPluginConfig = { + mode?: string; + command?: string; + gateway?: string; + gatewayEndpoint?: string; + from?: string; + policy?: string; + providers?: string[]; + gpu?: boolean; + autoProviders?: boolean; + remoteWorkspaceDir?: string; + remoteAgentWorkspaceDir?: string; + timeoutSeconds?: number; +}; + +export type ResolvedOpenShellPluginConfig = { + mode: "mirror" | "remote"; + command: string; + gateway?: string; + gatewayEndpoint?: string; + from: string; + policy?: string; + providers: string[]; + gpu: boolean; + autoProviders: boolean; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + timeoutMs: number; +}; + +const DEFAULT_COMMAND = "openshell"; +const DEFAULT_MODE = "mirror"; +const DEFAULT_SOURCE = "openclaw"; +const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox"; +const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent"; +const DEFAULT_TIMEOUT_MS = 120_000; + +type ParseSuccess = { success: true; data?: OpenShellPluginConfig }; +type ParseFailure = { + success: false; + error: { + issues: Array<{ path: Array; message: string }>; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeProviders(value: unknown): string[] | null { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + return null; + } + const seen = new Set(); + const providers: string[] = []; + for (const entry of value) { + if (typeof entry !== "string" || !entry.trim()) { + return null; + } + const normalized = entry.trim(); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + providers.push(normalized); + } + return providers; +} + +function normalizeRemotePath(value: string | undefined, fallback: string): string { + const candidate = value ?? fallback; + const normalized = path.posix.normalize(candidate.trim() || fallback); + if (!normalized.startsWith("/")) { + throw new Error(`OpenShell remote path must be absolute: ${candidate}`); + } + return normalized; +} + +export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema { + const safeParse = (value: unknown): ParseSuccess | ParseFailure => { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!isRecord(value)) { + return { + success: false, + error: { issues: [{ path: [], message: "expected config object" }] }, + }; + } + const allowedKeys = new Set([ + "mode", + "command", + "gateway", + "gatewayEndpoint", + "from", + "policy", + "providers", + "gpu", + "autoProviders", + "remoteWorkspaceDir", + "remoteAgentWorkspaceDir", + "timeoutSeconds", + ]); + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + return { + success: false, + error: { issues: [{ path: [key], message: `unknown config key: ${key}` }] }, + }; + } + } + + const providers = normalizeProviders(value.providers); + if (providers === null) { + return { + success: false, + error: { + issues: [{ path: ["providers"], message: "providers must be an array of strings" }], + }, + }; + } + + const timeoutSeconds = value.timeoutSeconds; + if ( + timeoutSeconds !== undefined && + (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1) + ) { + return { + success: false, + error: { + issues: [{ path: ["timeoutSeconds"], message: "timeoutSeconds must be a number >= 1" }], + }, + }; + } + + for (const key of ["gpu", "autoProviders"] as const) { + const candidate = value[key]; + if (candidate !== undefined && typeof candidate !== "boolean") { + return { + success: false, + error: { issues: [{ path: [key], message: `${key} must be a boolean` }] }, + }; + } + } + + return { + success: true, + data: { + mode: trimString(value.mode), + command: trimString(value.command), + gateway: trimString(value.gateway), + gatewayEndpoint: trimString(value.gatewayEndpoint), + from: trimString(value.from), + policy: trimString(value.policy), + providers, + gpu: value.gpu as boolean | undefined, + autoProviders: value.autoProviders as boolean | undefined, + remoteWorkspaceDir: trimString(value.remoteWorkspaceDir), + remoteAgentWorkspaceDir: trimString(value.remoteAgentWorkspaceDir), + timeoutSeconds: timeoutSeconds as number | undefined, + }, + }; + }; + + return { + safeParse, + jsonSchema: { + type: "object", + additionalProperties: false, + properties: { + command: { type: "string" }, + mode: { type: "string", enum: ["mirror", "remote"] }, + 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 }, + }, + }, + }; +} + +export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig { + const parsed = createOpenShellPluginConfigSchema().safeParse?.(value); + if (!parsed || !parsed.success) { + const issues = parsed && !parsed.success ? parsed.error?.issues : undefined; + const message = + issues?.map((issue: { message: string }) => issue.message).join(", ") || "invalid config"; + throw new Error(`Invalid openshell plugin config: ${message}`); + } + const raw = parsed.data ?? {}; + const cfg = (raw ?? {}) as OpenShellPluginConfig; + const mode = cfg.mode ?? DEFAULT_MODE; + if (mode !== "mirror" && mode !== "remote") { + throw new Error(`Invalid openshell plugin config: mode must be one of mirror, remote`); + } + return { + mode, + command: cfg.command ?? DEFAULT_COMMAND, + gateway: cfg.gateway, + gatewayEndpoint: cfg.gatewayEndpoint, + from: cfg.from ?? DEFAULT_SOURCE, + policy: cfg.policy, + providers: cfg.providers ?? [], + gpu: cfg.gpu ?? false, + autoProviders: cfg.autoProviders ?? true, + remoteWorkspaceDir: normalizeRemotePath(cfg.remoteWorkspaceDir, DEFAULT_REMOTE_WORKSPACE_DIR), + remoteAgentWorkspaceDir: normalizeRemotePath( + cfg.remoteAgentWorkspaceDir, + DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, + ), + timeoutMs: + typeof cfg.timeoutSeconds === "number" + ? Math.floor(cfg.timeoutSeconds * 1000) + : DEFAULT_TIMEOUT_MS, + }; +} diff --git a/extensions/openshell/src/fs-bridge.test.ts b/extensions/openshell/src/fs-bridge.test.ts new file mode 100644 index 00000000000..67a3edc5bcc --- /dev/null +++ b/extensions/openshell/src/fs-bridge.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-fs-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function createBackendMock(): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn().mockResolvedValue({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +describe("openshell fs bridge", () => { + it("writes locally and syncs the file to the remote workspace", async () => { + const workspaceDir = await makeTempDir(); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello"); + expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith( + path.join(workspaceDir, "nested", "file.txt"), + "/sandbox/nested/file.txt", + ); + }); + + it("maps agent mount paths when the sandbox workspace is read-only", async () => { + const workspaceDir = await makeTempDir(); + const agentWorkspaceDir = await makeTempDir(); + await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8"); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir, + workspaceAccess: "ro", + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" }); + expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); + expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); + }); +}); diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts new file mode 100644 index 00000000000..00257e81be4 --- /dev/null +++ b/extensions/openshell/src/fs-bridge.ts @@ -0,0 +1,355 @@ +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { movePathWithCopyFallback } from "./mirror.js"; + +type ResolvedMountPath = SandboxResolvedPath & { + mountHostRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellFsBridge(params.sandbox, params.backend); +} + +class OpenShellFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + hostPath: target.hostPath, + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return await fsPromises.readFile(hostPath); + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); + this.ensureWritable(target, "write files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + const parentDir = path.dirname(hostPath); + if (params.mkdir !== false) { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + const tempPath = path.join( + parentDir, + `.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`, + ); + await fsPromises.writeFile(tempPath, buffer); + await fsPromises.rename(tempPath, hostPath); + await this.backend.syncLocalPathToRemote(hostPath, target.containerPath); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); + this.ensureWritable(target, "create directories"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(hostPath, { recursive: true }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [target.containerPath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); + this.ensureWritable(target, "remove files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: params.force !== false, + allowFinalSymlinkForUnlink: true, + }); + await fsPromises.rm(hostPath, { + recursive: params.recursive ?? false, + force: params.force !== false, + }); + await this.backend.runRemoteShellScript({ + script: params.recursive + ? 'rm -rf -- "$1"' + : 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi', + args: [target.containerPath], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + const fromHostPath = this.requireHostPath(from); + const toHostPath = this.requireHostPath(to); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + await assertLocalPathSafety({ + target: from, + root: from.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: true, + }); + await assertLocalPathSafety({ + target: to, + root: to.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true }); + await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', + args: [from.containerPath, to.containerPath], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); + const stats = await fsPromises.lstat(hostPath).catch(() => null); + if (!stats) { + return null; + } + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return { + type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", + size: stats.size, + mtimeMs: stats.mtimeMs, + }; + } + + private ensureWritable(target: ResolvedMountPath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private requireHostPath(target: ResolvedMountPath): string { + if (!target.hostPath) { + throw new Error( + `OpenShell mirror bridge requires a local host path: ${target.containerPath}`, + ); + } + return target.hostPath; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot; + const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace( + /\\/g, + "/", + ); + const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/"); + const input = params.filePath.trim(); + + if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) { + const relative = path.posix.relative(workspaceContainerRoot, input) || ""; + const hostPath = relative + ? path.resolve(workspaceRoot, ...relative.split("/")) + : workspaceRoot; + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if ( + hasAgentMount && + (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot) + ) { + const relative = path.posix.relative(agentContainerRoot, input) || ""; + const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot; + return { + hostPath, + relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input); + + if (isPathInside(workspaceRoot, hostPath)) { + const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if (hasAgentMount && isPathInside(agentRoot, hostPath)) { + const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`); + } +} + +function isPathInside(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function assertLocalPathSafety(params: { + target: ResolvedMountPath; + root: string; + allowMissingLeaf: boolean; + allowFinalSymlinkForUnlink: boolean; +}): Promise { + if (!params.target.hostPath) { + throw new Error(`Missing local host path for ${params.target.containerPath}`); + } + const canonicalRoot = await fsPromises + .realpath(params.root) + .catch(() => path.resolve(params.root)); + const candidate = await resolveCanonicalCandidate(params.target.hostPath); + if (!isPathInside(canonicalRoot, candidate)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`, + ); + } + + const relative = path.relative(params.root, params.target.hostPath); + const segments = relative + .split(path.sep) + .filter(Boolean) + .slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length)); + let cursor = params.root; + for (let index = 0; index < segments.length; index += 1) { + cursor = path.join(cursor, segments[index]!); + const stats = await fsPromises.lstat(cursor).catch(() => null); + if (!stats) { + if (index === segments.length - 1 && params.allowMissingLeaf) { + return; + } + continue; + } + const isFinal = index === segments.length - 1; + if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) { + throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`); + } + } +} + +async function resolveCanonicalCandidate(targetPath: string): Promise { + const missing: string[] = []; + let cursor = path.resolve(targetPath); + while (true) { + const exists = await fsPromises + .lstat(cursor) + .then(() => true) + .catch(() => false); + if (exists) { + const canonical = await fsPromises.realpath(cursor).catch(() => cursor); + return path.resolve(canonical, ...missing); + } + const parent = path.dirname(cursor); + if (parent === cursor) { + return path.resolve(cursor, ...missing); + } + missing.unshift(path.basename(cursor)); + cursor = parent; + } +} diff --git a/extensions/openshell/src/mirror.ts b/extensions/openshell/src/mirror.ts new file mode 100644 index 00000000000..ee5024850d6 --- /dev/null +++ b/extensions/openshell/src/mirror.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function replaceDirectoryContents(params: { + sourceDir: string; + targetDir: string; +}): Promise { + await fs.mkdir(params.targetDir, { recursive: true }); + const existing = await fs.readdir(params.targetDir); + await Promise.all( + existing.map((entry) => + fs.rm(path.join(params.targetDir, entry), { + recursive: true, + force: true, + }), + ), + ); + const sourceEntries = await fs.readdir(params.sourceDir); + for (const entry of sourceEntries) { + await fs.cp(path.join(params.sourceDir, entry), path.join(params.targetDir, entry), { + recursive: true, + force: true, + dereference: false, + }); + } +} + +export async function movePathWithCopyFallback(params: { + from: string; + to: string; +}): Promise { + try { + await fs.rename(params.from, params.to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code !== "EXDEV") { + throw error; + } + } + await fs.cp(params.from, params.to, { + recursive: true, + force: true, + dereference: false, + }); + await fs.rm(params.from, { recursive: true, force: true }); +} diff --git a/extensions/openshell/src/remote-fs-bridge.test.ts b/extensions/openshell/src/remote-fs-bridge.test.ts new file mode 100644 index 00000000000..5a245e1d8fb --- /dev/null +++ b/extensions/openshell/src/remote-fs-bridge.test.ts @@ -0,0 +1,191 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function translateRemotePath(value: string, roots: { workspace: string; agent: string }) { + if (value === "/sandbox" || value.startsWith("/sandbox/")) { + return path.join(roots.workspace, value.slice("/sandbox".length)); + } + if (value === "/agent" || value.startsWith("/agent/")) { + return path.join(roots.agent, value.slice("/agent".length)); + } + return value; +} + +async function runLocalShell(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + roots: { workspace: string; agent: string }; +}) { + const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots)); + const script = normalizeScriptForLocalShell(params.script); + const result = await new Promise<{ stdout: Buffer; stderr: Buffer; code: number }>( + (resolve, reject) => { + const child = spawn("/bin/sh", ["-c", script, "openshell-test", ...translatedArgs], { + stdio: ["pipe", "pipe", "pipe"], + }); + 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 result = { + stdout: Buffer.concat(stdoutChunks), + stderr: Buffer.concat(stderrChunks), + code: code ?? 0, + }; + if (result.code !== 0 && !params.allowFailure) { + reject( + new Error( + result.stderr.toString("utf8").trim() || `script exited with code ${result.code}`, + ), + ); + return; + } + resolve(result); + }); + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }, + ); + return { + ...result, + stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"), + }; +} + +function createBackendMock(roots: { workspace: string; agent: string }): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + mode: "remote", + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn( + async (params) => + await runLocalShell({ + ...params, + roots, + }), + ), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) { + return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent"); +} + +function normalizeScriptForLocalShell(script: string) { + return script + .replace( + 'stats=$(stat -c "%F|%h" -- "$1")', + `stats=$(python3 - "$1" <<'PY' +import os, stat, sys +st = os.stat(sys.argv[1]) +kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other' +print(f"{kind}|{st.st_nlink}") +PY +)`, + ) + .replace( + 'stat -c "%F|%s|%Y" -- "$1"', + `python3 - "$1" <<'PY' +import os, stat, sys +st = os.stat(sys.argv[1]) +kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other' +print(f"{kind}|{st.st_size}|{int(st.st_mtime)}") +PY`, + ); +} + +describe("openshell remote fs bridge", () => { + it("writes, reads, renames, and removes files without local host paths", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-"); + const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-"); + const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-"); + const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir); + const remoteAgentRealDir = await fs.realpath(remoteAgentDir); + const backend = createBackendMock({ + workspace: remoteWorkspaceRealDir, + agent: remoteAgentRealDir, + }); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellRemoteFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe( + "hello", + ); + expect(await fs.readdir(workspaceDir)).toEqual([]); + + const resolved = bridge.resolvePath({ filePath: "nested/file.txt" }); + expect(resolved.hostPath).toBeUndefined(); + expect(resolved.containerPath).toBe("/sandbox/nested/file.txt"); + expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello")); + expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual( + expect.objectContaining({ + type: "file", + size: 5, + }), + ); + + await bridge.rename({ + from: "nested/file.txt", + to: "nested/renamed.txt", + }); + await expect( + fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"), + ).rejects.toBeDefined(); + expect( + await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), + ).toBe("hello"); + + await bridge.remove({ + filePath: "nested/renamed.txt", + }); + await expect( + fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), + ).rejects.toBeDefined(); + }); +}); diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts new file mode 100644 index 00000000000..3560fa78f28 --- /dev/null +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -0,0 +1,550 @@ +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; + +type ResolvedRemotePath = SandboxResolvedPath & { + writable: boolean; + mountRootPath: string; + source: "workspace" | "agent"; +}; + +type MountInfo = { + containerRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellRemoteFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellRemoteFsBridge(params.sandbox, params.backend); +} + +class OpenShellRemoteFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "read files", + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "read files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\ncat -- "$1"', + args: [canonical], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "write files", + requireWritable: true, + }); + await this.assertNoHardlinkedFile({ + containerPath: target.containerPath, + action: "write files", + signal: params.signal, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + await this.runMutation({ + args: [ + "write", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.mkdir !== false ? "1" : "0", + ], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); + if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, + ); + } + await this.runMutation({ + args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + if (params.force === false) { + throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); + } + return; + } + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "remove files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + await this.runMutation({ + args: [ + "remove", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.recursive ? "1" : "0", + params.force === false ? "0" : "1", + ], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + const fromPinned = await this.resolvePinnedParent({ + containerPath: from.containerPath, + action: "rename files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + const toPinned = await this.resolvePinnedParent({ + containerPath: to.containerPath, + action: "rename files", + requireWritable: true, + }); + await this.runMutation({ + args: [ + "rename", + fromPinned.mountRootPath, + fromPinned.relativeParentPath, + fromPinned.basename, + toPinned.mountRootPath, + toPinned.relativeParentPath, + toPinned.basename, + "1", + ], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + return null; + } + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "stat files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "stat files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', + args: [canonical], + signal: params.signal, + }); + const output = result.stdout.toString("utf8").trim(); + const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); + return { + type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", + size: Number(sizeRaw), + mtimeMs: Number(mtimeRaw) * 1000, + }; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir); + const mounts: MountInfo[] = [ + { + containerRoot: workspaceContainerRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + if ( + this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ) { + mounts.push({ + containerRoot: agentContainerRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + + const input = params.filePath.trim(); + const inputPosix = input.replace(/\\/g, "/"); + const maybeContainerMount = path.posix.isAbsolute(inputPosix) + ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) + : null; + if (maybeContainerMount) { + return this.toResolvedPath({ + mount: maybeContainerMount, + containerPath: normalizeContainerPath(inputPosix), + }); + } + + const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostCandidate = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(hostCwd, input); + if (isPathInside(workspaceRoot, hostCandidate)) { + const relative = toPosixRelative(workspaceRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[0]!, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + }); + } + if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { + const relative = toPosixRelative(agentRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[1], + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + }); + } + + if (params.cwd) { + const cwdPosix = params.cwd.replace(/\\/g, "/"); + if (path.posix.isAbsolute(cwdPosix)) { + const cwdContainer = normalizeContainerPath(cwdPosix); + const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); + if (cwdMount) { + return this.toResolvedPath({ + mount: cwdMount, + containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), + }); + } + } + } + + throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); + } + + private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { + const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); + if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, + ); + } + return { + relativePath: + params.mount.source === "workspace" + ? relative === "." + ? "" + : relative + : relative === "." + ? params.mount.containerRoot + : `${params.mount.containerRoot}/${relative}`, + containerPath: params.containerPath, + writable: params.mount.writable, + mountRootPath: params.mount.containerRoot, + source: params.mount.source, + }; + } + + private resolveMountByContainerPath( + mounts: MountInfo[], + containerPath: string, + ): MountInfo | null { + const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); + for (const mount of ordered) { + if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { + return mount; + } + } + return null; + } + + private ensureWritable(target: ResolvedRemotePath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { + const result = await this.runRemoteScript({ + script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + args: [containerPath], + signal, + }); + return result.stdout.toString("utf8").trim() === "1"; + } + + private async resolveCanonicalPath(params: { + containerPath: string; + action: string; + allowFinalSymlinkForUnlink?: boolean; + signal?: AbortSignal; + }): Promise { + const script = [ + "set -eu", + 'target="$1"', + 'allow_final="$2"', + 'suffix=""', + 'probe="$target"', + 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', + 'cursor="$probe"', + 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', + ' parent=$(dirname -- "$cursor")', + ' if [ "$parent" = "$cursor" ]; then break; fi', + ' base=$(basename -- "$cursor")', + ' suffix="/$base$suffix"', + ' cursor="$parent"', + "done", + 'canonical=$(readlink -f -- "$cursor")', + 'printf "%s%s\\n" "$canonical" "$suffix"', + ].join("\n"); + const result = await this.runRemoteScript({ + script, + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], + signal: params.signal, + }); + const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); + const mount = this.resolveMountByContainerPath( + [ + { + containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ...(this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ? [ + { + containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent" as const, + }, + ] + : []), + ], + canonical, + ); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return canonical; + } + + private async assertNoHardlinkedFile(params: { + containerPath: string; + action: string; + signal?: AbortSignal; + }): Promise { + const result = await this.runRemoteScript({ + script: [ + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', + 'stats=$(stat -c "%F|%h" -- "$1")', + 'printf "%s\\n" "$stats"', + ].join("\n"), + args: [params.containerPath], + signal: params.signal, + allowFailure: true, + }); + const output = result.stdout.toString("utf8").trim(); + if (!output) { + return; + } + const [kind = "", linksRaw = "1"] = output.split("|"); + if (kind === "regular file" && Number(linksRaw) > 1) { + throw new Error( + `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, + ); + } + } + + private async resolvePinnedParent(params: { + containerPath: string; + action: string; + requireWritable?: boolean; + allowFinalSymlinkForUnlink?: boolean; + }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { + const basename = path.posix.basename(params.containerPath); + if (!basename || basename === "." || basename === "/") { + throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); + } + const canonicalParent = await this.resolveCanonicalPath({ + containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), + action: params.action, + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + }); + const mount = this.resolveMountByContainerPath( + [ + { + containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ...(this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ? [ + { + containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent" as const, + }, + ] + : []), + ], + canonicalParent, + ); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + if (params.requireWritable && !mount.writable) { + throw new Error( + `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, + ); + } + const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); + if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return { + mountRootPath: mount.containerRoot, + relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, + basename, + }; + } + + private async runMutation(params: { + args: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + await this.runRemoteScript({ + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } + + private async runRemoteScript(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + return await this.backend.runRemoteShellScript({ + script: params.script, + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value.trim() || "/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isPathInsideContainerRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedCandidate = normalizeContainerPath(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function toPosixRelative(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); +} diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json index 9bd75d78c4b..5070b7a65b7 100644 --- a/extensions/qianfan/openclaw.plugin.json +++ b/extensions/qianfan/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qianfan", "providers": ["qianfan"], + "providerAuthEnvVars": { + "qianfan": ["QIANFAN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 919fa927e57..446070b0a6b 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -94,6 +94,7 @@ const qwenPortalPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/qwen", aliases: ["qwen"], + envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], catalog: { run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), }, diff --git a/extensions/qwen-portal-auth/openclaw.plugin.json b/extensions/qwen-portal-auth/openclaw.plugin.json index be200d11f04..1f5a5deb0b5 100644 --- a/extensions/qwen-portal-auth/openclaw.plugin.json +++ b/extensions/qwen-portal-auth/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qwen-portal-auth", "providers": ["qwen-portal"], + "providerAuthEnvVars": { + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index 161ea4c635a..8d5840c0fdf 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "sglang", "providers": ["sglang"], + "providerAuthEnvVars": { + "sglang": ["SGLANG_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts index afe80451845..18c27ec5a16 100644 --- a/extensions/signal/setup-entry.ts +++ b/extensions/signal/setup-entry.ts @@ -1,3 +1,3 @@ -import { signalPlugin } from "./src/channel.js"; +import { signalSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: signalPlugin }; +export default { plugin: signalSetupPlugin }; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts new file mode 100644 index 00000000000..544efa0f64f --- /dev/null +++ b/extensions/signal/src/channel.setup.ts @@ -0,0 +1,112 @@ +import { + createScopedAccountConfigAccessors, + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + listSignalAccountIds, + normalizeE164, + resolveDefaultSignalAccountId, + resolveSignalAccount, + setAccountEnabledInConfigSection, + SignalConfigSchema, + type ChannelPlugin, + type ResolvedSignalAccount, +} from "openclaw/plugin-sdk/signal"; +import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); + +export const signalSetupPlugin: ChannelPlugin = { + id: "signal", + meta: { + ...getChatChannelMeta("signal"), + }, + setupWizard: signalSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "signal", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "signal", + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "signal", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }), + }, + setup: signalSetupAdapter, +}; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 2f46c4d4c4c..1b5b00d8264 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -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, @@ -51,7 +51,7 @@ function isUuidLike(value: string): boolean { } export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + return parseSetupEntriesAllowingWildcard(raw, (entry) => { if (entry.toLowerCase().startsWith("uuid:")) { const id = entry.slice("uuid:".length).trim(); if (!id) { @@ -186,7 +186,7 @@ export const signalSetupAdapter: ChannelSetupAdapter = { export function createSignalSetupWizardProxy( loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, ) { - const signalDmPolicy: ChannelOnboardingDmPolicy = { + const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", channel, policyKey: "channels.signal.dmPolicy", @@ -270,6 +270,6 @@ export function createSignalSetupWizardProxy( ], }, dmPolicy: signalDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 822df4caf10..62cb02b78ab 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -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 { formatCliCommand } from "../../../src/cli/command-format.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; @@ -56,7 +56,7 @@ async function promptSignalAllowFrom(params: { }); } -const signalDmPolicy: ChannelOnboardingDmPolicy = { +const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", channel, policyKey: "channels.signal.dmPolicy", @@ -179,7 +179,7 @@ export const signalSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: signalDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts index d219e597148..1bd6eabde59 100644 --- a/extensions/slack/setup-entry.ts +++ b/extensions/slack/setup-entry.ts @@ -1,3 +1,3 @@ -import { slackPlugin } from "./src/channel.js"; +import { slackSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: slackPlugin }; +export default { plugin: slackSetupPlugin }; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts new file mode 100644 index 00000000000..83cd1625059 --- /dev/null +++ b/extensions/slack/src/channel.setup.ts @@ -0,0 +1,100 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + inspectSlackAccount, + isSlackInteractiveRepliesEnabled, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + SlackConfigSchema, + type ChannelPlugin, + type ResolvedSlackAccount, +} from "openclaw/plugin-sdk/slack"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; + +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const mode = account.config.mode ?? "socket"; + const hasBotToken = Boolean(account.botToken?.trim()); + if (!hasBotToken) { + return false; + } + if (mode === "http") { + return Boolean(account.config.signingSecret?.trim()); + } + return Boolean(account.appToken?.trim()); +} + +const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, +}); + +const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: "slack", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + +export const slackSetupPlugin: ChannelPlugin = { + id: "slack", + meta: { + ...getChatChannelMeta("slack"), + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: slackSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, + setup: slackSetupAdapter, +}; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 0cf7903e6d4..0aff9fc50a8 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, @@ -6,8 +5,9 @@ import { patchChannelConfigForAccount, setAccountGroupPolicyForChannel, 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, @@ -216,7 +216,7 @@ export const slackSetupAdapter: ChannelSetupAdapter = { export function createSlackSetupWizardProxy( loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, ) { - const slackDmPolicy: ChannelOnboardingDmPolicy = { + const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, policyKey: "channels.slack.dmPolicy", @@ -455,7 +455,7 @@ export function createSlackSetupWizardProxy( }) => { try { const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.groupAccess) { + if (!wizard.groupAccess?.resolveAllowlist) { return entries; } return await wizard.groupAccess.resolveAllowlist({ @@ -490,6 +490,6 @@ export function createSlackSetupWizardProxy( resolved: unknown; }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), }, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index dafcad32f74..4088e0d0ceb 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,15 +1,15 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - resolveOnboardingAccountId, + resolveSetupAccountId, setAccountGroupPolicyForChannel, 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, ChannelSetupWizardAllowFromEntry, @@ -166,7 +166,7 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultSlackAccountId(params.cfg), }); @@ -210,7 +210,7 @@ async function promptSlackAllowFrom(params: { }); } -const slackDmPolicy: ChannelOnboardingDmPolicy = { +const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, policyKey: "channels.slack.dmPolicy", @@ -424,5 +424,5 @@ export const slackSetupWizard: ChannelSetupWizard = { applyAllowlist: ({ cfg, accountId, resolved }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/synology-chat/index.ts b/extensions/synology-chat/index.ts index 69dbfb9edbf..9078b9f86c7 100644 --- a/extensions/synology-chat/index.ts +++ b/extensions/synology-chat/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/synology-chat"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/synology-chat"; -import { createSynologyChatPlugin } from "./src/channel.js"; +import { synologyChatPlugin } from "./src/channel.js"; import { setSynologyRuntime } from "./src/runtime.js"; const plugin = { @@ -10,7 +10,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setSynologyRuntime(api.runtime); - api.registerChannel({ plugin: createSynologyChatPlugin() }); + api.registerChannel({ plugin: synologyChatPlugin }); }, }; diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index c6148c856a3..d8ff22d6361 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "synology-chat", "label": "Synology Chat", diff --git a/extensions/synology-chat/setup-entry.ts b/extensions/synology-chat/setup-entry.ts new file mode 100644 index 00000000000..45cc966e082 --- /dev/null +++ b/extensions/synology-chat/setup-entry.ts @@ -0,0 +1,5 @@ +import { synologyChatPlugin } from "./src/channel.js"; + +export default { + plugin: synologyChatPlugin, +}; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index bdce5f37d79..b45f8c355e4 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -22,6 +22,8 @@ describe("createSynologyChatPlugin", () => { expect(plugin.meta).toBeDefined(); expect(plugin.capabilities).toBeDefined(); expect(plugin.config).toBeDefined(); + expect(plugin.setup).toBeDefined(); + expect(plugin.setupWizard).toBeDefined(); expect(plugin.security).toBeDefined(); expect(plugin.outbound).toBeDefined(); expect(plugin.gateway).toBeDefined(); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index d84516dbda5..0bc771a7d26 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; +import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; import { createWebhookHandler } from "./webhook-handler.js"; @@ -68,6 +69,8 @@ export function createSynologyChatPlugin() { reload: { configPrefixes: [`channels.${CHANNEL_ID}`] }, configSchema: SynologyChatConfigSchema, + setup: synologyChatSetupAdapter, + setupWizard: synologyChatSetupWizard, config: { listAccountIds: (cfg: any) => listAccountIds(cfg), @@ -377,3 +380,5 @@ export function createSynologyChatPlugin() { }, }; } + +export const synologyChatPlugin = createSynologyChatPlugin(); diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/setup-surface.test.ts new file mode 100644 index 00000000000..d7a2a1056a0 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { synologyChatPlugin } from "./channel.js"; +import { synologyChatSetupWizard } from "./setup-surface.js"; + +function createPrompter(overrides: Partial = {}): 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 synologyChatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ + plugin: synologyChatPlugin, + wizard: synologyChatSetupWizard, +}); + +describe("synology-chat setup wizard", () => { + it("configures token and incoming webhook for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.["synology-chat"]?.enabled).toBe(true); + expect(result.cfg.channels?.["synology-chat"]?.token).toBe("synology-token"); + expect(result.cfg.channels?.["synology-chat"]?.incomingUrl).toBe( + "https://nas.example.com/webapi/entry.cgi?token=incoming", + ); + }); + + it("records allowed user ids when setup forces allowFrom", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + if (message === "Allowed Synology Chat user ids") { + return "123456, synology-chat:789012"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.cfg.channels?.["synology-chat"]?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.["synology-chat"]?.allowedUserIds).toEqual(["123456", "789012"]); + }); +}); diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts new file mode 100644 index 00000000000..77ad0ded2c2 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.ts @@ -0,0 +1,324 @@ +import { + mergeAllowFromEntries, + setSetupChannelEnabled, + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listAccountIds, resolveAccount } from "./accounts.js"; +import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; + +const channel = "synology-chat" as const; +const DEFAULT_WEBHOOK_PATH = "/webhook/synology"; + +const SYNOLOGY_SETUP_HELP_LINES = [ + "1) Create an incoming webhook in Synology Chat and copy its URL", + "2) Create an outgoing webhook and copy its secret token", + `3) Point the outgoing webhook to https://${DEFAULT_WEBHOOK_PATH}`, + "4) Keep allowed user IDs handy for DM allowlisting", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +const SYNOLOGY_ALLOW_FROM_HELP_LINES = [ + "Allowlist Synology Chat DMs by numeric user id.", + "Examples:", + "- 123456", + "- synology-chat:123456", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig { + return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {}; +} + +function getRawAccountConfig(cfg: OpenClawConfig, accountId: string): SynologyChatAccountRaw { + const channelConfig = getChannelConfig(cfg); + if (accountId === DEFAULT_ACCOUNT_ID) { + return channelConfig; + } + return channelConfig.accounts?.[accountId] ?? {}; +} + +function patchSynologyChatAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const channelConfig = getChannelConfig(params.cfg); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + const nextChannelConfig = { ...channelConfig } as Record; + for (const field of params.clearFields ?? []) { + delete nextChannelConfig[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...nextChannelConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccounts = { ...(channelConfig.accounts ?? {}) } as Record< + string, + Record + >; + const nextAccountConfig = { ...(nextAccounts[params.accountId] ?? {}) }; + for (const field of params.clearFields ?? []) { + delete nextAccountConfig[field]; + } + nextAccounts[params.accountId] = { + ...nextAccountConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }; + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...channelConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: nextAccounts, + }, + }, + }; +} + +function isSynologyChatConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const account = resolveAccount(cfg, accountId); + return Boolean(account.token.trim() && account.incomingUrl.trim()); +} + +function validateWebhookUrl(value: string): string | undefined { + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "Incoming webhook must use http:// or https://."; + } + } catch { + return "Incoming webhook must be a valid URL."; + } + return undefined; +} + +function validateWebhookPath(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.startsWith("/") ? undefined : "Webhook path must start with /."; +} + +function parseSynologyUserId(value: string): string | null { + const cleaned = value.replace(/^synology-chat:/i, "").trim(); + return /^\d+$/.test(cleaned) ? cleaned : null; +} + +function resolveExistingAllowedUserIds(cfg: OpenClawConfig, accountId: string): string[] { + const raw = getRawAccountConfig(cfg, accountId).allowedUserIds; + if (Array.isArray(raw)) { + return raw.map((value) => String(value).trim()).filter(Boolean); + } + return String(raw ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +export const synologyChatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID, + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Synology Chat env credentials only support the default account."; + } + if (!input.useEnv && !input.token?.trim()) { + return "Synology Chat requires --token or --use-env."; + } + if (!input.url?.trim()) { + return "Synology Chat requires --url for the incoming webhook."; + } + const urlError = validateWebhookUrl(input.url.trim()); + if (urlError) { + return urlError; + } + if (input.webhookPath?.trim()) { + return validateWebhookPath(input.webhookPath.trim()) ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: input.useEnv ? ["token"] : undefined, + patch: { + ...(input.useEnv ? {} : { token: input.token?.trim() }), + incomingUrl: input.url?.trim(), + ...(input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}), + }, + }), +}; + +export const synologyChatSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + incoming webhook", + configuredHint: "configured", + unconfiguredHint: "needs token + incoming webhook", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listAccountIds(cfg).some((accountId) => isSynologyChatConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `Synology Chat: ${configured ? "configured" : "needs token + incoming webhook"}`, + `Accounts: ${listAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "Synology Chat webhook setup", + lines: SYNOLOGY_SETUP_HELP_LINES, + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "outgoing webhook token", + preferredEnvVar: "SYNOLOGY_CHAT_TOKEN", + helpTitle: "Synology Chat webhook token", + helpLines: SYNOLOGY_SETUP_HELP_LINES, + envPrompt: "SYNOLOGY_CHAT_TOKEN detected. Use env var?", + keepPrompt: "Synology Chat webhook token already configured. Keep it?", + inputPrompt: "Enter Synology Chat outgoing webhook token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveAccount(cfg, accountId); + const raw = getRawAccountConfig(cfg, accountId); + return { + accountConfigured: isSynologyChatConfigured(cfg, accountId), + hasConfiguredValue: Boolean(raw.token?.trim()), + resolvedValue: account.token.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.SYNOLOGY_CHAT_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async ({ cfg, accountId }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["token"], + patch: {}, + }), + applySet: async ({ cfg, accountId, resolvedValue }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { token: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "url", + message: "Incoming webhook URL", + placeholder: + "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...", + helpTitle: "Synology Chat incoming webhook", + helpLines: [ + "Use the incoming webhook URL from Synology Chat integrations.", + "This is the URL OpenClaw uses to send replies back to Chat.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(), + keepPrompt: (value) => `Incoming webhook URL set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookUrl(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { incomingUrl: value.trim() }, + }), + }, + { + inputKey: "webhookPath", + message: "Outgoing webhook path (optional)", + placeholder: DEFAULT_WEBHOOK_PATH, + required: false, + applyEmptyValue: true, + helpTitle: "Synology Chat outgoing webhook path", + helpLines: [ + `Default path: ${DEFAULT_WEBHOOK_PATH}`, + "Change this only if you need multiple Synology Chat webhook routes.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(), + keepPrompt: (value) => `Outgoing webhook path set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookPath(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: value.trim() ? undefined : ["webhookPath"], + patch: value.trim() ? { webhookPath: value.trim() } : {}, + }), + }, + ], + allowFrom: { + helpTitle: "Synology Chat allowlist", + helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES, + message: "Allowed Synology Chat user ids", + placeholder: "123456, 987654", + invalidWithoutCredentialNote: "Synology Chat user ids must be numeric.", + parseInputs: splitSetupEntries, + parseId: parseSynologyUserId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseSynologyUserId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { + dmPolicy: "allowlist", + allowedUserIds: mergeAllowFromEntries( + resolveExistingAllowedUserIds(cfg, accountId), + allowFrom, + ), + }, + }), + }, + completionNote: { + title: "Synology Chat access control", + lines: [ + `Default outgoing webhook path: ${DEFAULT_WEBHOOK_PATH}`, + 'Set allowed user IDs, or manually switch `channels.synology-chat.dmPolicy` to `"open"` for public DMs.', + 'With `dmPolicy="allowlist"`, an empty allowedUserIds list blocks the route from starting.', + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, + ], + }, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json index fab1326ca34..54c12a19e4c 100644 --- a/extensions/synthetic/openclaw.plugin.json +++ b/extensions/synthetic/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "synthetic", "providers": ["synthetic"], + "providerAuthEnvVars": { + "synthetic": ["SYNTHETIC_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index b5e7fc8c073..030f4bb3295 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -1,3 +1,3 @@ -import { telegramPlugin } from "./src/channel.js"; +import { telegramSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: telegramPlugin }; +export default { plugin: telegramSetupPlugin }; diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts new file mode 100644 index 00000000000..6abc8ba0c62 --- /dev/null +++ b/extensions/telegram/src/channel.setup.ts @@ -0,0 +1,125 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + inspectTelegramAccount, + listTelegramAccountIds, + normalizeAccountId, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, + type ResolvedTelegramAccount, + type TelegramProbe, +} from "openclaw/plugin-sdk/telegram"; +import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupWizard } from "./setup-surface.js"; + +function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +export const telegramSetupPlugin: ChannelPlugin = { + id: "telegram", + meta: { + ...getChatChannelMeta("telegram"), + quickstartAllowFrom: true, + }, + setupWizard: telegramSetupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), + config: { + ...telegramConfigBase, + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, + setup: telegramSetupAdapter, +}; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index fe9c9993035..1a3d17e68fd 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,7 +1,7 @@ import { patchChannelConfigForAccount, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -73,7 +73,7 @@ export async function promptTelegramAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: Parameters< NonNullable< - import("../../../src/channels/plugins/onboarding-types.js").ChannelOnboardingDmPolicy["promptAllowFrom"] + import("../../../src/channels/plugins/setup-flow-types.js").ChannelSetupDmPolicy["promptAllowFrom"] > >[0]["prompter"]; accountId?: string; @@ -88,7 +88,7 @@ export async function promptTelegramAllowFromForAccount(params: { ); } const { promptResolvedAllowFrom } = - await import("../../../src/channels/plugins/onboarding/helpers.js"); + await import("../../../src/channels/plugins/setup-flow-helpers.js"); const unique = await promptResolvedAllowFrom({ prompter: params.prompter, existing: resolved.config.allowFrom ?? [], @@ -96,7 +96,7 @@ export async function promptTelegramAllowFromForAccount(params: { message: "Telegram allowFrom (numeric sender id; @username resolves to id)", placeholder: "@username", label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, invalidWithoutTokenNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 3fcf09ed7db..ba03f2bb251 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,10 +1,10 @@ -import { type ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { patchChannelConfigForAccount, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, + 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 { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; @@ -22,7 +22,7 @@ import { const channel = "telegram" as const; -const dmPolicy: ChannelOnboardingDmPolicy = { +const dmPolicy: ChannelSetupDmPolicy = { label: "Telegram", channel, policyKey: "channels.telegram.dmPolicy", @@ -89,7 +89,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { placeholder: "@username", invalidWithoutCredentialNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, resolveEntries: async ({ credentialValues, entries }) => resolveTelegramAllowFromEntries({ @@ -105,7 +105,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { }), }, dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts index bb638fc3018..9d3f432b46c 100644 --- a/extensions/tlon/src/setup-surface.test.ts +++ b/extensions/tlon/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon"; 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 { tlonPlugin } from "./channel.js"; @@ -26,7 +26,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const tlonConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const tlonConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: tlonPlugin, wizard: tlonPlugin.setupWizard!, }); diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index 2a868251f34..ea3ae237fa2 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "together", "providers": ["together"], + "providerAuthEnvVars": { + "together": ["TOGETHER_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 776644a2d23..7d4129d2ebd 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -2,8 +2,7 @@ * Twitch setup wizard surface for CLI setup. */ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.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"; @@ -184,7 +183,7 @@ export async function configureWithEnvToken( account: TwitchAccountConfig | null, envToken: string, forceAllowFrom: boolean, - dmPolicy: ChannelOnboardingDmPolicy, + dmPolicy: ChannelSetupDmPolicy, ): Promise<{ cfg: OpenClawConfig } | null> { const useEnv = await prompter.confirm({ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", @@ -228,7 +227,27 @@ function setTwitchAccessControl( }); } -const twitchDmPolicy: ChannelOnboardingDmPolicy = { +function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" { + const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + if (account?.allowedRoles?.includes("all")) { + return "open"; + } + if (account?.allowedRoles?.includes("moderator")) { + return "allowlist"; + } + return "disabled"; +} + +function setTwitchGroupPolicy( + cfg: OpenClawConfig, + policy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + const allowedRoles: TwitchRole[] = + policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : []; + return setTwitchAccessControl(cfg, allowedRoles, true); +} + +const twitchDmPolicy: ChannelSetupDmPolicy = { label: "Twitch", channel, policyKey: "channels.twitch.allowedRoles", @@ -270,6 +289,24 @@ const twitchDmPolicy: ChannelOnboardingDmPolicy = { }, }; +const twitchGroupAccess: NonNullable = { + label: "Twitch chat", + placeholder: "", + skipAllowlistEntries: true, + currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg as OpenClawConfig), + currentEntries: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return account?.allowFrom ?? []; + }, + updatePrompt: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length); + }, + setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg as OpenClawConfig, policy), + resolveAllowlist: async () => [], + applyAllowlist: ({ cfg }) => cfg as OpenClawConfig, +}; + export const twitchSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountConfig: ({ cfg }) => @@ -342,37 +379,10 @@ export const twitchSetupWizard: ChannelSetupWizard = { ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; - if (!account?.allowFrom || account.allowFrom.length === 0) { - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Twitch chat", - currentPolicy: account?.allowedRoles?.includes("all") - ? "open" - : account?.allowedRoles?.includes("moderator") - ? "allowlist" - : "disabled", - currentEntries: [], - placeholder: "", - updatePrompt: false, - }); - - if (accessConfig) { - const allowedRoles: TwitchRole[] = - accessConfig.policy === "open" - ? ["all"] - : accessConfig.policy === "allowlist" - ? ["moderator", "vip"] - : []; - - return { - cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true), - }; - } - } - return { cfg: cfgWithAllowFrom }; }, dmPolicy: twitchDmPolicy, + groupAccess: twitchGroupAccess, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json index 6262595509e..a84a0e7b669 100644 --- a/extensions/venice/openclaw.plugin.json +++ b/extensions/venice/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "venice", "providers": ["venice"], + "providerAuthEnvVars": { + "venice": ["VENICE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 14f4a214605..47037724c36 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "vercel-ai-gateway", "providers": ["vercel-ai-gateway"], + "providerAuthEnvVars": { + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index 5a9f9a778ee..6ab01cb5e89 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "vllm", "providers": ["vllm"], + "providerAuthEnvVars": { + "vllm": ["VLLM_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index 0773577aef9..2b5e54ff013 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "volcengine", "providers": ["volcengine", "volcengine-plan"], + "providerAuthEnvVars": { + "volcengine": ["VOLCANO_ENGINE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index 0dd48c5b785..5b18e10073b 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -1,3 +1,3 @@ -import { whatsappPlugin } from "./src/channel.js"; +import { whatsappSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: whatsappPlugin }; +export default { plugin: whatsappSetupPlugin }; diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts new file mode 100644 index 00000000000..b352bd2ed73 --- /dev/null +++ b/extensions/whatsapp/src/channel.setup.ts @@ -0,0 +1,198 @@ +import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; +import { webAuthExists } from "./auth-store.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; + +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; + +export const whatsappSetupPlugin: ChannelPlugin = { + id: "whatsapp", + meta: { + ...getChatChannelMeta("whatsapp"), + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: whatsappSetupWizardProxy, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: async (account) => await webAuthExists(account.authDir), + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "whatsapp", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }), + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: whatsappSetupAdapter, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, +}; diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/onboarding.test.ts index bf816e3f03d..e28766058af 100644 --- a/extensions/whatsapp/src/onboarding.test.ts +++ b/extensions/whatsapp/src/onboarding.test.ts @@ -1,5 +1,5 @@ import { beforeEach, 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 { RuntimeEnv } from "../../../src/runtime.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; @@ -83,7 +83,7 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } -const whatsappConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const whatsappConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: whatsappPlugin, wizard: whatsappPlugin.setupWizard!, }); diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index e0e9fa3191b..e9b5b8aeb0b 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { loginWeb } from "../../../src/channel-web.js"; import { normalizeAllowFromEntries, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-flow-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -96,7 +96,7 @@ async function applyWhatsAppOwnerAllowlist(params: { } function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { - const parts = splitOnboardingEntries(raw); + const parts = splitSetupEntries(raw); if (parts.length === 0) { return { entries: [] }; } @@ -330,7 +330,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = { }); return { cfg: next }; }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 78c758c6571..4f0c03c280f 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "xiaomi", "providers": ["xiaomi"], + "providerAuthEnvVars": { + "xiaomi": ["XIAOMI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 5e23160ddb6..c5985d748b0 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "zai", "providers": ["zai"], + "providerAuthEnvVars": { + "zai": ["ZAI_API_KEY", "Z_AI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index a72aabbb29e..b6ab61f7cee 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "zalo", "label": "Zalo", diff --git a/extensions/zalo/setup-entry.ts b/extensions/zalo/setup-entry.ts new file mode 100644 index 00000000000..dd8ca1b70f8 --- /dev/null +++ b/extensions/zalo/setup-entry.ts @@ -0,0 +1,5 @@ +import { zaloPlugin } from "./src/channel.js"; + +export default { + plugin: zaloPlugin, +}; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index adba1f8bd93..69f99c69e3a 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -40,7 +40,8 @@ import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; -import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js"; +import { zaloSetupAdapter } from "./setup-core.js"; +import { zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/onboarding.status.test.ts index 4db31735c94..65e5591cbae 100644 --- a/extensions/zalo/src/onboarding.status.test.ts +++ b/extensions/zalo/src/onboarding.status.test.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; 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 { zaloPlugin } from "./channel.js"; -const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zaloConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, }); diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts new file mode 100644 index 00000000000..6e194a41652 --- /dev/null +++ b/extensions/zalo/src/setup-core.ts @@ -0,0 +1,57 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "zalo" as const; + +export const zaloSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "ZALO_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Zalo requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch, + }); + }, +}; diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index 2353a66e453..b5db1019c38 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; 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 { zaloPlugin } from "./channel.js"; @@ -18,7 +18,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zaloConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, }); diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 643c2f6ff76..b3ad6549c13 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,24 +1,19 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, mergeAllowFromEntries, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-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 { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; +import { zaloSetupAdapter } from "./setup-core.js"; const channel = "zalo" as const; @@ -127,7 +122,7 @@ async function noteZaloTokenHelp( async function promptZaloAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -187,7 +182,7 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const zaloDmPolicy: ChannelOnboardingDmPolicy = { +const zaloDmPolicy: ChannelSetupDmPolicy = { label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", @@ -207,53 +202,7 @@ const zaloDmPolicy: ChannelOnboardingDmPolicy = { }, }; -export const zaloSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "ZALO_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch, - }); - }, -}; +export { zaloSetupAdapter } from "./setup-core.js"; export const zaloSetupWizard: ChannelSetupWizard = { channel, diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index e7c12c9b4b2..5e3a1070237 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -12,6 +12,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "zalouser", "label": "Zalo Personal", diff --git a/extensions/zalouser/setup-entry.ts b/extensions/zalouser/setup-entry.ts new file mode 100644 index 00000000000..f983cad8f80 --- /dev/null +++ b/extensions/zalouser/setup-entry.ts @@ -0,0 +1,5 @@ +import { zalouserPlugin } from "./src/channel.js"; + +export default { + plugin: zalouserPlugin, +}; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index b7d103e9b6e..46dbb2c9fee 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -42,7 +42,8 @@ import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; -import { zalouserSetupAdapter, zalouserSetupWizard } from "./setup-surface.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; +import { zalouserSetupWizard } from "./setup-surface.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts new file mode 100644 index 00000000000..45f412ed9f6 --- /dev/null +++ b/extensions/zalouser/src/setup-core.ts @@ -0,0 +1,42 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "zalouser" as const; + +export const zalouserSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: () => null, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: {}, + }); + }, +}; diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index d28fd8f0ccc..bd96ff2efe0 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; 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"; vi.mock("./zalo-js.js", async (importOriginal) => { @@ -50,7 +50,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const zalouserConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zalouserConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zalouserPlugin, wizard: zalouserPlugin.setupWizard!, }); diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index b091ed37947..c7406f50edd 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,16 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; +import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.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 { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -22,6 +16,7 @@ import { checkZcaAuthenticated, } from "./accounts.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; import { logoutZaloProfile, resolveZaloAllowFromEntries, @@ -96,7 +91,7 @@ async function noteZalouserHelp( async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -149,7 +144,7 @@ async function promptZalouserAllowFrom(params: { } } -const zalouserDmPolicy: ChannelOnboardingDmPolicy = { +const zalouserDmPolicy: ChannelSetupDmPolicy = { label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", @@ -169,38 +164,7 @@ const zalouserDmPolicy: ChannelOnboardingDmPolicy = { }, }; -export const zalouserSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: {}, - }); - }, -}; +export { zalouserSetupAdapter } from "./setup-core.js"; export const zalouserSetupWizard: ChannelSetupWizard = { channel, diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts index 00a1c8c1be0..f7bc1a358b3 100644 --- a/extensions/zalouser/src/zca-client.ts +++ b/extensions/zalouser/src/zca-client.ts @@ -1,16 +1,18 @@ -import { - LoginQRCallbackEventType as LoginQRCallbackEventTypeRuntime, - Reactions as ReactionsRuntime, - ThreadType as ThreadTypeRuntime, - Zalo as ZaloRuntime, -} from "zca-js"; +import * as zcaJsRuntime from "zca-js"; -export const ThreadType = ThreadTypeRuntime as { +const zcaJs = zcaJsRuntime as unknown as { + ThreadType: unknown; + LoginQRCallbackEventType: unknown; + Reactions: unknown; + Zalo: unknown; +}; + +export const ThreadType = zcaJs.ThreadType as { User: 0; Group: 1; }; -export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { +export const LoginQRCallbackEventType = zcaJs.LoginQRCallbackEventType as { QRCodeGenerated: 0; QRCodeExpired: 1; QRCodeScanned: 2; @@ -18,7 +20,7 @@ export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { GotLoginInfo: 4; }; -export const Reactions = ReactionsRuntime as Record & { +export const Reactions = zcaJs.Reactions as Record & { HEART: string; LIKE: string; HAHA: string; @@ -290,4 +292,4 @@ type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => { ): Promise; }; -export const Zalo = ZaloRuntime as unknown as ZaloCtor; +export const Zalo = zcaJs.Zalo as unknown as ZaloCtor; diff --git a/scripts/lib/plugin-sdk-entries.mjs b/scripts/lib/plugin-sdk-entries.mjs index ba6c1a5c386..c2ce28484ae 100644 --- a/scripts/lib/plugin-sdk-entries.mjs +++ b/scripts/lib/plugin-sdk-entries.mjs @@ -1,48 +1,6 @@ -export const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -]; +import pluginSdkEntryList from "./plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json new file mode 100644 index 00000000000..c42f27db5a1 --- /dev/null +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -0,0 +1,45 @@ +[ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue" +] diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 1f305379b5d..5182dfdf0af 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -361,4 +361,46 @@ describe("applyPatch", () => { } }); }); + + it("uses container paths when the sandbox bridge has no local host path", async () => { + const files = new Map([["/sandbox/source.txt", "before\n"]]); + const bridge = { + resolvePath: ({ filePath }: { filePath: string }) => ({ + relativePath: filePath, + containerPath: `/sandbox/${filePath}`, + }), + readFile: vi.fn(async ({ filePath }: { filePath: string }) => + Buffer.from(files.get(filePath) ?? "", "utf8"), + ), + writeFile: vi.fn(async ({ filePath, data }: { filePath: string; data: Buffer | string }) => { + files.set(filePath, Buffer.isBuffer(data) ? data.toString("utf8") : data); + }), + remove: vi.fn(async ({ filePath }: { filePath: string }) => { + files.delete(filePath); + }), + mkdirp: vi.fn(async () => {}), + }; + + const patch = `*** Begin Patch +*** Update File: source.txt +@@ +-before ++after +*** End Patch`; + + const result = await applyPatch(patch, { + cwd: "/local/workspace", + sandbox: { + root: "/local/workspace", + bridge: bridge as never, + }, + }); + + expect(files.get("/sandbox/source.txt")).toBe("after\n"); + expect(result.summary.modified).toEqual(["source.txt"]); + expect(bridge.readFile).toHaveBeenCalledWith({ + filePath: "/sandbox/source.txt", + cwd: "/local/workspace", + }); + }); }); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index d7a5dc1e0ff..0fc612923c1 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -313,7 +313,7 @@ async function resolvePatchPath( filePath, cwd: options.cwd, }); - if (options.workspaceOnly !== false) { + if (options.workspaceOnly !== false && resolved.hostPath) { await assertSandboxPath({ filePath: resolved.hostPath, cwd: options.cwd, @@ -323,8 +323,8 @@ async function resolvePatchPath( }); } return { - resolved: resolved.hostPath, - display: resolved.relativePath || resolved.hostPath, + resolved: resolved.hostPath ?? resolved.containerPath, + display: resolved.relativePath || resolved.containerPath, }; } diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 5c3301414b9..72367deb33d 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -384,6 +384,7 @@ export async function runExecProcess(opts: { typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? Math.floor(opts.timeoutSec * 1000) : undefined; + let sandboxFinalizeToken: unknown; const spawnSpec: | { @@ -398,11 +399,18 @@ export async function runExecProcess(opts: { childFallbackArgv: string[]; env: NodeJS.ProcessEnv; stdinMode: "pipe-open"; - } = (() => { + } = await (async () => { if (opts.sandbox) { + const backendExecSpec = await opts.sandbox.buildExecSpec?.({ + command: execCommand, + workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, + env: shellRuntimeEnv, + usePty: opts.usePty, + }); + sandboxFinalizeToken = backendExecSpec?.finalizeToken; return { mode: "child" as const, - argv: [ + argv: backendExecSpec?.argv ?? [ "docker", ...buildDockerExecArgs({ containerName: opts.sandbox.containerName, @@ -412,8 +420,10 @@ export async function runExecProcess(opts: { tty: opts.usePty, }), ], - env: process.env, - stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const), + env: backendExecSpec?.env ?? process.env, + stdinMode: + backendExecSpec?.stdinMode ?? + (opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const)), }; } const { shell, args: shellArgs } = getShellConfig(); @@ -519,7 +529,7 @@ export async function runExecProcess(opts: { const promise = managedRun .wait() - .then((exit): ExecProcessOutcome => { + .then(async (exit): Promise => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; const exitCode = exit.exitCode ?? 0; @@ -536,6 +546,14 @@ export async function runExecProcess(opts: { session.stdin.destroyed = true; } const aggregated = session.aggregated.trim(); + if (opts.sandbox?.finalizeExec) { + await opts.sandbox.finalizeExec({ + status, + exitCode: exit.exitCode ?? null, + timedOut: exit.timedOut, + token: sandboxFinalizeToken, + }); + } if (status === "completed") { const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 3cfb92655e2..25f1fb5bd8d 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; +import type { SandboxBackendExecSpec } from "./sandbox/backend.js"; const CHUNK_LIMIT = 8 * 1024; @@ -12,6 +13,18 @@ export type BashSandboxConfig = { workspaceDir: string; containerWorkdir: string; env?: Record; + buildExecSpec?: (params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }) => Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; }; export function buildSandboxEnv(params: { diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index c9cb9159138..e318cd2e9c8 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -1,45 +1,10 @@ -export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { - "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], - anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], - zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], - opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], - "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], - "byteplus-plan": ["BYTEPLUS_API_KEY"], - "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - openai: ["OPENAI_API_KEY"], - google: ["GEMINI_API_KEY"], - voyage: ["VOYAGE_API_KEY"], - groq: ["GROQ_API_KEY"], - deepgram: ["DEEPGRAM_API_KEY"], - cerebras: ["CEREBRAS_API_KEY"], - xai: ["XAI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - litellm: ["LITELLM_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - nvidia: ["NVIDIA_API_KEY"], - xiaomi: ["XIAOMI_API_KEY"], - synthetic: ["SYNTHETIC_API_KEY"], - venice: ["VENICE_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - together: ["TOGETHER_API_KEY"], - qianfan: ["QIANFAN_API_KEY"], - modelstudio: ["MODELSTUDIO_API_KEY"], - ollama: ["OLLAMA_API_KEY"], - sglang: ["SGLANG_API_KEY"], - vllm: ["VLLM_API_KEY"], - kilocode: ["KILOCODE_API_KEY"], -}; +import { + PROVIDER_AUTH_ENV_VAR_CANDIDATES, + listKnownProviderAuthEnvVarNames, +} from "../secrets/provider-env-vars.js"; + +export const PROVIDER_ENV_API_KEY_CANDIDATES = PROVIDER_AUTH_ENV_VAR_CANDIDATES; export function listKnownProviderEnvApiKeyNames(): string[] { - return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())]; + return listKnownProviderAuthEnvVarNames(); } diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index a1fc511aaf8..ca509f632d4 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -426,4 +426,45 @@ describe("getApiKeyForModel", () => { }, ); }); + + it("resolveEnvApiKey('qwen-portal') accepts QWEN_OAUTH_TOKEN", async () => { + await withEnvAsync( + { + QWEN_OAUTH_TOKEN: "qwen-oauth-token", + QWEN_PORTAL_API_KEY: undefined, + }, + async () => { + const resolved = resolveEnvApiKey("qwen"); + expect(resolved?.apiKey).toBe("qwen-oauth-token"); + expect(resolved?.source).toContain("QWEN_OAUTH_TOKEN"); + }, + ); + }); + + it("resolveEnvApiKey('minimax-portal') accepts MINIMAX_OAUTH_TOKEN", async () => { + await withEnvAsync( + { + MINIMAX_OAUTH_TOKEN: "minimax-oauth-token", + MINIMAX_API_KEY: undefined, + }, + async () => { + const resolved = resolveEnvApiKey("minimax-portal"); + expect(resolved?.apiKey).toBe("minimax-oauth-token"); + expect(resolved?.source).toContain("MINIMAX_OAUTH_TOKEN"); + }, + ); + }); + + it("resolveEnvApiKey('volcengine-plan') uses volcengine auth candidates", async () => { + await withEnvAsync( + { + VOLCANO_ENGINE_API_KEY: "volcengine-plan-key", + }, + async () => { + const resolved = resolveEnvApiKey("volcengine-plan"); + expect(resolved?.apiKey).toBe("volcengine-plan-key"); + expect(resolved?.source).toContain("VOLCANO_ENGINE_API_KEY"); + }, + ); + }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 0616bc41194..4a896d5b56b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -25,7 +25,7 @@ import { isNonSecretApiKeyMarker, OLLAMA_LOCAL_AUTH_MARKER, } from "./model-auth-markers.js"; -import { normalizeProviderId } from "./model-selection.js"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -400,7 +400,7 @@ export function resolveEnvApiKey( provider: string, env: NodeJS.ProcessEnv = process.env, ): EnvApiKeyResult | null { - const normalized = normalizeProviderId(provider); + const normalized = normalizeProviderIdForAuth(provider); const applied = new Set(getShellEnvAppliedKeys()); const pick = (envVar: string): EnvApiKeyResult | null => { const value = normalizeOptionalSecretInput(env[envVar]); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts index ed4b0a7100c..efcba001638 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js"; +import { DEFAULT_COPILOT_API_BASE_URL } from "../../extensions/github-copilot/token.js"; import { withEnvAsync } from "../test-utils/env.js"; import { installModelsConfigTestHooks, diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts index 8b225ff89cb..52289130690 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts @@ -5,10 +5,13 @@ import type { SandboxContext } from "./sandbox.js"; function createSandboxContext(overrides?: Partial): SandboxContext { const base = { enabled: true, + backendId: "docker", sessionKey: "session:test", workspaceDir: "/tmp/openclaw-sandbox", agentWorkspaceDir: "/tmp/openclaw-workspace", workspaceAccess: "none", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index f9f9934f453..cbea9e5f21b 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -33,7 +33,7 @@ vi.mock("../infra/backoff.js", () => ({ sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal), })); -vi.mock("../providers/github-copilot-token.js", () => ({ +vi.mock("../../extensions/github-copilot/token.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), })); diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index e24186e0b30..353b0333759 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -574,10 +574,13 @@ describe("Agent-specific tool filtering", () => { agentDir: "/tmp/agent-restricted", sandbox: { enabled: true, + backendId: "docker", sessionKey: "agent:restricted:main", workspaceDir: "/tmp/sandbox", agentWorkspaceDir: "/tmp/test-restricted", workspaceAccess: "none", + runtimeId: "test-container", + runtimeLabel: "test-container", containerName: "test-container", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 6536e9dfbb5..9c7aafbd56e 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -438,7 +438,9 @@ export function createOpenClawCodingTools(options?: { containerName: sandbox.containerName, workspaceDir: sandbox.workspaceDir, containerWorkdir: sandbox.containerWorkdir, - env: sandbox.docker.env, + env: sandbox.backend?.env ?? sandbox.docker.env, + buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend), + finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend), } : undefined, }); diff --git a/src/agents/sandbox-media-paths.test.ts b/src/agents/sandbox-media-paths.test.ts index 4179c2a68ef..0007e943fdd 100644 --- a/src/agents/sandbox-media-paths.test.ts +++ b/src/agents/sandbox-media-paths.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { createSandboxBridgeReadFile } from "./sandbox-media-paths.js"; +import { + createSandboxBridgeReadFile, + resolveSandboxedBridgeMediaPath, +} from "./sandbox-media-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; describe("createSandboxBridgeReadFile", () => { @@ -19,4 +22,24 @@ describe("createSandboxBridgeReadFile", () => { cwd: "/tmp/sandbox-root", }); }); + + it("falls back to container paths when the bridge has no host path", async () => { + const stat = vi.fn(async () => ({ type: "file", size: 1, mtimeMs: 1 })); + const resolved = await resolveSandboxedBridgeMediaPath({ + sandbox: { + root: "/tmp/sandbox-root", + bridge: { + resolvePath: ({ filePath }: { filePath: string }) => ({ + relativePath: filePath, + containerPath: `/sandbox/${filePath}`, + }), + stat, + } as unknown as SandboxFsBridge, + }, + mediaPath: "image.png", + }); + + expect(resolved).toEqual({ resolved: "/sandbox/image.png" }); + expect(stat).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/sandbox-media-paths.ts b/src/agents/sandbox-media-paths.ts index 3c6b2614c94..1c46f392482 100644 --- a/src/agents/sandbox-media-paths.ts +++ b/src/agents/sandbox-media-paths.ts @@ -44,8 +44,10 @@ export async function resolveSandboxedBridgeMediaPath(params: { }); try { const resolved = resolveDirect(); - await enforceWorkspaceBoundary(resolved.hostPath); - return { resolved: resolved.hostPath }; + if (resolved.hostPath) { + await enforceWorkspaceBoundary(resolved.hostPath); + } + return { resolved: resolved.hostPath ?? resolved.containerPath }; } catch (err) { const fallbackDir = params.inboundFallbackDir?.trim(); if (!fallbackDir) { @@ -67,7 +69,12 @@ export async function resolveSandboxedBridgeMediaPath(params: { filePath: fallbackPath, cwd: params.sandbox.root, }); - await enforceWorkspaceBoundary(resolvedFallback.hostPath); - return { resolved: resolvedFallback.hostPath, rewrittenFrom: filePath }; + if (resolvedFallback.hostPath) { + await enforceWorkspaceBoundary(resolvedFallback.hostPath); + } + return { + resolved: resolvedFallback.hostPath ?? resolvedFallback.containerPath, + rewrittenFrom: filePath, + }; } } diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 0635703b8bb..d120ac84820 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSandboxBrowserConfig, + resolveSandboxConfigForAgent, resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, @@ -128,4 +129,8 @@ describe("sandbox config merges", () => { }); expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + + it("defaults sandbox backend to docker", () => { + expect(resolveSandboxConfigForAgent().backend).toBe("docker"); + }); }); diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 2ecec621a70..0fa62a364e2 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerSandboxBackend } from "./sandbox/backend.js"; import { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; describe("resolveSandboxContext", () => { @@ -84,4 +85,45 @@ describe("resolveSandboxContext", () => { }), ).toBeNull(); }, 15_000); + + it("resolves a registered non-docker backend", async () => { + const restore = registerSandboxBackend("test-backend", async () => ({ + id: "test-backend", + runtimeId: "test-runtime", + runtimeLabel: "Test Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["test-backend", "exec"], + env: process.env, + stdinMode: "pipe-closed", + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { mode: "all", backend: "test-backend", scope: "session" }, + }, + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:worker:task", + workspaceDir: "/tmp/openclaw-test", + }); + + expect(result?.backendId).toBe("test-backend"); + expect(result?.runtimeId).toBe("test-runtime"); + expect(result?.containerName).toBe("test-runtime"); + expect(result?.backend?.id).toBe("test-backend"); + } finally { + restore(); + } + }, 15_000); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8ac65795d0f..b52cb5ab050 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -11,6 +11,12 @@ export { DEFAULT_SANDBOX_IMAGE, } from "./sandbox/constants.js"; export { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "./sandbox/backend.js"; export { buildSandboxCreateArgs } from "./sandbox/docker.js"; export { @@ -27,6 +33,20 @@ export { } from "./sandbox/runtime-status.js"; export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; +export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; + +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, +} from "./sandbox/backend.js"; export type { SandboxBrowserConfig, diff --git a/src/agents/sandbox/backend.test.ts b/src/agents/sandbox/backend.test.ts new file mode 100644 index 00000000000..6878e768945 --- /dev/null +++ b/src/agents/sandbox/backend.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, +} from "./backend.js"; + +describe("sandbox backend registry", () => { + it("registers and restores backend factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const restore = registerSandboxBackend("test-backend", factory); + expect(getSandboxBackendFactory("test-backend")).toBe(factory); + restore(); + expect(getSandboxBackendFactory("test-backend")).toBeNull(); + }); + + it("registers backend managers alongside factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const manager = { + describeRuntime: async () => ({ + running: true, + configLabelMatch: true, + }), + removeRuntime: async () => {}, + }; + const restore = registerSandboxBackend("test-managed", { + factory, + manager, + }); + expect(getSandboxBackendFactory("test-managed")).toBe(factory); + expect(getSandboxBackendManager("test-managed")).toBe(manager); + restore(); + expect(getSandboxBackendManager("test-managed")).toBeNull(); + }); +}); diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts new file mode 100644 index 00000000000..c186b0fe4cc --- /dev/null +++ b/src/agents/sandbox/backend.ts @@ -0,0 +1,148 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxFsBridge } from "./fs-bridge.js"; +import type { SandboxRegistryEntry } from "./registry.js"; +import type { SandboxConfig, SandboxContext } from "./types.js"; + +export type SandboxBackendId = string; + +export type SandboxBackendExecSpec = { + argv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open" | "pipe-closed"; + finalizeToken?: unknown; +}; + +export type SandboxBackendCommandParams = { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; +}; + +export type SandboxBackendCommandResult = { + stdout: Buffer; + stderr: Buffer; + code: number; +}; + +export type SandboxBackendHandle = { + id: SandboxBackendId; + runtimeId: string; + runtimeLabel: string; + workdir: string; + env?: Record; + configLabel?: string; + configLabelKind?: string; + capabilities?: { + browser?: boolean; + }; + buildExecSpec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; + runShellCommand(params: SandboxBackendCommandParams): Promise; + createFsBridge?: (params: { sandbox: SandboxContext }) => SandboxFsBridge; +}; + +export type SandboxBackendRuntimeInfo = { + running: boolean; + actualConfigLabel?: string; + configLabelMatch: boolean; +}; + +export type SandboxBackendManager = { + describeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; + removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; +}; + +export type CreateSandboxBackendParams = { + sessionKey: string; + scopeKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + cfg: SandboxConfig; +}; + +export type SandboxBackendFactory = ( + params: CreateSandboxBackendParams, +) => Promise; + +export type SandboxBackendRegistration = + | SandboxBackendFactory + | { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; + }; + +type RegisteredSandboxBackend = { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; +}; + +const SANDBOX_BACKEND_FACTORIES = new Map(); + +function normalizeSandboxBackendId(id: string): SandboxBackendId { + const normalized = id.trim().toLowerCase(); + if (!normalized) { + throw new Error("Sandbox backend id must not be empty."); + } + return normalized; +} + +export function registerSandboxBackend( + id: string, + registration: SandboxBackendRegistration, +): () => void { + const normalizedId = normalizeSandboxBackendId(id); + const resolved = typeof registration === "function" ? { factory: registration } : registration; + const previous = SANDBOX_BACKEND_FACTORIES.get(normalizedId); + SANDBOX_BACKEND_FACTORIES.set(normalizedId, resolved); + return () => { + if (previous) { + SANDBOX_BACKEND_FACTORIES.set(normalizedId, previous); + return; + } + SANDBOX_BACKEND_FACTORIES.delete(normalizedId); + }; +} + +export function getSandboxBackendFactory(id: string): SandboxBackendFactory | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.factory ?? null; +} + +export function getSandboxBackendManager(id: string): SandboxBackendManager | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.manager ?? null; +} + +export function requireSandboxBackendFactory(id: string): SandboxBackendFactory { + const factory = getSandboxBackendFactory(id); + if (factory) { + return factory; + } + throw new Error( + [ + `Sandbox backend "${id}" is not registered.`, + "Load the plugin that provides it, or set agents.defaults.sandbox.backend=docker.", + ].join("\n"), + ); +} + +import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; + +registerSandboxBackend("docker", { + factory: createDockerSandboxBackend, + manager: dockerSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 077db23c53b..c62276c6b87 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -48,6 +48,7 @@ vi.mock("../../browser/bridge-server.js", () => ({ function buildConfig(enableNoVnc: boolean): SandboxConfig { return { mode: "all", + backend: "docker", scope: "session", workspaceAccess: "none", workspaceRoot: "/tmp/openclaw-sandboxes", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index b7595ae8c4b..dda3e048ea7 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -189,6 +189,7 @@ export function resolveSandboxConfigForAgent( return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", + backend: agentSandbox?.backend?.trim() || agent?.backend?.trim() || "docker", scope, workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", workspaceRoot: diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 8468dd2c556..031b7c45998 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -7,11 +7,12 @@ import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; import { syncSkillsToWorkspace } from "../skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; +import { requireSandboxBackendFactory } from "./backend.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; -import { ensureSandboxContainer } from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { maybePruneSandboxes } from "./prune.js"; +import { updateRegistry } from "./registry.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js"; @@ -131,12 +132,24 @@ export async function resolveSandboxContext(params: { }); const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker }; - const containerName = await ensureSandboxContainer({ + const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend); + const backend = await backendFactory({ sessionKey: rawSessionKey, + scopeKey, workspaceDir, agentWorkspaceDir, cfg: resolvedCfg, }); + await updateRegistry({ + containerName: backend.runtimeId, + backendId: backend.id, + runtimeLabel: backend.runtimeLabel, + sessionKey: scopeKey, + createdAtMs: Date.now(), + lastUsedAtMs: Date.now(), + image: backend.configLabel ?? resolvedCfg.docker.image, + configLabelKind: backend.configLabelKind ?? "Image", + }); const evaluateEnabled = params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; @@ -157,30 +170,44 @@ export async function resolveSandboxContext(params: { return browserAuth; })() : undefined; - const browser = await ensureSandboxBrowser({ - scopeKey, - workspaceDir, - agentWorkspaceDir, - cfg: resolvedCfg, - evaluateEnabled, - bridgeAuth, - }); + if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) { + throw new Error( + `Sandbox backend "${resolvedCfg.backend}" does not support browser sandboxes yet.`, + ); + } + const browser = + resolvedCfg.browser.enabled && backend.capabilities?.browser === true + ? await ensureSandboxBrowser({ + scopeKey, + workspaceDir, + agentWorkspaceDir, + cfg: resolvedCfg, + evaluateEnabled, + bridgeAuth, + }) + : null; const sandboxContext: SandboxContext = { enabled: true, + backendId: backend.id, sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, workspaceAccess: resolvedCfg.workspaceAccess, - containerName, - containerWorkdir: resolvedCfg.docker.workdir, + runtimeId: backend.runtimeId, + runtimeLabel: backend.runtimeLabel, + containerName: backend.runtimeId, + containerWorkdir: backend.workdir, docker: resolvedCfg.docker, tools: resolvedCfg.tools, browserAllowHostControl: resolvedCfg.browser.allowHostControl, browser: browser ?? undefined, + backend, }; - sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext }); + sandboxContext.fsBridge = + backend.createFsBridge?.({ sandbox: sandboxContext }) ?? + createSandboxFsBridge({ sandbox: sandboxContext }); return sandboxContext; } diff --git a/src/agents/sandbox/docker-backend.ts b/src/agents/sandbox/docker-backend.ts new file mode 100644 index 00000000000..9686dc4b612 --- /dev/null +++ b/src/agents/sandbox/docker-backend.ts @@ -0,0 +1,130 @@ +import { buildDockerExecArgs } from "../bash-tools.shared.js"; +import type { + CreateSandboxBackendParams, + SandboxBackendManager, + SandboxBackendCommandParams, + SandboxBackendHandle, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + dockerContainerState, + ensureSandboxContainer, + execDocker, + execDockerRaw, +} from "./docker.js"; + +export async function createDockerSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + const containerName = await ensureSandboxContainer({ + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + cfg: params.cfg, + }); + return createDockerSandboxBackendHandle({ + containerName, + workdir: params.cfg.docker.workdir, + env: params.cfg.docker.env, + image: params.cfg.docker.image, + }); +} + +export function createDockerSandboxBackendHandle(params: { + containerName: string; + workdir: string; + env?: Record; + image: string; +}): SandboxBackendHandle { + return { + id: "docker", + runtimeId: params.containerName, + runtimeLabel: params.containerName, + workdir: params.workdir, + env: params.env, + configLabel: params.image, + configLabelKind: "Image", + capabilities: { + browser: true, + }, + async buildExecSpec({ command, workdir, env, usePty }) { + return { + argv: [ + "docker", + ...buildDockerExecArgs({ + containerName: params.containerName, + command, + workdir: workdir ?? params.workdir, + env, + tty: usePty, + }), + ], + env: process.env, + stdinMode: usePty ? "pipe-open" : "pipe-closed", + }; + }, + runShellCommand(command) { + return runDockerSandboxShellCommand({ + containerName: params.containerName, + ...command, + }); + }, + }; +} + +export function runDockerSandboxShellCommand( + params: { + containerName: string; + } & SandboxBackendCommandParams, +) { + const dockerArgs = [ + "exec", + "-i", + params.containerName, + "sh", + "-c", + params.script, + "moltbot-sandbox-fs", + ]; + if (params.args?.length) { + dockerArgs.push(...params.args); + } + return execDockerRaw(dockerArgs, { + input: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); +} + +export const dockerSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const state = await dockerContainerState(entry.containerName); + let actualConfigLabel = entry.image; + if (state.exists) { + try { + const result = await execDocker( + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualConfigLabel = result.stdout.trim() || actualConfigLabel; + } + } catch { + // ignore inspect failures + } + } + const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; + return { + running: state.running, + actualConfigLabel, + configLabelMatch: actualConfigLabel === configuredImage, + }; + }, + async removeRuntime({ entry }) { + try { + await execDocker(["rm", "-f", entry.containerName], { allowFailure: true }); + } catch { + // ignore removal failures + } + }, +}; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b2cd24c6630..54941ba04d1 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -91,6 +91,7 @@ function createSandboxConfig( ): SandboxConfig { return { mode: "all", + backend: "docker", scope: "shared", workspaceAccess, workspaceRoot: "~/.openclaw/sandboxes", diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index aefceb08495..80a2921cb6b 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -557,10 +557,13 @@ export async function ensureSandboxContainer(params: { } await updateRegistry({ containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, + configLabelKind: "Image", configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, }); return containerName; diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 7a9a22d4459..7941b2b6828 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; -import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; +import { runDockerSandboxShellCommand } from "./docker-backend.js"; import { buildPinnedMkdirpPlan, buildPinnedRemovePlan, @@ -23,7 +24,7 @@ type RunCommandOptions = { }; export type SandboxResolvedPath = { - hostPath: string; + hostPath?: string; relativePath: string; containerPath: string; }; @@ -248,21 +249,22 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCommand( script: string, options: RunCommandOptions = {}, - ): Promise { - const dockerArgs = [ - "exec", - "-i", - this.sandbox.containerName, - "sh", - "-c", - script, - "moltbot-sandbox-fs", - ]; - if (options.args?.length) { - dockerArgs.push(...options.args); + ): Promise { + const backend = this.sandbox.backend; + if (backend) { + return await backend.runShellCommand({ + script, + args: options.args, + stdin: options.stdin, + allowFailure: options.allowFailure, + signal: options.signal, + }); } - return execDockerRaw(dockerArgs, { - input: options.stdin, + return await runDockerSandboxShellCommand({ + containerName: this.sandbox.containerName, + script, + args: options.args, + stdin: options.stdin, allowFailure: options.allowFailure, signal: options.signal, }); @@ -279,7 +281,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCheckedCommand( plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal }, - ): Promise { + ): Promise { await this.pathGuard.assertPathChecks(plan.checks); if (plan.recheckBeforeCommand) { await this.pathGuard.assertPathChecks(plan.checks); @@ -295,7 +297,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runPlannedCommand( plan: SandboxFsCommandPlan, signal?: AbortSignal, - ): Promise { + ): Promise { return await this.runCheckedCommand({ ...plan, signal }); } diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index f6988146e90..0b5ba578d7d 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -1,8 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { loadConfig } from "../../config/config.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { resolveSandboxConfigForAgent } from "./config.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -23,80 +23,92 @@ export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & { imageMatch: boolean; }; -async function listSandboxRegistryItems< - TEntry extends { containerName: string; image: string; sessionKey: string }, ->(params: { - read: () => Promise<{ entries: TEntry[] }>; - resolveConfiguredImage: (agentId?: string) => string; -}): Promise> { - const registry = await params.read(); - const results: Array = []; +export async function listSandboxContainers(): Promise { + const config = loadConfig(); + const registry = await readRegistry(); + const results: SandboxContainerInfo[] = []; for (const entry of registry.entries) { - const state = await dockerContainerState(entry.containerName); - // Get actual image from container. - let actualImage = entry.image; - if (state.exists) { - try { - const result = await execDocker( - ["inspect", "-f", "{{.Config.Image}}", entry.containerName], - { allowFailure: true }, - ); - if (result.code === 0) { - actualImage = result.stdout.trim(); - } - } catch { - // ignore - } + const backendId = entry.backendId ?? "docker"; + const manager = getSandboxBackendManager(backendId); + if (!manager) { + results.push({ + ...entry, + running: false, + imageMatch: true, + }); + continue; } const agentId = resolveSandboxAgentId(entry.sessionKey); - const configuredImage = params.resolveConfiguredImage(agentId); + const runtime = await manager.describeRuntime({ + entry, + config, + agentId, + }); results.push({ ...entry, - image: actualImage, - running: state.running, - imageMatch: actualImage === configuredImage, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, }); } return results; } -export async function listSandboxContainers(): Promise { - const config = loadConfig(); - return listSandboxRegistryItems({ - read: readRegistry, - resolveConfiguredImage: (agentId) => resolveSandboxConfigForAgent(config, agentId).docker.image, - }); -} - export async function listSandboxBrowsers(): Promise { const config = loadConfig(); - return listSandboxRegistryItems({ - read: readBrowserRegistry, - resolveConfiguredImage: (agentId) => - resolveSandboxConfigForAgent(config, agentId).browser.image, - }); + const registry = await readBrowserRegistry(); + const results: SandboxBrowserInfo[] = []; + + for (const entry of registry.entries) { + const agentId = resolveSandboxAgentId(entry.sessionKey); + const runtime = await dockerSandboxBackendManager.describeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + config, + agentId, + }); + results.push({ + ...entry, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, + }); + } + + return results; } export async function removeSandboxContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readBrowserRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); } await removeBrowserRegistryEntry(containerName); - // Stop browser bridge if active for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) { if (bridge.containerName === containerName) { await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 45e7fda6308..6ccfd8ac238 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,7 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { defaultRuntime } from "../../runtime.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -16,7 +17,7 @@ let lastPruneAtMs = 0; type PruneableRegistryEntry = Pick< SandboxRegistryEntry, - "containerName" | "createdAtMs" | "lastUsedAtMs" + "containerName" | "backendId" | "createdAtMs" | "lastUsedAtMs" >; function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: PruneableRegistryEntry) { @@ -33,10 +34,11 @@ function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: Pruneab ); } -async function pruneSandboxRegistryEntries(params: { +async function pruneSandboxRegistryEntries(params: { cfg: SandboxConfig; read: () => Promise<{ entries: TEntry[] }>; remove: (containerName: string) => Promise; + removeRuntime: (entry: TEntry) => Promise; onRemoved?: (entry: TEntry) => Promise; }) { const now = Date.now(); @@ -49,9 +51,7 @@ async function pruneSandboxRegistryEntries { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); + }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { - await pruneSandboxRegistryEntries({ + await pruneSandboxRegistryEntries< + SandboxBrowserRegistryEntry & { + backendId?: string; + runtimeLabel?: string; + configLabelKind?: string; + } + >({ cfg, read: readBrowserRegistry, remove: removeBrowserRegistryEntry, + removeRuntime: async (entry) => { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); + }, onRemoved: async (entry) => { const bridge = BROWSER_BRIDGES.get(entry.sessionKey); if (bridge?.containerName === entry.containerName) { @@ -103,10 +123,3 @@ export async function maybePruneSandboxes(cfg: SandboxConfig) { defaultRuntime.error?.(`Sandbox prune failed: ${message ?? "unknown error"}`); } } - -export async function ensureDockerContainerIsRunning(containerName: string) { - const state = await dockerContainerState(containerName); - if (state.exists && !state.running) { - await execDocker(["start", containerName]); - } -} diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 2de75190bf8..059e6f77c88 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -172,6 +172,28 @@ async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) { } describe("registry race safety", () => { + it("normalizes legacy registry entries on read", async () => { + await seedContainerRegistry([ + { + containerName: "legacy-container", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:test", + }, + ]); + + const registry = await readRegistry(); + expect(registry.entries).toEqual([ + expect.objectContaining({ + containerName: "legacy-container", + backendId: "docker", + runtimeLabel: "legacy-container", + configLabelKind: "Image", + }), + ]); + }); + it("keeps both container updates under concurrent writes", async () => { await Promise.all([ updateRegistry(containerEntry({ containerName: "container-a" })), diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 54bb361934b..f8efebbf32b 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -5,10 +5,13 @@ import { SANDBOX_BROWSER_REGISTRY_PATH, SANDBOX_REGISTRY_PATH } from "./constant export type SandboxRegistryEntry = { containerName: string; + backendId?: string; + runtimeLabel?: string; sessionKey: string; createdAtMs: number; lastUsedAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -42,8 +45,11 @@ type RegistryFile = { }; type UpsertEntry = RegistryEntry & { + backendId?: string; + runtimeLabel?: string; createdAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -55,6 +61,15 @@ function isRegistryEntry(value: unknown): value is RegistryEntry { return isRecord(value) && typeof value.containerName === "string"; } +function normalizeSandboxRegistryEntry(entry: SandboxRegistryEntry): SandboxRegistryEntry { + return { + ...entry, + backendId: entry.backendId?.trim() || "docker", + runtimeLabel: entry.runtimeLabel?.trim() || entry.containerName, + configLabelKind: entry.configLabelKind?.trim() || "Image", + }; +} + function isRegistryFile(value: unknown): value is RegistryFile { if (!isRecord(value)) { return false; @@ -110,7 +125,13 @@ async function writeRegistryFile( } export async function readRegistry(): Promise { - return await readRegistryFromFile(SANDBOX_REGISTRY_PATH, "fallback"); + const registry = await readRegistryFromFile( + SANDBOX_REGISTRY_PATH, + "fallback", + ); + return { + entries: registry.entries.map((entry) => normalizeSandboxRegistryEntry(entry)), + }; } function upsertEntry(entries: T[], entry: T): T[] { @@ -118,8 +139,11 @@ function upsertEntry(entries: T[], entry: T): T[] { const next = entries.filter((item) => item.containerName !== entry.containerName); next.push({ ...entry, + backendId: entry.backendId ?? existing?.backendId, + runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image, + configLabelKind: entry.configLabelKind ?? existing?.configLabelKind, configHash: entry.configHash ?? existing?.configHash, }); return next; diff --git a/src/agents/sandbox/test-fixtures.ts b/src/agents/sandbox/test-fixtures.ts index db3835dcba5..b20b5b452f7 100644 --- a/src/agents/sandbox/test-fixtures.ts +++ b/src/agents/sandbox/test-fixtures.ts @@ -28,10 +28,13 @@ export function createSandboxTestContext(params?: { return { enabled: true, + backendId: "docker", sessionKey: "sandbox:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", workspaceAccess: "rw", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", tools: { allow: ["*"], deny: [] }, diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 4ccfd691cfb..8244583ea0c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,3 +1,4 @@ +import type { SandboxBackendHandle, SandboxBackendId } from "./backend.js"; import type { SandboxFsBridge } from "./fs-bridge.js"; import type { SandboxDockerConfig } from "./types.docker.js"; @@ -54,6 +55,7 @@ export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { mode: "off" | "non-main" | "all"; + backend: SandboxBackendId; scope: SandboxScope; workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; @@ -71,10 +73,13 @@ export type SandboxBrowserContext = { export type SandboxContext = { enabled: boolean; + backendId: SandboxBackendId; sessionKey: string; workspaceDir: string; agentWorkspaceDir: string; workspaceAccess: SandboxWorkspaceAccess; + runtimeId: string; + runtimeLabel: string; containerName: string; containerWorkdir: string; docker: SandboxDockerConfig; @@ -82,6 +87,7 @@ export type SandboxContext = { browserAllowHostControl: boolean; browser?: SandboxBrowserContext; fsBridge?: SandboxFsBridge; + backend?: SandboxBackendHandle; }; export type SandboxWorkspaceInfo = { diff --git a/src/agents/test-helpers/host-sandbox-fs-bridge.ts b/src/agents/test-helpers/host-sandbox-fs-bridge.ts index 93bb34969a8..fc466f0ea67 100644 --- a/src/agents/test-helpers/host-sandbox-fs-bridge.ts +++ b/src/agents/test-helpers/host-sandbox-fs-bridge.ts @@ -10,10 +10,16 @@ export function createSandboxFsBridgeFromResolver( resolvePath: ({ filePath, cwd }) => resolvePath(filePath, cwd), readFile: async ({ filePath, cwd }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } return fs.readFile(target.hostPath); }, writeFile: async ({ filePath, cwd, data, mkdir = true }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } if (mkdir) { await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); } @@ -22,10 +28,16 @@ export function createSandboxFsBridgeFromResolver( }, mkdirp: async ({ filePath, cwd }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } await fs.mkdir(target.hostPath, { recursive: true }); }, remove: async ({ filePath, cwd, recursive, force }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } await fs.rm(target.hostPath, { recursive: recursive ?? false, force: force ?? false, @@ -34,12 +46,20 @@ export function createSandboxFsBridgeFromResolver( rename: async ({ from, to, cwd }) => { const source = resolvePath(from, cwd); const target = resolvePath(to, cwd); + if (!source.hostPath || !target.hostPath) { + throw new Error( + `Expected hostPath for rename: ${source.containerPath} -> ${target.containerPath}`, + ); + } await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); await fs.rename(source.hostPath, target.hostPath); }, stat: async ({ filePath, cwd }) => { try { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } const stats = await fs.stat(target.hostPath); return { type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", diff --git a/src/agents/test-helpers/pi-tools-sandbox-context.ts b/src/agents/test-helpers/pi-tools-sandbox-context.ts index 286c5eed685..abf712c2c0b 100644 --- a/src/agents/test-helpers/pi-tools-sandbox-context.ts +++ b/src/agents/test-helpers/pi-tools-sandbox-context.ts @@ -18,10 +18,13 @@ export function createPiToolsSandboxContext(params: PiToolsSandboxContextParams) const workspaceDir = params.workspaceDir; return { enabled: true, + backendId: "docker", sessionKey: params.sessionKey ?? "sandbox:test", workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir ?? workspaceDir, workspaceAccess: params.workspaceAccess ?? "rw", + runtimeId: params.containerName ?? "openclaw-sbx-test", + runtimeLabel: params.containerName ?? "openclaw-sbx-test", containerName: params.containerName ?? "openclaw-sbx-test", containerWorkdir: params.containerWorkdir ?? "/workspace", fsBridge: params.fsBridge, diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 5141b8dc559..e0711ecf8ae 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -11,6 +11,8 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../../channels/plugins/types.js"; +import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; @@ -805,7 +807,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } } - const cfg = options?.config ?? loadConfig(); + const cfg = options?.config + ? options.config + : ( + await resolveCommandSecretRefsViaGateway({ + config: loadConfig(), + commandName: "tools.message", + targetIds: getChannelsCommandSecretTargetIds(), + mode: "enforce_resolved", + }) + ).resolvedConfig; const action = readStringParam(params, "action", { required: true, }) as ChannelMessageActionName; diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 4dc57abf80d..86d03650eb6 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -206,27 +206,33 @@ function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boole return false; } +export async function extractBasicHtmlContent(params: { + html: string; + extractMode: ExtractMode; +}): Promise<{ text: string; title?: string } | null> { + const cleanHtml = await sanitizeHtml(params.html); + const rendered = htmlToMarkdown(cleanHtml); + if (params.extractMode === "text") { + const text = + stripInvisibleUnicode(markdownToText(rendered.text)) || + stripInvisibleUnicode(normalizeWhitespace(stripTags(cleanHtml))); + return text ? { text, title: rendered.title } : null; + } + const text = stripInvisibleUnicode(rendered.text); + return text ? { text, title: rendered.title } : null; +} + export async function extractReadableContent(params: { html: string; url: string; extractMode: ExtractMode; }): Promise<{ text: string; title?: string } | null> { const cleanHtml = await sanitizeHtml(params.html); - const fallback = (): { text: string; title?: string } => { - const rendered = htmlToMarkdown(cleanHtml); - if (params.extractMode === "text") { - const text = - stripInvisibleUnicode(markdownToText(rendered.text)) || - stripInvisibleUnicode(normalizeWhitespace(stripTags(cleanHtml))); - return { text, title: rendered.title }; - } - return { text: stripInvisibleUnicode(rendered.text), title: rendered.title }; - }; if ( cleanHtml.length > READABILITY_MAX_HTML_CHARS || exceedsEstimatedHtmlNestingDepth(cleanHtml, READABILITY_MAX_ESTIMATED_NESTING_DEPTH) ) { - return fallback(); + return null; } try { const { Readability, parseHTML } = await loadReadabilityDeps(); @@ -239,16 +245,17 @@ export async function extractReadableContent(params: { const reader = new Readability(document, { charThreshold: 0 }); const parsed = reader.parse(); if (!parsed?.content) { - return fallback(); + return null; } const title = parsed.title || undefined; if (params.extractMode === "text") { const text = stripInvisibleUnicode(normalizeWhitespace(parsed.textContent ?? "")); - return text ? { text, title } : fallback(); + return text ? { text, title } : null; } const rendered = htmlToMarkdown(parsed.content); - return { text: stripInvisibleUnicode(rendered.text), title: title ?? rendered.title }; + const text = stripInvisibleUnicode(rendered.text); + return text ? { text, title: title ?? rendered.title } : null; } catch { - return fallback(); + return null; } } diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index f4cc88e2d83..92f94bf3a28 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -10,13 +10,14 @@ import { stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { + extractBasicHtmlContent, extractReadableContent, htmlToMarkdown, markdownToText, truncateText, type ExtractMode, } from "./web-fetch-utils.js"; -import { fetchWithWebToolsNetworkGuard } from "./web-guarded-fetch.js"; +import { fetchWithWebToolsNetworkGuard, withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; import { CacheEntry, DEFAULT_CACHE_TTL_MINUTES, @@ -26,7 +27,6 @@ import { readResponseText, resolveCacheTtlMs, resolveTimeoutSeconds, - withTimeout, writeCache, } from "./web-shared.js"; @@ -161,11 +161,12 @@ function resolveFirecrawlEnabled(params: { } function resolveFirecrawlBaseUrl(firecrawl?: FirecrawlFetchConfig): string { - const raw = + const fromConfig = firecrawl && "baseUrl" in firecrawl && typeof firecrawl.baseUrl === "string" ? firecrawl.baseUrl.trim() : ""; - return raw || DEFAULT_FIRECRAWL_BASE_URL; + const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_BASE_URL); + return fromConfig || fromEnv || DEFAULT_FIRECRAWL_BASE_URL; } function resolveFirecrawlOnlyMainContent(firecrawl?: FirecrawlFetchConfig): boolean { @@ -381,54 +382,59 @@ export async function fetchFirecrawlContent(params: { proxy: params.proxy, storeInCache: params.storeInCache, }; - - const res = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", + return await withTrustedWebToolsEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, }, - body: JSON.stringify(body), - signal: withTimeout(undefined, params.timeoutSeconds * 1000), - }); - - const payload = (await res.json()) as { - success?: boolean; - data?: { - markdown?: string; - content?: string; - metadata?: { - title?: string; - sourceURL?: string; - statusCode?: number; + async ({ response }) => { + const payload = (await response.json()) as { + success?: boolean; + data?: { + markdown?: string; + content?: string; + metadata?: { + title?: string; + sourceURL?: string; + statusCode?: number; + }; + }; + warning?: string; + error?: string; }; - }; - warning?: string; - error?: string; - }; - if (!res.ok || payload?.success === false) { - const detail = payload?.error ?? ""; - throw new Error( - `Firecrawl fetch failed (${res.status}): ${wrapWebContent(detail || res.statusText, "web_fetch")}`.trim(), - ); - } + if (!response.ok || payload?.success === false) { + const detail = payload?.error ?? ""; + throw new Error( + `Firecrawl fetch failed (${response.status}): ${wrapWebContent(detail || response.statusText, "web_fetch")}`.trim(), + ); + } - const data = payload?.data ?? {}; - const rawText = - typeof data.markdown === "string" - ? data.markdown - : typeof data.content === "string" - ? data.content - : ""; - const text = params.extractMode === "text" ? markdownToText(rawText) : rawText; - return { - text, - title: data.metadata?.title, - finalUrl: data.metadata?.sourceURL, - status: data.metadata?.statusCode, - warning: payload?.warning, - }; + const data = payload?.data ?? {}; + const rawText = + typeof data.markdown === "string" + ? data.markdown + : typeof data.content === "string" + ? data.content + : ""; + const text = params.extractMode === "text" ? markdownToText(rawText) : rawText; + return { + text, + title: data.metadata?.title, + finalUrl: data.metadata?.sourceURL, + status: data.metadata?.statusCode, + warning: payload?.warning, + }; + }, + ); } type FirecrawlRuntimeParams = { @@ -629,9 +635,19 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise { expect(authHeader).toBe("Bearer firecrawl-test-key"); }); + it("uses FIRECRAWL_BASE_URL env var when firecrawl.baseUrl is unset", async () => { + vi.stubEnv("FIRECRAWL_BASE_URL", "https://fc.example.com"); + + expect(webFetchTesting.resolveFirecrawlBaseUrl({})).toBe("https://fc.example.com"); + }); + + it("uses guarded endpoint fetch for firecrawl requests", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + + const fetchSpy = installMockFetch((input: RequestInfo | URL) => { + const url = resolveRequestUrl(input); + if (url.includes("api.firecrawl.dev/v2/scrape")) { + return Promise.resolve( + firecrawlResponse("firecrawl guarded transport"), + ) as Promise; + } + return Promise.resolve( + htmlResponse("", url), + ) as Promise; + }); + + const tool = createFirecrawlTool(); + const result = await executeFetch(tool, { url: "https://example.com/guarded-firecrawl" }); + + expect(result?.details).toMatchObject({ extractor: "firecrawl" }); + const firecrawlCall = fetchSpy.mock.calls.find((call) => + resolveRequestUrl(call[0]).includes("/v2/scrape"), + ); + expect(firecrawlCall).toBeTruthy(); + const requestInit = firecrawlCall?.[1] as (RequestInit & { dispatcher?: unknown }) | undefined; + expect(requestInit?.dispatcher).toBeDefined(); + expect(requestInit?.dispatcher).toBeInstanceOf(EnvHttpProxyAgent); + }); + it("throws when readability is disabled and firecrawl is unavailable", async () => { installMockFetch( (input: RequestInfo | URL) => @@ -356,7 +391,29 @@ describe("web_fetch extraction fallbacks", () => { const tool = createFirecrawlTool(); await expect( executeFetch(tool, { url: "https://example.com/readability-empty" }), - ).rejects.toThrow("Readability and Firecrawl returned no content"); + ).rejects.toThrow("Readability, Firecrawl, and basic HTML cleanup returned no content"); + }); + + it("falls back to basic HTML cleanup after readability and before giving up", async () => { + installMockFetch( + (input: RequestInfo | URL) => + Promise.resolve( + htmlResponse( + "Shell App
", + resolveRequestUrl(input), + ), + ) as Promise, + ); + + const tool = createFetchTool({ + firecrawl: { enabled: false }, + }); + const result = await executeFetch(tool, { url: "https://example.com/shell" }); + const details = result?.details as { extractor?: string; text?: string; title?: string }; + + expect(details.extractor).toBe("raw-html"); + expect(details.text).toContain("Shell App"); + expect(details.title).toContain("Shell App"); }); it("uses firecrawl when direct fetch fails", async () => { diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 792aa545a54..d9add345eeb 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -7,19 +7,19 @@ import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); const CHANNEL_ENV_PREFIXES = [ - "BLUEBUBBLES_", - "DISCORD_", - "GOOGLECHAT_", - "IRC_", - "LINE_", - "MATRIX_", - "MSTEAMS_", - "SIGNAL_", - "SLACK_", - "TELEGRAM_", - "WHATSAPP_", - "ZALOUSER_", - "ZALO_", + ["BLUEBUBBLES_", "bluebubbles"], + ["DISCORD_", "discord"], + ["GOOGLECHAT_", "googlechat"], + ["IRC_", "irc"], + ["LINE_", "line"], + ["MATRIX_", "matrix"], + ["MSTEAMS_", "msteams"], + ["SIGNAL_", "signal"], + ["SLACK_", "slack"], + ["TELEGRAM_", "telegram"], + ["WHATSAPP_", "whatsapp"], + ["ZALOUSER_", "zalouser"], + ["ZALO_", "zalo"], ] as const; function hasNonEmptyString(value: unknown): boolean { @@ -60,13 +60,49 @@ function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { } } +export function listPotentialConfiguredChannelIds( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const configuredChannelIds = new Set(); + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + configuredChannelIds.add(key); + } + } + } + + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + for (const [prefix, channelId] of CHANNEL_ENV_PREFIXES) { + if (key.startsWith(prefix)) { + configuredChannelIds.add(channelId); + } + } + if (key === "TELEGRAM_BOT_TOKEN") { + configuredChannelIds.add("telegram"); + } + } + if (hasWhatsAppAuthState(env)) { + configuredChannelIds.add("whatsapp"); + } + return [...configuredChannelIds]; +} + function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { continue; } if ( - CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + CHANNEL_ENV_PREFIXES.some(([prefix]) => key.startsWith(prefix)) || key === "TELEGRAM_BOT_TOKEN" ) { return true; diff --git a/src/channels/plugins/onboarding/helpers.test.ts b/src/channels/plugins/setup-flow-helpers.test.ts similarity index 96% rename from src/channels/plugins/onboarding/helpers.test.ts rename to src/channels/plugins/setup-flow-helpers.test.ts index f4d4c0c2f5a..3b24600372c 100644 --- a/src/channels/plugins/onboarding/helpers.test.ts +++ b/src/channels/plugins/setup-flow-helpers.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); -vi.mock("../../../plugin-sdk/onboarding.js", () => ({ +vi.mock("../../plugin-sdk/onboarding.js", () => ({ promptAccountId: promptAccountIdSdkMock, })); @@ -14,17 +14,17 @@ import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, patchChannelConfigForAccount, patchLegacyDmChannelConfig, promptLegacyChannelAllowFrom, - parseOnboardingEntriesWithParser, + parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptSingleChannelToken, promptResolvedAllowFrom, resolveAccountIdForConfigure, - resolveOnboardingAccountId, + resolveSetupAccountId, setAccountAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, @@ -33,9 +33,9 @@ import { setTopLevelChannelGroupPolicy, setLegacyChannelAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "./helpers.js"; + setSetupChannelEnabled, + splitSetupEntries, +} from "./setup-flow-helpers.js"; function createPrompter(inputs: string[]) { return { @@ -464,7 +464,7 @@ describe("promptParsedAllowFromForScopedChannel", () => { message: "msg", placeholder: "placeholder", parseEntries: (raw) => - parseOnboardingEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), + parseSetupEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), getExistingAllowFrom: ({ cfg }) => cfg.channels?.imessage?.allowFrom ?? [], }); @@ -748,7 +748,7 @@ describe("patchChannelConfigForAccount", () => { }); }); -describe("setOnboardingChannelEnabled", () => { +describe("setSetupChannelEnabled", () => { it("updates enabled and keeps existing channel fields", () => { const cfg: OpenClawConfig = { channels: { @@ -759,13 +759,13 @@ describe("setOnboardingChannelEnabled", () => { }, }; - const next = setOnboardingChannelEnabled(cfg, "discord", false); + const next = setSetupChannelEnabled(cfg, "discord", false); expect(next.channels?.discord?.enabled).toBe(false); expect(next.channels?.discord?.token).toBe("abc"); }); it("creates missing channel config with enabled state", () => { - const next = setOnboardingChannelEnabled({}, "signal", true); + const next = setSetupChannelEnabled({}, "signal", true); expect(next.channels?.signal?.enabled).toBe(true); }); }); @@ -1016,16 +1016,16 @@ describe("setTopLevelChannelGroupPolicy", () => { }); }); -describe("splitOnboardingEntries", () => { +describe("splitSetupEntries", () => { it("splits comma/newline/semicolon input and trims blanks", () => { - expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); + expect(splitSetupEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); }); }); -describe("parseOnboardingEntriesWithParser", () => { +describe("parseSetupEntriesWithParser", () => { it("maps entries and de-duplicates parsed values", () => { expect( - parseOnboardingEntriesWithParser(" alice, ALICE ; * ", (entry) => { + parseSetupEntriesWithParser(" alice, ALICE ; * ", (entry) => { if (entry === "*") { return { value: "*" }; } @@ -1038,7 +1038,7 @@ describe("parseOnboardingEntriesWithParser", () => { it("returns parser errors and clears parsed entries", () => { expect( - parseOnboardingEntriesWithParser("ok, bad", (entry) => + parseSetupEntriesWithParser("ok, bad", (entry) => entry === "bad" ? { error: "invalid entry: bad" } : { value: entry }, ), ).toEqual({ @@ -1048,10 +1048,10 @@ describe("parseOnboardingEntriesWithParser", () => { }); }); -describe("parseOnboardingEntriesAllowingWildcard", () => { +describe("parseSetupEntriesAllowingWildcard", () => { it("preserves wildcard and delegates non-wildcard entries", () => { expect( - parseOnboardingEntriesAllowingWildcard(" *, Foo ", (entry) => ({ + parseSetupEntriesAllowingWildcard(" *, Foo ", (entry) => ({ value: entry.toLowerCase(), })), ).toEqual({ @@ -1061,7 +1061,7 @@ describe("parseOnboardingEntriesAllowingWildcard", () => { it("returns parser errors for non-wildcard entries", () => { expect( - parseOnboardingEntriesAllowingWildcard("ok,bad", (entry) => + parseSetupEntriesAllowingWildcard("ok,bad", (entry) => entry === "bad" ? { error: "bad entry" } : { value: entry }, ), ).toEqual({ @@ -1129,10 +1129,10 @@ describe("normalizeAllowFromEntries", () => { }); }); -describe("resolveOnboardingAccountId", () => { +describe("resolveSetupAccountId", () => { it("normalizes provided account ids", () => { expect( - resolveOnboardingAccountId({ + resolveSetupAccountId({ accountId: " Work Account ", defaultAccountId: DEFAULT_ACCOUNT_ID, }), @@ -1141,7 +1141,7 @@ describe("resolveOnboardingAccountId", () => { it("falls back to default account id when input is blank", () => { expect( - resolveOnboardingAccountId({ + resolveSetupAccountId({ accountId: " ", defaultAccountId: "custom-default", }), diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/setup-flow-helpers.ts similarity index 94% rename from src/channels/plugins/onboarding/helpers.ts rename to src/channels/plugins/setup-flow-helpers.ts index d26999bd3ff..87a208a9a21 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/setup-flow-helpers.ts @@ -1,18 +1,18 @@ import { promptSecretRefForOnboarding, resolveSecretInputModeForEnvSelection, -} from "../../../commands/auth-choice.apply-helpers.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { DmPolicy, GroupPolicy } from "../../../config/types.js"; -import type { SecretInput } from "../../../config/types.secrets.js"; -import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboarding.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js"; +} from "../../commands/auth-choice.apply-helpers.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { DmPolicy, GroupPolicy } from "../../config/types.js"; +import type { SecretInput } from "../../config/types.secrets.js"; +import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/onboarding.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import type { PromptAccountId, PromptAccountIdParams } from "./setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, -} from "../setup-helpers.js"; +} from "./setup-helpers.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { return await promptAccountIdSdk(params); @@ -34,20 +34,20 @@ export function mergeAllowFromEntries( return [...new Set(merged)]; } -export function splitOnboardingEntries(raw: string): string[] { +export function splitSetupEntries(raw: string): string[] { return raw .split(/[\n,;]+/g) .map((entry) => entry.trim()) .filter(Boolean); } -type ParsedOnboardingEntry = { value: string } | { error: string }; +type ParsedSetupEntry = { value: string } | { error: string }; -export function parseOnboardingEntriesWithParser( +export function parseSetupEntriesWithParser( raw: string, - parseEntry: (entry: string) => ParsedOnboardingEntry, + parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { - const parts = splitOnboardingEntries(String(raw ?? "")); + const parts = splitSetupEntries(String(raw ?? "")); const entries: string[] = []; for (const part of parts) { const parsed = parseEntry(part); @@ -59,11 +59,11 @@ export function parseOnboardingEntriesWithParser( return { entries: normalizeAllowFromEntries(entries) }; } -export function parseOnboardingEntriesAllowingWildcard( +export function parseSetupEntriesAllowingWildcard( raw: string, - parseEntry: (entry: string) => ParsedOnboardingEntry, + parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { - return parseOnboardingEntriesWithParser(raw, (entry) => { + return parseSetupEntriesWithParser(raw, (entry) => { if (entry === "*") { return { value: "*" }; } @@ -117,7 +117,7 @@ export function normalizeAllowFromEntries( return [...new Set(normalized)]; } -export function resolveOnboardingAccountId(params: { +export function resolveSetupAccountId(params: { accountId?: string; defaultAccountId: string; }): string { @@ -338,7 +338,7 @@ export function patchLegacyDmChannelConfig(params: { }; } -export function setOnboardingChannelEnabled( +export function setSetupChannelEnabled( cfg: OpenClawConfig, channel: string, enabled: boolean, @@ -656,7 +656,7 @@ export async function promptParsedAllowFromForScopedChannel(params: { accountId: string; }) => Array; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: params.defaultAccountId, }); @@ -799,7 +799,7 @@ export async function promptLegacyChannelAllowFrom(params: { message: params.message, placeholder: params.placeholder, label: params.noteTitle, - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: params.parseId, invalidWithoutTokenNote: params.invalidWithoutTokenNote, resolveEntries: params.resolveEntries, diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/setup-flow-types.ts similarity index 70% rename from src/channels/plugins/onboarding-types.ts rename to src/channels/plugins/setup-flow-types.ts index f560b27b172..a3887cc7ef2 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/setup-flow-types.ts @@ -4,13 +4,18 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; +export type ChannelOnboardingSetupPlugin = Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" +>; + export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; onSelection?: (selection: ChannelId[]) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; - onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelOnboardingSetupPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; @@ -35,7 +40,7 @@ export type PromptAccountIdParams = { export type PromptAccountId = (params: PromptAccountIdParams) => Promise; -export type ChannelOnboardingStatus = { +export type ChannelSetupStatus = { channel: ChannelId; configured: boolean; statusLines: string[]; @@ -43,13 +48,13 @@ export type ChannelOnboardingStatus = { quickstartScore?: number; }; -export type ChannelOnboardingStatusContext = { +export type ChannelSetupStatusContext = { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; }; -export type ChannelOnboardingConfigureContext = { +export type ChannelSetupConfigureContext = { cfg: OpenClawConfig; runtime: RuntimeEnv; prompter: WizardPrompter; @@ -59,19 +64,19 @@ export type ChannelOnboardingConfigureContext = { forceAllowFrom: boolean; }; -export type ChannelOnboardingResult = { +export type ChannelSetupResult = { cfg: OpenClawConfig; accountId?: string; }; -export type ChannelOnboardingConfiguredResult = ChannelOnboardingResult | "skip"; +export type ChannelSetupConfiguredResult = ChannelSetupResult | "skip"; -export type ChannelOnboardingInteractiveContext = ChannelOnboardingConfigureContext & { +export type ChannelSetupInteractiveContext = ChannelSetupConfigureContext & { configured: boolean; label: string; }; -export type ChannelOnboardingDmPolicy = { +export type ChannelSetupDmPolicy = { label: string; channel: ChannelId; policyKey: string; @@ -85,17 +90,17 @@ export type ChannelOnboardingDmPolicy = { }) => Promise; }; -export type ChannelOnboardingAdapter = { +export type ChannelSetupFlowAdapter = { channel: ChannelId; - getStatus: (ctx: ChannelOnboardingStatusContext) => Promise; - configure: (ctx: ChannelOnboardingConfigureContext) => Promise; + getStatus: (ctx: ChannelSetupStatusContext) => Promise; + configure: (ctx: ChannelSetupConfigureContext) => Promise; configureInteractive?: ( - ctx: ChannelOnboardingInteractiveContext, - ) => Promise; + ctx: ChannelSetupInteractiveContext, + ) => Promise; configureWhenConfigured?: ( - ctx: ChannelOnboardingInteractiveContext, - ) => Promise; - dmPolicy?: ChannelOnboardingDmPolicy; + ctx: ChannelSetupInteractiveContext, + ) => Promise; + dmPolicy?: ChannelSetupDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; }; diff --git a/src/channels/plugins/onboarding/channel-access-configure.test.ts b/src/channels/plugins/setup-group-access-configure.test.ts similarity index 77% rename from src/channels/plugins/onboarding/channel-access-configure.test.ts rename to src/channels/plugins/setup-group-access-configure.test.ts index aba8f05ea95..bb3b0307501 100644 --- a/src/channels/plugins/onboarding/channel-access-configure.test.ts +++ b/src/channels/plugins/setup-group-access-configure.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; -import type { ChannelAccessPolicy } from "./channel-access.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; +import type { ChannelAccessPolicy } from "./setup-group-access.js"; function createPrompter(params: { confirm: boolean; policy?: ChannelAccessPolicy; text?: string }) { return { @@ -89,6 +89,41 @@ describe("configureChannelAccessWithAllowlist", () => { expect(applyAllowlist).not.toHaveBeenCalled(); }); + it("supports allowlist policies without prompting for entries", async () => { + const cfg: OpenClawConfig = {}; + const prompter = createPrompter({ + confirm: true, + policy: "allowlist", + }); + const setPolicy = vi.fn( + (next: OpenClawConfig, policy: ChannelAccessPolicy): OpenClawConfig => ({ + ...next, + channels: { twitch: { groupPolicy: policy } }, + }), + ); + const resolveAllowlist = vi.fn(async () => ["ignored"]); + const applyAllowlist = vi.fn((params: { cfg: OpenClawConfig }) => params.cfg); + + const next = await configureChannelAccessWithAllowlist({ + cfg, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Twitch chat", + currentPolicy: "disabled", + currentEntries: [], + placeholder: "", + updatePrompt: false, + skipAllowlistEntries: true, + setPolicy, + resolveAllowlist, + applyAllowlist, + }); + + expect(next.channels).toEqual({ twitch: { groupPolicy: "allowlist" } }); + expect(resolveAllowlist).not.toHaveBeenCalled(); + expect(applyAllowlist).not.toHaveBeenCalled(); + }); + it("resolves allowlist entries and applies them after forcing allowlist policy", async () => { const cfg: OpenClawConfig = {}; const prompter = createPrompter({ diff --git a/src/channels/plugins/onboarding/channel-access-configure.ts b/src/channels/plugins/setup-group-access-configure.ts similarity index 65% rename from src/channels/plugins/onboarding/channel-access-configure.ts rename to src/channels/plugins/setup-group-access-configure.ts index 200efce5811..26b07f9cf99 100644 --- a/src/channels/plugins/onboarding/channel-access-configure.ts +++ b/src/channels/plugins/setup-group-access-configure.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./channel-access.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./setup-group-access.js"; export async function configureChannelAccessWithAllowlist(params: { cfg: OpenClawConfig; @@ -10,9 +10,10 @@ export async function configureChannelAccessWithAllowlist(params: { currentEntries: string[]; placeholder: string; updatePrompt: boolean; + skipAllowlistEntries?: boolean; setPolicy: (cfg: OpenClawConfig, policy: ChannelAccessPolicy) => OpenClawConfig; - resolveAllowlist: (params: { cfg: OpenClawConfig; entries: string[] }) => Promise; - applyAllowlist: (params: { cfg: OpenClawConfig; resolved: TResolved }) => OpenClawConfig; + resolveAllowlist?: (params: { cfg: OpenClawConfig; entries: string[] }) => Promise; + applyAllowlist?: (params: { cfg: OpenClawConfig; resolved: TResolved }) => OpenClawConfig; }): Promise { let next = params.cfg; const accessConfig = await promptChannelAccessConfig({ @@ -22,6 +23,7 @@ export async function configureChannelAccessWithAllowlist(params: { currentEntries: params.currentEntries, placeholder: params.placeholder, updatePrompt: params.updatePrompt, + skipAllowlistEntries: params.skipAllowlistEntries, }); if (!accessConfig) { return next; @@ -29,6 +31,9 @@ export async function configureChannelAccessWithAllowlist(params: { if (accessConfig.policy !== "allowlist") { return params.setPolicy(next, accessConfig.policy); } + if (params.skipAllowlistEntries || !params.resolveAllowlist || !params.applyAllowlist) { + return params.setPolicy(next, "allowlist"); + } const resolved = await params.resolveAllowlist({ cfg: next, entries: accessConfig.entries, diff --git a/src/channels/plugins/onboarding/channel-access.test.ts b/src/channels/plugins/setup-group-access.test.ts similarity index 84% rename from src/channels/plugins/onboarding/channel-access.test.ts rename to src/channels/plugins/setup-group-access.test.ts index 0e5b2ba6651..a19ed348015 100644 --- a/src/channels/plugins/onboarding/channel-access.test.ts +++ b/src/channels/plugins/setup-group-access.test.ts @@ -5,7 +5,7 @@ import { promptChannelAccessConfig, promptChannelAllowlist, promptChannelAccessPolicy, -} from "./channel-access.js"; +} from "./setup-group-access.js"; function createPrompter(params?: { confirm?: (options: { message: string; initialValue: boolean }) => Promise; @@ -83,6 +83,27 @@ describe("promptChannelAccessPolicy", () => { }); }); +describe("promptChannelAccessConfig", () => { + it("skips the allowlist text prompt when entries are policy-only", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "allowlist", + text: async () => { + throw new Error("text prompt should not run"); + }, + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Twitch chat", + skipAllowlistEntries: true, + }); + + expect(result).toEqual({ policy: "allowlist", entries: [] }); + }); +}); + describe("promptChannelAccessConfig", () => { it("returns null when user skips configuration", async () => { const prompter = createPrompter({ diff --git a/src/channels/plugins/onboarding/channel-access.ts b/src/channels/plugins/setup-group-access.ts similarity index 91% rename from src/channels/plugins/onboarding/channel-access.ts rename to src/channels/plugins/setup-group-access.ts index ef86b37f336..b9130f7de51 100644 --- a/src/channels/plugins/onboarding/channel-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,10 +1,10 @@ -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { splitOnboardingEntries } from "./helpers.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { splitSetupEntries } from "./setup-flow-helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; export function parseAllowlistEntries(raw: string): string[] { - return splitOnboardingEntries(String(raw ?? "")); + return splitSetupEntries(String(raw ?? "")); } export function formatAllowlistEntries(entries: string[]): string { @@ -64,6 +64,7 @@ export async function promptChannelAccessConfig(params: { placeholder?: string; allowOpen?: boolean; allowDisabled?: boolean; + skipAllowlistEntries?: boolean; defaultPrompt?: boolean; updatePrompt?: boolean; }): Promise<{ policy: ChannelAccessPolicy; entries: string[] } | null> { @@ -88,6 +89,9 @@ export async function promptChannelAccessConfig(params: { if (policy !== "allowlist") { return { policy, entries: [] }; } + if (params.skipAllowlistEntries) { + return { policy, entries: [] }; + } const entries = await promptChannelAllowlist({ prompter: params.prompter, label: params.label, diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index 493b14351cc..a8c7212ca1f 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -1,3 +1,12 @@ +import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; +import { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; +import { ircPlugin } from "../../../extensions/irc/src/channel.js"; +import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.js"; +import { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; +import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; +import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.js"; +import { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; import { getActivePluginRegistryVersion, requireActivePluginRegistry, @@ -19,6 +28,18 @@ const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = { let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE; +const BUNDLED_CHANNEL_SETUP_PLUGINS = [ + telegramSetupPlugin, + whatsappSetupPlugin, + discordSetupPlugin, + ircPlugin, + googlechatPlugin, + slackSetupPlugin, + signalSetupPlugin, + imessageSetupPlugin, + lineSetupPlugin, +] as ChannelPlugin[]; + function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); const resolved: ChannelPlugin[] = []; @@ -33,17 +54,8 @@ function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { return resolved; } -function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { - const registry = requireActivePluginRegistry(); - const registryVersion = getActivePluginRegistryVersion(); - const cached = cachedChannelSetupPlugins; - if (cached.registryVersion === registryVersion) { - return cached; - } - - const sorted = dedupeSetupPlugins( - (registry.channelSetups ?? []).map((entry) => entry.plugin), - ).toSorted((a, b) => { +function sortChannelSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { + return dedupeSetupPlugins(plugins).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); @@ -53,6 +65,20 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { } return a.id.localeCompare(b.id); }); +} + +function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { + const registry = requireActivePluginRegistry(); + const registryVersion = getActivePluginRegistryVersion(); + const cached = cachedChannelSetupPlugins; + if (cached.registryVersion === registryVersion) { + return cached; + } + + const registryPlugins = (registry.channelSetups ?? []).map((entry) => entry.plugin); + const sorted = sortChannelSetupPlugins( + registryPlugins.length > 0 ? registryPlugins : BUNDLED_CHANNEL_SETUP_PLUGINS, + ); const byId = new Map(); for (const plugin of sorted) { byId.set(plugin.id, plugin); diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 9f4f1fdb5cc..66e7765ffe4 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -1,21 +1,21 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -import type { - ChannelOnboardingAdapter, - ChannelOnboardingConfigureContext, - ChannelOnboardingDmPolicy, - ChannelOnboardingStatus, - ChannelOnboardingStatusContext, -} from "./onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "./onboarding/channel-access-configure.js"; -import type { ChannelAccessPolicy } from "./onboarding/channel-access.js"; import { promptResolvedAllowFrom, resolveAccountIdForConfigure, runSingleChannelSecretStep, - splitOnboardingEntries, -} from "./onboarding/helpers.js"; + splitSetupEntries, +} from "./setup-flow-helpers.js"; +import type { + ChannelSetupFlowAdapter, + ChannelSetupConfigureContext, + ChannelSetupDmPolicy, + ChannelSetupStatus, + ChannelSetupStatusContext, +} from "./setup-flow-types.js"; +import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; +import type { ChannelAccessPolicy } from "./setup-group-access.js"; import type { ChannelSetupInput } from "./types.core.js"; import type { ChannelPlugin } from "./types.js"; @@ -184,6 +184,7 @@ export type ChannelSetupWizardGroupAccess = { placeholder: string; helpTitle?: string; helpLines?: string[]; + skipAllowlistEntries?: boolean; currentPolicy: (params: { cfg: OpenClawConfig; accountId: string }) => ChannelAccessPolicy; currentEntries: (params: { cfg: OpenClawConfig; accountId: string }) => string[]; updatePrompt: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; @@ -192,14 +193,14 @@ export type ChannelSetupWizardGroupAccess = { accountId: string; policy: ChannelAccessPolicy; }) => OpenClawConfig; - resolveAllowlist: (params: { + resolveAllowlist?: (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; entries: string[]; prompter: Pick; }) => Promise; - applyAllowlist: (params: { + applyAllowlist?: (params: { cfg: OpenClawConfig; accountId: string; resolved: unknown; @@ -210,9 +211,9 @@ export type ChannelSetupWizardPrepare = (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; - runtime: ChannelOnboardingConfigureContext["runtime"]; + runtime: ChannelSetupConfigureContext["runtime"]; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; }) => | { cfg?: OpenClawConfig; @@ -228,9 +229,9 @@ export type ChannelSetupWizardFinalize = (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; - runtime: ChannelOnboardingConfigureContext["runtime"]; + runtime: ChannelSetupConfigureContext["runtime"]; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; forceAllowFrom: boolean; }) => | { @@ -251,7 +252,7 @@ export type ChannelSetupWizard = { resolveAccountIdForConfigure?: (params: { cfg: OpenClawConfig; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; accountOverride?: string; shouldPromptAccountIds: boolean; listAccountIds: ChannelSetupWizardPlugin["config"]["listAccountIds"]; @@ -259,7 +260,7 @@ export type ChannelSetupWizard = { }) => string | Promise; resolveShouldPromptAccountIds?: (params: { cfg: OpenClawConfig; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; shouldPromptAccountIds: boolean; }) => boolean; prepare?: ChannelSetupWizardPrepare; @@ -268,11 +269,11 @@ export type ChannelSetupWizard = { textInputs?: ChannelSetupWizardTextInput[]; finalize?: ChannelSetupWizardFinalize; completionNote?: ChannelSetupWizardNote; - dmPolicy?: ChannelOnboardingDmPolicy; + dmPolicy?: ChannelSetupDmPolicy; allowFrom?: ChannelSetupWizardAllowFrom; groupAccess?: ChannelSetupWizardGroupAccess; disable?: (cfg: OpenClawConfig) => OpenClawConfig; - onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"]; + onAccountRecorded?: ChannelSetupFlowAdapter["onAccountRecorded"]; }; type ChannelSetupWizardPlugin = Pick; @@ -280,8 +281,8 @@ type ChannelSetupWizardPlugin = Pick { + ctx: ChannelSetupStatusContext, +): Promise { const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg }); const statusLines = (await wizard.status.resolveStatusLines?.({ cfg: ctx.cfg, @@ -398,10 +399,10 @@ async function applyWizardTextInputValue(params: { }).cfg; } -export function buildChannelOnboardingAdapterFromSetupWizard(params: { +export function buildChannelSetupFlowAdapterFromSetupWizard(params: { plugin: ChannelSetupWizardPlugin; wizard: ChannelSetupWizard; -}): ChannelOnboardingAdapter { +}): ChannelSetupFlowAdapter { const { plugin, wizard } = params; return { channel: plugin.id, @@ -757,26 +758,31 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { currentEntries: access.currentEntries({ cfg: next, accountId }), placeholder: access.placeholder, updatePrompt: access.updatePrompt({ cfg: next, accountId }), + skipAllowlistEntries: access.skipAllowlistEntries, setPolicy: (currentCfg, policy) => access.setPolicy({ cfg: currentCfg, accountId, policy, }), - resolveAllowlist: async ({ cfg: currentCfg, entries }) => - await access.resolveAllowlist({ - cfg: currentCfg, - accountId, - credentialValues, - entries, - prompter, - }), - applyAllowlist: ({ cfg: currentCfg, resolved }) => - access.applyAllowlist({ - cfg: currentCfg, - accountId, - resolved, - }), + resolveAllowlist: access.resolveAllowlist + ? async ({ cfg: currentCfg, entries }) => + await access.resolveAllowlist!({ + cfg: currentCfg, + accountId, + credentialValues, + entries, + prompter, + }) + : undefined, + applyAllowlist: access.applyAllowlist + ? ({ cfg: currentCfg, resolved }) => + access.applyAllowlist!({ + cfg: currentCfg, + accountId, + resolved, + }) + : undefined, }); } @@ -803,7 +809,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { message: allowFrom.message, placeholder: allowFrom.placeholder, label: allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, - parseInputs: allowFrom.parseInputs ?? splitOnboardingEntries, + parseInputs: allowFrom.parseInputs ?? splitSetupEntries, parseId: allowFrom.parseId, invalidWithoutTokenNote: allowFrom.invalidWithoutCredentialNote, resolveEntries: async ({ entries }) => diff --git a/src/channels/plugins/status.ts b/src/channels/plugins/status.ts index 689c50c6710..983ba23be33 100644 --- a/src/channels/plugins/status.ts +++ b/src/channels/plugins/status.ts @@ -41,17 +41,17 @@ async function buildSnapshotFromAccount(params: { }; } -function inspectChannelAccount(params: { +async function inspectChannelAccount(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; accountId: string; -}): ResolvedAccount | null { +}): Promise { return (params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: params.plugin.id, cfg: params.cfg, accountId: params.accountId, - })) as ResolvedAccount | null; + }))) as ResolvedAccount | null; } export async function buildReadOnlySourceChannelAccountSnapshot(params: { @@ -62,7 +62,7 @@ export async function buildReadOnlySourceChannelAccountSnapshot probe?: unknown; audit?: unknown; }): Promise { - const inspectedAccount = inspectChannelAccount(params); + const inspectedAccount = await inspectChannelAccount(params); if (!inspectedAccount) { return null; } @@ -80,7 +80,7 @@ export async function buildChannelAccountSnapshot(params: { probe?: unknown; audit?: unknown; }): Promise { - const inspectedAccount = inspectChannelAccount(params); + const inspectedAccount = await inspectChannelAccount(params); const account = inspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId); return await buildSnapshotFromAccount({ diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index f9252a93c56..fc6d1b91731 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -22,6 +22,7 @@ export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => Chan export type ChannelSetupInput = { name?: string; token?: string; + privateKey?: string; tokenFile?: string; botToken?: string; appToken?: string; @@ -47,6 +48,7 @@ export type ChannelSetupInput = { initialSyncLimit?: number; ship?: string; url?: string; + relayUrls?: string; code?: string; groupChannels?: string[]; dmAllowlist?: string[]; diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts new file mode 100644 index 00000000000..aed3283b7a2 --- /dev/null +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectDiscordAccount, + type InspectedDiscordAccount, +} from "../../extensions/discord/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts new file mode 100644 index 00000000000..6d0e0a10b29 --- /dev/null +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectSlackAccount, + type InspectedSlackAccount, +} from "../../extensions/slack/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts new file mode 100644 index 00000000000..07866b9d450 --- /dev/null +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectTelegramAccount, + type InspectedTelegramAccount, +} from "../../extensions/telegram/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index c8d99a3a42e..d26c1c77f55 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -1,41 +1,55 @@ -import { - inspectDiscordAccount, - type InspectedDiscordAccount, -} from "../../extensions/discord/src/account-inspect.js"; -import { - inspectSlackAccount, - type InspectedSlackAccount, -} from "../../extensions/slack/src/account-inspect.js"; -import { - inspectTelegramAccount, - type InspectedTelegramAccount, -} from "../../extensions/telegram/src/account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ChannelId } from "./plugins/types.js"; -export type ReadOnlyInspectedAccount = - | InspectedDiscordAccount - | InspectedSlackAccount - | InspectedTelegramAccount; +type DiscordInspectModule = typeof import("./read-only-account-inspect.discord.runtime.js"); +type SlackInspectModule = typeof import("./read-only-account-inspect.slack.runtime.js"); +type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.runtime.js"); -export function inspectReadOnlyChannelAccount(params: { +let discordInspectModulePromise: Promise | undefined; +let slackInspectModulePromise: Promise | undefined; +let telegramInspectModulePromise: Promise | undefined; + +function loadDiscordInspectModule() { + discordInspectModulePromise ??= import("./read-only-account-inspect.discord.runtime.js"); + return discordInspectModulePromise; +} + +function loadSlackInspectModule() { + slackInspectModulePromise ??= import("./read-only-account-inspect.slack.runtime.js"); + return slackInspectModulePromise; +} + +function loadTelegramInspectModule() { + telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.runtime.js"); + return telegramInspectModulePromise; +} + +export type ReadOnlyInspectedAccount = + | Awaited> + | Awaited> + | Awaited>; + +export async function inspectReadOnlyChannelAccount(params: { channelId: ChannelId; cfg: OpenClawConfig; accountId?: string | null; -}): ReadOnlyInspectedAccount | null { +}): Promise { if (params.channelId === "discord") { + const { inspectDiscordAccount } = await loadDiscordInspectModule(); return inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, }); } if (params.channelId === "slack") { + const { inspectSlackAccount } = await loadSlackInspectModule(); return inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, }); } if (params.channelId === "telegram") { + const { inspectTelegramAccount } = await loadTelegramInspectModule(); return inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 3015ed1d42a..d2e7bf148f3 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -14,6 +14,7 @@ const optionNamesAdd = [ "account", "name", "token", + "privateKey", "tokenFile", "botToken", "appToken", @@ -39,6 +40,7 @@ const optionNamesAdd = [ "initialSyncLimit", "ship", "url", + "relayUrls", "code", "groupChannels", "dmAllowlist", @@ -164,6 +166,7 @@ export function registerChannelsCli(program: Command) { .option("--account ", "Account id (default when omitted)") .option("--name ", "Display name for this account") .option("--token ", "Bot token (Telegram/Discord)") + .option("--private-key ", "Nostr private key (nsec... or hex)") .option("--token-file ", "Bot token file (Telegram)") .option("--bot-token ", "Slack bot token (xoxb-...)") .option("--app-token ", "Slack app token (xapp-...)") @@ -188,6 +191,7 @@ export function registerChannelsCli(program: Command) { .option("--initial-sync-limit ", "Matrix initial sync limit") .option("--ship ", "Tlon ship name (~sampel-palnet)") .option("--url ", "Tlon ship URL") + .option("--relay-urls ", "Nostr relay URLs (comma-separated)") .option("--code ", "Tlon login code") .option("--group-channels ", "Tlon group channels (comma-separated)") .option("--dm-allowlist ", "Tlon DM allowlist (comma-separated ships)") diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 74c47f637e9..c9de91d4257 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -43,7 +43,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { async function resolveTalkApiKey(params: { envKey: string; commandName?: string; - mode?: "strict" | "summary"; + mode?: "enforce_resolved" | "read_only_status"; }) { return resolveCommandSecretRefsViaGateway({ config: makeTalkApiKeySecretRefConfig(params.envKey), @@ -447,7 +447,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.diagnostics).toEqual(["memory search ref inactive"]); }); - it("degrades unresolved refs in summary mode instead of throwing", async () => { + it("degrades unresolved refs in read-only status mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; callGateway.mockResolvedValueOnce({ assignments: [], @@ -457,7 +457,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); @@ -470,6 +470,25 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); }); + it("accepts legacy summary mode as a read-only alias", async () => { + const envKey = "TALK_API_KEY_LEGACY_SUMMARY_MISSING"; + callGateway.mockResolvedValueOnce({ + assignments: [], + diagnostics: [], + }); + await withEnvValue(envKey, undefined, async () => { + const result = await resolveCommandSecretRefsViaGateway({ + config: makeTalkApiKeySecretRefConfig(envKey), + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); + expect(result.hadUnresolvedTargets).toBe(true); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); + }); + }); + it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; callGateway.mockResolvedValueOnce({ @@ -480,7 +499,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); @@ -571,7 +590,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "status", targetIds: new Set(["talk.apiKey"]), - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("target-only"); @@ -591,7 +610,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } }); - it("degrades unresolved refs in operational read-only mode", async () => { + it("degrades unresolved refs in read-only operational mode", async () => { const envKey = "TALK_API_KEY_OPERATIONAL_MISSING"; const priorValue = process.env[envKey]; delete process.env[envKey]; @@ -606,7 +625,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "channels resolve", targetIds: new Set(["talk.apiKey"]), - mode: "operational_readonly", + mode: "read_only_operational", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 03e578b642c..8b2b73c9f0f 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -26,7 +26,16 @@ type ResolveCommandSecretsResult = { hadUnresolvedTargets: boolean; }; -export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret +export type CommandSecretResolutionMode = + | "enforce_resolved" + | "read_only_status" + | "read_only_operational"; + +type LegacyCommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret + +type CommandSecretResolutionModeInput = + | CommandSecretResolutionMode + | LegacyCommandSecretResolutionMode; export type CommandSecretTargetState = | "resolved_gateway" @@ -54,6 +63,22 @@ const WEB_RUNTIME_SECRET_PATH_PREFIXES = [ "tools.web.fetch.firecrawl.", ] as const; +function normalizeCommandSecretResolutionMode( + mode?: CommandSecretResolutionModeInput, +): CommandSecretResolutionMode { + if (!mode || mode === "enforce_resolved" || mode === "strict") { + return "enforce_resolved"; + } + if (mode === "read_only_status" || mode === "summary") { + return "read_only_status"; + } + return "read_only_operational"; +} + +function enforcesResolvedSecrets(mode: CommandSecretResolutionMode): boolean { + return mode === "enforce_resolved"; +} + function dedupeDiagnostics(entries: readonly string[]): string[] { const seen = new Set(); const ordered: string[] = []; @@ -242,7 +267,7 @@ async function resolveCommandSecretRefsLocally(params: { context, }); } catch (error) { - if (params.mode === "strict") { + if (enforcesResolvedSecrets(params.mode)) { throw error; } localResolutionDiagnostics.push( @@ -289,7 +314,7 @@ async function resolveCommandSecretRefsLocally(params: { analyzed, resolvedState: "resolved_local", }); - if (params.mode !== "strict" && analyzed.unresolved.length > 0) { + if (!enforcesResolvedSecrets(params.mode) && analyzed.unresolved.length > 0) { scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); } else if (analyzed.unresolved.length > 0) { throw new Error( @@ -336,7 +361,7 @@ function buildUnresolvedDiagnostics( unresolved: UnresolvedCommandSecretAssignment[], mode: CommandSecretResolutionMode, ): string[] { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { return []; } return unresolved.map( @@ -411,7 +436,7 @@ async function resolveTargetSecretLocally(params: { }); setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved); } catch (error) { - if (params.mode !== "strict") { + if (!enforcesResolvedSecrets(params.mode)) { params.localResolutionDiagnostics.push( `${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`, ); @@ -423,9 +448,9 @@ export async function resolveCommandSecretRefsViaGateway(params: { config: OpenClawConfig; commandName: string; targetIds: Set; - mode?: CommandSecretResolutionMode; + mode?: CommandSecretResolutionModeInput; }): Promise { - const mode = params.mode ?? "strict"; + const mode = normalizeCommandSecretResolutionMode(params.mode); const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, @@ -567,7 +592,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { (entry) => !recoveredPaths.has(entry.path), ); if (stillUnresolved.length > 0) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw new Error( `${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, ); @@ -590,7 +615,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { ]); } } catch (error) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw error; } scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index a71ac5e00c4..22a23b36055 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, getMemoryCommandSecretTargetIds, + getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; describe("command secret target ids", () => { @@ -21,4 +22,13 @@ describe("command secret target ids", () => { ]), ); }); + + it("includes gateway auth and channel targets for security audit", () => { + const ids = getSecurityAuditCommandSecretTargetIds(); + expect(ids.has("channels.discord.token")).toBe(true); + expect(ids.has("gateway.auth.token")).toBe(true); + expect(ids.has("gateway.auth.password")).toBe(true); + expect(ids.has("gateway.remote.token")).toBe(true); + expect(ids.has("gateway.remote.password")).toBe(true); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index e1c2c49e0ae..d6dde83cd19 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -30,6 +30,7 @@ const COMMAND_SECRET_TARGETS = { "agents.defaults.memorySearch.remote.", "agents.list[].memorySearch.remote.", ]), + securityAudit: idsByPrefix(["channels.", "gateway.auth.", "gateway.remote."]), } as const; function toTargetIdSet(values: readonly string[]): Set { @@ -59,3 +60,7 @@ export function getAgentRuntimeCommandSecretTargetIds(): Set { export function getStatusCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.status); } + +export function getSecurityAuditCommandSecretTargetIds(): Set { + return toTargetIdSet(COMMAND_SECRET_TARGETS.securityAudit); +} diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 27b53753eda..fd94acca3a9 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; -const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const })); +const callGatewayStatusProbe = vi.fn< + (opts?: unknown) => Promise<{ ok: boolean; url?: string; error?: string | null }> +>(async (_opts?: unknown) => ({ + ok: true, + url: "ws://127.0.0.1:19001", + error: null, +})); const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ enabled: true, required: true, @@ -333,6 +339,71 @@ describe("gatherDaemonStatus", () => { ); }); + it("degrades safely when daemon probe auth SecretRef is unresolved", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(callGatewayStatusProbe).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + password: undefined, + }), + ); + expect(status.rpc?.authWarning).toBeUndefined(); + }); + + it("surfaces authWarning when daemon probe auth SecretRef is unresolved and probe fails", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + callGatewayStatusProbe.mockResolvedValueOnce({ + ok: false, + error: "gateway closed", + url: "wss://127.0.0.1:19001", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(status.rpc?.ok).toBe(false); + expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable"); + expect(status.rpc?.authWarning).toContain("probing without configured auth credentials"); + }); + it("keeps remote probe auth strict when remote token is missing", async () => { daemonLoadedConfig = { gateway: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 707a908b1f6..4647b789ff9 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -16,7 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; -import { trimToUndefined } from "../../gateway/credentials.js"; +import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; import { resolveGatewayBindHost } from "../../gateway/net.js"; import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; @@ -112,6 +112,7 @@ export type DaemonStatus = { ok: boolean; error?: string; url?: string; + authWarning?: string; }; health?: { healthy: boolean; @@ -130,6 +131,10 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool return true; } +function parseGatewaySecretRefPathFromError(error: unknown): string | null { + return isGatewaySecretRefUnavailableError(error) ? error.path : null; +} + async function loadDaemonConfigContext( serviceEnv?: Record, ): Promise { @@ -310,8 +315,11 @@ export async function gatherDaemonStatus( const tlsRuntime = shouldUseLocalTlsRuntime ? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls) : undefined; - const daemonProbeAuth = opts.probe - ? await resolveGatewayProbeAuthWithSecretInputs({ + let daemonProbeAuth: { token?: string; password?: string } | undefined; + let rpcAuthWarning: string | undefined; + if (opts.probe) { + try { + daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({ cfg: daemonCfg, mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local", env: mergedDaemonEnv as NodeJS.ProcessEnv, @@ -319,8 +327,16 @@ export async function gatherDaemonStatus( token: opts.rpc.token, password: opts.rpc.password, }, - }) - : undefined; + }); + } catch (error) { + const refPath = parseGatewaySecretRefPathFromError(error); + if (!refPath) { + throw error; + } + daemonProbeAuth = undefined; + rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; + } + } const rpc = opts.probe ? await probeGatewayStatus({ @@ -336,6 +352,9 @@ export async function gatherDaemonStatus( configPath: daemonConfigSummary.path, }) : undefined; + if (rpc?.ok) { + rpcAuthWarning = undefined; + } const health = opts.probe && loaded ? await inspectGatewayRestart({ @@ -369,7 +388,15 @@ export async function gatherDaemonStatus( port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, - ...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}), + ...(rpc + ? { + rpc: { + ...rpc, + url: gateway.probeUrl, + ...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}), + }, + } + : {}), ...(health ? { health: { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 91348d10d4a..088a3654797 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -181,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`); } else { defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`); + if (rpc.authWarning) { + defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`); + } if (rpc.url) { defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`); } diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts new file mode 100644 index 00000000000..f9751d5fed8 --- /dev/null +++ b/src/cli/plugin-registry.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentId: vi.fn(() => "main"), + loadConfig: vi.fn(), + loadOpenClawPlugins: vi.fn(), + loadPluginManifestRegistry: vi.fn(), + getActivePluginRegistry: vi.fn(), +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, +})); + +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: mocks.getActivePluginRegistry, +})); + +describe("ensurePluginRegistryLoaded", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ + plugins: { enabled: true }, + channels: { telegram: { enabled: false } }, + }); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { id: "telegram", channels: ["telegram"] }, + { id: "slack", channels: ["slack"] }, + { id: "openai", channels: [] }, + ], + }); + mocks.getActivePluginRegistry.mockReturnValue({ + plugins: [], + channels: [], + tools: [], + }); + }); + + it("loads only configured channel plugins for configured-channels scope", async () => { + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["telegram"], + }), + ); + }); + + it("reloads when escalating from configured-channels to channels", async () => { + mocks.getActivePluginRegistry + .mockReturnValueOnce({ + plugins: [], + channels: [], + tools: [], + }) + .mockReturnValue({ + plugins: [{ id: "telegram" }], + channels: [{ plugin: { id: "telegram" } }], + tools: [], + }); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + ensurePluginRegistryLoaded({ scope: "channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ onlyPluginIds: ["telegram"] }), + ); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ onlyPluginIds: ["telegram", "slack"] }), + ); + }); +}); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index aad181eff7f..f51a57d7fda 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; @@ -7,9 +8,22 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded: "none" | "channels" | "all" = "none"; +let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; -export type PluginRegistryScope = "channels" | "all"; +export type PluginRegistryScope = "configured-channels" | "channels" | "all"; + +function scopeRank(scope: typeof pluginRegistryLoaded): number { + switch (scope) { + case "none": + return 0; + case "configured-channels": + return 1; + case "channels": + return 2; + case "all": + return 3; + } +} function resolveChannelPluginIds(params: { config: ReturnType; @@ -25,15 +39,30 @@ function resolveChannelPluginIds(params: { .map((plugin) => plugin.id); } +function resolveConfiguredChannelPluginIds(params: { + config: ReturnType; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); +} + export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { const scope = options?.scope ?? "all"; - if (pluginRegistryLoaded === "all" || pluginRegistryLoaded === scope) { + if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { return; } const active = getActivePluginRegistry(); // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid // doing an expensive load when we already have plugins/channels/tools. if ( + pluginRegistryLoaded === "none" && active && (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) ) { @@ -52,15 +81,23 @@ export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistrySco config, workspaceDir, logger, - ...(scope === "channels" + ...(scope === "configured-channels" ? { - onlyPluginIds: resolveChannelPluginIds({ + onlyPluginIds: resolveConfiguredChannelPluginIds({ config, workspaceDir, env: process.env, }), } - : {}), + : scope === "channels" + ? { + onlyPluginIds: resolveChannelPluginIds({ + config, + workspaceDir, + env: process.env, + }), + } + : {}), }); pluginRegistryLoaded = scope; } diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index e7958a684a5..896dcb6757a 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -5,6 +5,7 @@ const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {})); const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -16,6 +17,10 @@ vi.mock("../../commands/models.js", () => ({ modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/gateway-status.js", () => ({ + gatewayStatusCommand: gatewayStatusCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -32,9 +37,12 @@ describe("program routes", () => { await expect(route?.run(argv)).resolves.toBe(false); } - it("matches status route and always preloads plugins", () => { + it("matches status route and preloads plugins only for text output", () => { const route = expectRoute(["status"]); - expect(route?.loadPlugins).toBe(true); + expect(typeof route?.loadPlugins).toBe("function"); + const shouldLoad = route?.loadPlugins as (argv: string[]) => boolean; + expect(shouldLoad(["node", "openclaw", "status"])).toBe(true); + expect(shouldLoad(["node", "openclaw", "status", "--json"])).toBe(false); }); it("matches health route and preloads plugins only for text output", () => { @@ -45,6 +53,73 @@ describe("program routes", () => { expect(shouldLoad(["node", "openclaw", "health", "--json"])).toBe(false); }); + it("matches gateway status route without plugin preload", () => { + const route = expectRoute(["gateway", "status"]); + expect(route?.loadPlugins).toBeUndefined(); + }); + + it("returns false for gateway status route when option values are missing", async () => { + await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--url"]); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--token"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--password"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--timeout"], + ); + await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--ssh"]); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-identity"], + ); + }); + + it("passes parsed gateway status flags through", async () => { + const route = expectRoute(["gateway", "status"]); + await expect( + route?.run([ + "node", + "openclaw", + "--profile", + "work", + "gateway", + "status", + "--url", + "ws://127.0.0.1:18789", + "--token", + "abc", + "--password", + "def", + "--timeout", + "5000", + "--ssh", + "user@host", + "--ssh-identity", + "~/.ssh/id_test", + "--ssh-auto", + "--json", + ]), + ).resolves.toBe(true); + expect(gatewayStatusCommandMock).toHaveBeenCalledWith( + { + url: "ws://127.0.0.1:18789", + token: "abc", + password: "def", + timeout: "5000", + json: true, + ssh: "user@host", + sshIdentity: "~/.ssh/id_test", + sshAuto: true, + }, + expect.any(Object), + ); + }); + it("returns false when status timeout flag value is missing", async () => { await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 52e0d8f8446..353c9b8f11d 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -53,6 +53,53 @@ const routeStatus: RouteSpec = { }, }; +const routeGatewayStatus: RouteSpec = { + match: (path) => path[0] === "gateway" && path[1] === "status", + run: async (argv) => { + const url = getFlagValue(argv, "--url"); + if (url === null) { + return false; + } + const token = getFlagValue(argv, "--token"); + if (token === null) { + return false; + } + const password = getFlagValue(argv, "--password"); + if (password === null) { + return false; + } + const timeout = getFlagValue(argv, "--timeout"); + if (timeout === null) { + return false; + } + const ssh = getFlagValue(argv, "--ssh"); + if (ssh === null) { + return false; + } + const sshIdentity = getFlagValue(argv, "--ssh-identity"); + if (sshIdentity === null) { + return false; + } + const sshAuto = hasFlag(argv, "--ssh-auto"); + const json = hasFlag(argv, "--json"); + const { gatewayStatusCommand } = await import("../../commands/gateway-status.js"); + await gatewayStatusCommand( + { + url: url ?? undefined, + token: token ?? undefined, + password: password ?? undefined, + timeout: timeout ?? undefined, + json, + ssh: ssh ?? undefined, + sshIdentity: sshIdentity ?? undefined, + sshAuto, + }, + defaultRuntime, + ); + return true; + }, +}; + const routeSessions: RouteSpec = { // Fast-path only bare `sessions`; subcommands (e.g. `sessions cleanup`) // must fall through to Commander so nested handlers run. @@ -251,6 +298,7 @@ const routeModelsStatus: RouteSpec = { const routes: RouteSpec[] = [ routeHealth, routeStatus, + routeGatewayStatus, routeSessions, routeAgentsList, routeMemoryStatus, diff --git a/src/cli/security-cli.test.ts b/src/cli/security-cli.test.ts new file mode 100644 index 00000000000..95c3e62d4ae --- /dev/null +++ b/src/cli/security-cli.test.ts @@ -0,0 +1,245 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn(); +const runSecurityAudit = vi.fn(); +const fixSecurityFootguns = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const getSecurityAuditCommandSecretTargetIds = vi.fn( + () => new Set(["gateway.auth.token", "gateway.auth.password"]), +); + +const { defaultRuntime, runtimeLogs, resetRuntimeCapture } = createCliRuntimeCapture(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfig(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../security/audit.js", () => ({ + runSecurityAudit: (opts: unknown) => runSecurityAudit(opts), +})); + +vi.mock("../security/fix.js", () => ({ + fixSecurityFootguns: () => fixSecurityFootguns(), +})); + +vi.mock("./command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./command-secret-targets.js", () => ({ + getSecurityAuditCommandSecretTargetIds: () => getSecurityAuditCommandSecretTargetIds(), +})); + +const { registerSecurityCli } = await import("./security-cli.js"); + +function createProgram() { + const program = new Command(); + program.exitOverride(); + registerSecurityCli(program); + return program; +} + +describe("security CLI", () => { + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + runSecurityAudit.mockReset(); + fixSecurityFootguns.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + getSecurityAuditCommandSecretTargetIds.mockClear(); + fixSecurityFootguns.mockResolvedValue({ + changes: [], + actions: [], + errors: [], + }); + }); + + it("runs audit with read-only SecretRef resolution and prints JSON diagnostics", async () => { + const sourceConfig = { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig = { + ...sourceConfig, + gateway: { + ...sourceConfig.gateway, + auth: { + ...sourceConfig.gateway.auth, + token: "resolved-token", + }, + }, + }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 1, info: 0 }, + findings: [ + { + checkId: "gateway.probe_failed", + severity: "warn", + title: "Gateway probe failed (deep)", + detail: "connect failed: connect ECONNREFUSED 127.0.0.1:18789", + }, + ], + }); + + await createProgram().parseAsync(["security", "audit", "--json"], { from: "user" }); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: sourceConfig, + commandName: "security audit", + mode: "read_only_status", + targetIds: expect.any(Set), + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + config: resolvedConfig, + sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); + const payload = JSON.parse(String(runtimeLogs.at(-1))); + expect(payload.secretDiagnostics).toEqual([ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ]); + }); + + it("forwards --token to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--token", "explicit-token", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { token: "explicit-token" }, + }), + ); + }); + + it("forwards --password to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--password", "explicit-password", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { password: "explicit-password" }, + }), + ); + }); + + it("forwards both --token and --password to deep probe auth", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + [ + "security", + "audit", + "--deep", + "--token", + "explicit-token", + "--password", + "explicit-password", + "--json", + ], + { + from: "user", + }, + ); + + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { + token: "explicit-token", + password: "explicit-password", + }, + }), + ); + }); +}); diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index f55f657f4c1..586e5e0f114 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -7,12 +7,16 @@ import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; +import { getSecurityAuditCommandSecretTargetIds } from "./command-secret-targets.js"; import { formatHelpExamples } from "./help-format.js"; type SecurityAuditOptions = { json?: boolean; deep?: boolean; fix?: boolean; + token?: string; + password?: string; }; function formatSummary(summary: { critical: number; warn: number; info: number }): string { @@ -37,6 +41,11 @@ export function registerSecurityCli(program: Command) { `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw security audit", "Run a local security audit."], ["openclaw security audit --deep", "Include best-effort live Gateway probe checks."], + ["openclaw security audit --deep --token ", "Use explicit token for deep probe."], + [ + "openclaw security audit --deep --password ", + "Use explicit password for deep probe.", + ], ["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."], ["openclaw security audit --json", "Output machine-readable JSON."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`, @@ -46,22 +55,45 @@ export function registerSecurityCli(program: Command) { .command("audit") .description("Audit config + local state for common security foot-guns") .option("--deep", "Attempt live Gateway probe (best-effort)", false) + .option("--token ", "Use explicit gateway token for deep probe auth") + .option("--password ", "Use explicit gateway password for deep probe auth") .option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false) .option("--json", "Print JSON", false) .action(async (opts: SecurityAuditOptions) => { const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null; - const cfg = loadConfig(); + const sourceConfig = loadConfig(); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: sourceConfig, + commandName: "security audit", + targetIds: getSecurityAuditCommandSecretTargetIds(), + mode: "read_only_status", + }); const report = await runSecurityAudit({ config: cfg, + sourceConfig, deep: Boolean(opts.deep), includeFilesystem: true, includeChannelSecurity: true, + deepProbeAuth: + opts.token?.trim() || opts.password?.trim() + ? { + ...(opts.token?.trim() ? { token: opts.token } : {}), + ...(opts.password?.trim() ? { password: opts.password } : {}), + } + : undefined, }); if (opts.json) { defaultRuntime.log( - JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2), + JSON.stringify( + fixResult + ? { fix: fixResult, report, secretDiagnostics } + : { ...report, secretDiagnostics }, + null, + 2, + ), ); return; } @@ -74,6 +106,9 @@ export function registerSecurityCli(program: Command) { lines.push(heading("OpenClaw security audit")); lines.push(muted(`Summary: ${formatSummary(report.summary)}`)); lines.push(muted(`Run deeper: ${formatCliCommand("openclaw security audit --deep")}`)); + for (const diagnostic of secretDiagnostics) { + lines.push(muted(`[secrets] ${diagnostic}`)); + } if (opts.fix) { lines.push(muted(`Fix: ${formatCliCommand("openclaw security audit --fix")}`)); diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts index 9fdaadb5231..4cdbde4d7e2 100644 --- a/src/commands/channel-account-context.test.ts +++ b/src/commands/channel-account-context.test.ts @@ -21,6 +21,8 @@ describe("resolveDefaultChannelAccountContext", () => { expect(result.account).toBe(account); expect(result.enabled).toBe(true); expect(result.configured).toBe(true); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); }); it("uses plugin enable/configure hooks", async () => { @@ -43,5 +45,70 @@ describe("resolveDefaultChannelAccountContext", () => { expect(isConfigured).toHaveBeenCalledWith(account, {}); expect(result.enabled).toBe(false); expect(result.configured).toBe(false); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); + }); + + it("keeps strict mode fail-closed when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow( + /missing secret/i, + ); + }); + + it("degrades safely in read_only mode when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + commandName: "status", + }); + + expect(result.enabled).toBe(false); + expect(result.configured).toBe(false); + expect(result.degraded).toBe(true); + expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe( + true, + ); + }); + + it("prefers inspectAccount in read_only mode", async () => { + const inspectAccount = vi.fn(() => ({ configured: true, enabled: true })); + const resolveAccount = vi.fn(() => ({ configured: false, enabled: false })); + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-1"], + inspectAccount, + resolveAccount, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + }); + + expect(inspectAccount).toHaveBeenCalled(); + expect(resolveAccount).not.toHaveBeenCalled(); + expect(result.enabled).toBe(true); + expect(result.configured).toBe(true); + expect(result.degraded).toBe(true); }); }); diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts index 36ce8c53e72..a9f12974b06 100644 --- a/src/commands/channel-account-context.ts +++ b/src/commands/channel-account-context.ts @@ -1,6 +1,8 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; +import { formatErrorMessage } from "../infra/errors.js"; export type ChannelDefaultAccountContext = { accountIds: string[]; @@ -8,22 +10,154 @@ export type ChannelDefaultAccountContext = { account: unknown; enabled: boolean; configured: boolean; + diagnostics: string[]; + /** + * Indicates read-only resolution was used instead of strict full-account resolution. + * This is expected for read_only mode and does not necessarily mean an error occurred. + */ + degraded: boolean; }; +export type ChannelAccountContextMode = "strict" | "read_only"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function getBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +function formatContextDiagnostic(params: { + commandName?: string; + pluginId: string; + accountId: string; + message: string; +}): string { + const prefix = params.commandName ? `${params.commandName}: ` : ""; + return `${prefix}channels.${params.pluginId}.accounts.${params.accountId}: ${params.message}`; +} + export async function resolveDefaultChannelAccountContext( plugin: ChannelPlugin, cfg: OpenClawConfig, + options?: { mode?: ChannelAccountContextMode; commandName?: string }, ): Promise { + const mode = options?.mode ?? "strict"; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds, }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; - return { accountIds, defaultAccountId, account, enabled, configured }; + if (mode === "strict") { + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics: [], + degraded: false, + }; + } + + const diagnostics: string[] = []; + let degraded = false; + + const inspected = + plugin.config.inspectAccount?.(cfg, defaultAccountId) ?? + (await inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId: defaultAccountId, + })); + + let account = inspected; + if (!account) { + try { + account = plugin.config.resolveAccount(cfg, defaultAccountId); + } catch (error) { + degraded = true; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to resolve account (${formatErrorMessage(error)}); skipping read-only checks.`, + }), + ); + return { + accountIds, + defaultAccountId, + account: {}, + enabled: false, + configured: false, + diagnostics, + degraded, + }; + } + } else { + degraded = true; + } + + const inspectEnabled = getBooleanField(account, "enabled"); + let enabled = inspectEnabled ?? true; + if (inspectEnabled === undefined && plugin.config.isEnabled) { + try { + enabled = plugin.config.isEnabled(account, cfg); + } catch (error) { + degraded = true; + enabled = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate enabled state (${formatErrorMessage(error)}); treating as disabled.`, + }), + ); + } + } + + const inspectConfigured = getBooleanField(account, "configured"); + let configured = inspectConfigured ?? true; + if (inspectConfigured === undefined && plugin.config.isConfigured) { + try { + configured = await plugin.config.isConfigured(account, cfg); + } catch (error) { + degraded = true; + configured = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate configured state (${formatErrorMessage(error)}); treating as unconfigured.`, + }), + ); + } + } + + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics, + degraded, + }; } diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts new file mode 100644 index 00000000000..8ae5f16f800 --- /dev/null +++ b/src/commands/channel-setup/discovery.ts @@ -0,0 +1,108 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + listChannelPluginCatalogEntries, + type ChannelPluginCatalogEntry, +} from "../../channels/plugins/catalog.js"; +import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js"; +import { listChatChannels } from "../../channels/registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import type { ChannelChoice } from "../onboard-types.js"; + +type ChannelCatalogEntry = { + id: ChannelChoice; + meta: ChannelMeta; +}; + +export type ResolvedChannelSetupEntries = { + entries: ChannelCatalogEntry[]; + installedCatalogEntries: ChannelPluginCatalogEntry[]; + installableCatalogEntries: ChannelPluginCatalogEntry[]; + installedCatalogById: Map; + installableCatalogById: Map; +}; + +function resolveWorkspaceDir(cfg: OpenClawConfig, workspaceDir?: string): string | undefined { + return workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); +} + +export function listManifestInstalledChannelIds(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Set { + const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir); + return new Set( + loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir, + env: params.env ?? process.env, + }).plugins.flatMap((plugin) => plugin.channels as ChannelChoice[]), + ); +} + +export function isCatalogChannelInstalled(params: { + cfg: OpenClawConfig; + entry: ChannelPluginCatalogEntry; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): boolean { + return listManifestInstalledChannelIds(params).has(params.entry.id as ChannelChoice); +} + +export function resolveChannelSetupEntries(params: { + cfg: OpenClawConfig; + installedPlugins: ChannelPlugin[]; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ResolvedChannelSetupEntries { + const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir); + const manifestInstalledIds = listManifestInstalledChannelIds({ + cfg: params.cfg, + workspaceDir, + env: params.env, + }); + const installedPluginIds = new Set(params.installedPlugins.map((plugin) => plugin.id)); + const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); + const installedCatalogEntries = catalogEntries.filter( + (entry) => + !installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice), + ); + const installableCatalogEntries = catalogEntries.filter( + (entry) => + !installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice), + ); + + const metaById = new Map(); + for (const meta of listChatChannels()) { + metaById.set(meta.id, meta); + } + for (const plugin of params.installedPlugins) { + metaById.set(plugin.id, plugin.meta); + } + for (const entry of installedCatalogEntries) { + if (!metaById.has(entry.id)) { + metaById.set(entry.id, entry.meta); + } + } + for (const entry of installableCatalogEntries) { + if (!metaById.has(entry.id)) { + metaById.set(entry.id, entry.meta); + } + } + + return { + entries: Array.from(metaById, ([id, meta]) => ({ + id: id as ChannelChoice, + meta, + })), + installedCatalogEntries, + installableCatalogEntries, + installedCatalogById: new Map( + installedCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ), + installableCatalogById: new Map( + installableCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ), + }; +} diff --git a/src/commands/channel-setup/registry.ts b/src/commands/channel-setup/registry.ts new file mode 100644 index 00000000000..9bfd1cf188b --- /dev/null +++ b/src/commands/channel-setup/registry.ts @@ -0,0 +1,47 @@ +import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { ChannelChoice } from "../onboard-types.js"; +import type { ChannelSetupFlowAdapter } from "./types.js"; + +const setupWizardAdapters = new WeakMap(); + +export function resolveChannelSetupFlowAdapterForPlugin( + plugin?: ChannelPlugin, +): ChannelSetupFlowAdapter | undefined { + if (plugin?.setupWizard) { + const cached = setupWizardAdapters.get(plugin); + if (cached) { + return cached; + } + const adapter = buildChannelSetupFlowAdapterFromSetupWizard({ + plugin, + wizard: plugin.setupWizard, + }); + setupWizardAdapters.set(plugin, adapter); + return adapter; + } + return undefined; +} + +const CHANNEL_SETUP_FLOW_ADAPTERS = () => { + const adapters = new Map(); + for (const plugin of listChannelSetupPlugins()) { + const adapter = resolveChannelSetupFlowAdapterForPlugin(plugin); + if (!adapter) { + continue; + } + adapters.set(plugin.id, adapter); + } + return adapters; +}; + +export function getChannelSetupFlowAdapter( + channel: ChannelChoice, +): ChannelSetupFlowAdapter | undefined { + return CHANNEL_SETUP_FLOW_ADAPTERS().get(channel); +} + +export function listChannelSetupFlowAdapters(): ChannelSetupFlowAdapter[] { + return Array.from(CHANNEL_SETUP_FLOW_ADAPTERS().values()); +} diff --git a/src/commands/channel-setup/types.ts b/src/commands/channel-setup/types.ts new file mode 100644 index 00000000000..f610d0cb1f6 --- /dev/null +++ b/src/commands/channel-setup/types.ts @@ -0,0 +1 @@ +export * from "../../channels/plugins/setup-flow-types.js"; diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 2814f6bb5bd..7a6d687a91c 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,22 +6,22 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { getChannelSetupFlowAdapter } from "./channel-setup/registry.js"; +import type { ChannelSetupFlowAdapter } from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; -import { getChannelOnboardingAdapter } from "./onboarding/registry.js"; -import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; -type ChannelOnboardingAdapterPatch = Partial< +type ChannelSetupFlowAdapterPatch = Partial< Pick< - ChannelOnboardingAdapter, + ChannelSetupFlowAdapter, "configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus" > >; -type PatchedOnboardingAdapterFields = { - configure?: ChannelOnboardingAdapter["configure"]; - configureInteractive?: ChannelOnboardingAdapter["configureInteractive"]; - configureWhenConfigured?: ChannelOnboardingAdapter["configureWhenConfigured"]; - getStatus?: ChannelOnboardingAdapter["getStatus"]; +type PatchedSetupAdapterFields = { + configure?: ChannelSetupFlowAdapter["configure"]; + configureInteractive?: ChannelSetupFlowAdapter["configureInteractive"]; + configureWhenConfigured?: ChannelSetupFlowAdapter["configureWhenConfigured"]; + getStatus?: ChannelSetupFlowAdapter["getStatus"]; }; export function setDefaultChannelPluginRegistryForTests(): void { @@ -36,16 +36,16 @@ export function setDefaultChannelPluginRegistryForTests(): void { setActivePluginRegistry(createTestRegistry(channels)); } -export function patchChannelOnboardingAdapter( +export function patchChannelSetupFlowAdapter( channel: ChannelChoice, - patch: ChannelOnboardingAdapterPatch, + patch: ChannelSetupFlowAdapterPatch, ): () => void { - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getChannelSetupFlowAdapter(channel); if (!adapter) { - throw new Error(`missing onboarding adapter for ${channel}`); + throw new Error(`missing setup adapter for ${channel}`); } - const previous: PatchedOnboardingAdapterFields = {}; + const previous: PatchedSetupAdapterFields = {}; if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) { previous.getStatus = adapter.getStatus; diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 9f584494fba..fdb3e61f97d 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -14,6 +14,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), })); +const manifestRegistryMocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), +})); + vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -22,6 +26,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -48,6 +60,11 @@ describe("channelsAddCommand", () => { runtime.exit.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); + manifestRegistryMocks.loadPluginManifestRegistry.mockClear(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); vi.mocked(ensureOnboardingPluginInstalled).mockClear(); vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, @@ -171,6 +188,85 @@ describe("channelsAddCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("uses the installed external channel snapshot without reinstalling", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "@openclaw/msteams-plugin", + channels: ["msteams"], + } as never, + ], + diagnostics: [], + }); + const scopedMSTeamsPlugin = { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + setup: { + applyAccountConfig: vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + enabled: true, + tenantId: input.token, + }, + }, + })), + }, + }; + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), + ); + + await channelsAddCommand( + { + channel: "msteams", + account: "default", + token: "tenant-installed", + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + msteams: { + enabled: true, + tenantId: "tenant-installed", + }, + }, + }), + ); + }); + it("uses the installed plugin id when channel and plugin ids differ", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry(createTestRegistry()); diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts new file mode 100644 index 00000000000..e613c64323a --- /dev/null +++ b/src/commands/channels.status.command-flow.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const requireValidConfigSnapshot = vi.fn(); +const listChannelPlugins = vi.fn(); +const withProgress = vi.fn(async (_opts: unknown, run: () => Promise) => await run()); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts), +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./shared.js", () => ({ + requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime), + formatChannelAccountLabel: ({ + channel, + accountId, + }: { + channel: string; + accountId: string; + name?: string; + }) => `${channel} ${accountId}`, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => listChannelPlugins(), + getChannelPlugin: (channel: string) => + (listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel), +})); + +vi.mock("../cli/progress.js", () => ({ + withProgress: (opts: unknown, run: () => Promise) => withProgress(opts, run), +})); + +const { channelsStatusCommand } = await import("./channels/status.js"); + +function createTokenOnlyPlugin() { + return { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + defaultAccountId: () => "default", + inspectAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + resolveAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function createRuntimeCapture() { + const logs: string[] = []; + const errors: string[] = []; + const runtime = { + log: (message: unknown) => logs.push(String(message)), + error: (message: unknown) => errors.push(String(message)), + exit: (_code?: number) => undefined, + }; + return { runtime, logs, errors }; +} + +describe("channelsStatusCommand SecretRef fallback flow", () => { + beforeEach(() => { + callGateway.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + requireValidConfigSnapshot.mockReset(); + listChannelPlugins.mockReset(); + withProgress.mockClear(); + listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); + }); + + it("keeps read-only fallback output when SecretRefs are unresolved", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: false, channels: {} }, + diagnostics: [ + "channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: true, + }); + const { runtime, logs, errors } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true); + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + commandName: "channels status", + mode: "read_only_status", + }), + ); + expect( + logs.some((line) => + line.includes("[secrets] channels status: channels.discord.token is unavailable"), + ), + ).toBe(true); + const joined = logs.join("\n"); + expect(joined).toContain("configured, secret unavailable in this command path"); + expect(joined).toContain("token:config (unavailable)"); + }); + + it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: true, channels: {} }, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + const { runtime, logs } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + const joined = logs.join("\n"); + expect(joined).toContain("configured"); + expect(joined).toContain("token:config"); + expect(joined).not.toContain("secret unavailable in this command path"); + expect(joined).not.toContain("token:config (unavailable)"); + }); +}); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 88e1a245906..d4175cf100b 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; @@ -9,6 +10,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; +import { isCatalogChannelInstalled } from "../channel-setup/discovery.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -55,7 +57,7 @@ export async function channelsAddCommand( const prompter = createClackPrompter(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; - const resolvedPlugins = new Map(); + const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, @@ -202,24 +204,32 @@ export async function channelsAddCommand( }; if (!channel && catalogEntry) { - const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); - const prompter = createClackPrompter(); const workspaceDir = resolveWorkspaceDir(); - const result = await ensureOnboardingPluginInstalled({ - cfg: nextConfig, - entry: catalogEntry, - prompter, - runtime, - workspaceDir, - }); - nextConfig = result.cfg; - if (!result.installed) { - return; + if ( + !isCatalogChannelInstalled({ + cfg: nextConfig, + entry: catalogEntry, + workspaceDir, + }) + ) { + const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); + const prompter = createClackPrompter(); + const result = await ensureOnboardingPluginInstalled({ + cfg: nextConfig, + entry: catalogEntry, + prompter, + runtime, + workspaceDir, + }); + nextConfig = result.cfg; + if (!result.installed) { + return; + } + catalogEntry = { + ...catalogEntry, + ...(result.pluginId ? { pluginId: result.pluginId } : {}), + }; } - catalogEntry = { - ...catalogEntry, - ...(result.pluginId ? { pluginId: result.pluginId } : {}), - }; channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); } @@ -251,6 +261,7 @@ export async function channelsAddCommand( const input: ChannelSetupInput = { name: opts.name, token: opts.token, + privateKey: opts.privateKey, tokenFile: opts.tokenFile, botToken: opts.botToken, appToken: opts.appToken, @@ -276,6 +287,7 @@ export async function channelsAddCommand( useEnv, ship: opts.ship, url: opts.url, + relayUrls: opts.relayUrls, code: opts.code, groupChannels, dmAllowlist, diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index e9e0345871f..7a29b4993f5 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -75,7 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), - mode: "operational_readonly", + mode: "read_only_operational", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 3a56810e44c..2cbdaf17726 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -315,7 +315,7 @@ export async function channelsStatusCommand( config: cfg, commandName: "channels status", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f616bfaba55..a06c090f9f4 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -330,7 +330,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi config: cfg, commandName: "doctor --fix", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => { const inspected = inspectTelegramAccount({ cfg, accountId }); diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 90790e90737..2138c422fe2 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -94,6 +94,11 @@ function resolveSandboxDockerImage(cfg: OpenClawConfig): string { return image ? image : DEFAULT_SANDBOX_IMAGE; } +function resolveSandboxBackend(cfg: OpenClawConfig): string { + const backend = cfg.agents?.defaults?.sandbox?.backend?.trim(); + return backend || "docker"; +} + function resolveSandboxBrowserImage(cfg: OpenClawConfig): string { const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim(); return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; @@ -185,6 +190,16 @@ export async function maybeRepairSandboxImages( if (!sandbox || mode === "off") { return cfg; } + const backend = resolveSandboxBackend(cfg); + if (backend !== "docker") { + if (sandbox.browser?.enabled) { + note( + `Sandbox backend "${backend}" selected. Docker browser health checks are skipped; browser sandbox currently requires the docker backend.`, + "Sandbox", + ); + } + return cfg; + } const dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index c91ed2087a4..ca2bfb2989c 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -173,6 +173,32 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).toContain("direct/DM targets by default"); }); + it("degrades safely when channel account resolution fails in read-only security checks", async () => { + pluginRegistry.list = [ + { + id: "whatsapp", + meta: { label: "WhatsApp" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + isEnabled: () => true, + isConfigured: () => true, + }, + security: { + resolveDmPolicy: () => null, + }, + }, + ]; + + await noteSecurityWarnings({} as OpenClawConfig); + const message = lastMessage(); + expect(message).toContain("[secrets]"); + expect(message).toContain("failed to resolve account"); + expect(message).toContain("Run: openclaw security audit --deep"); + }); + it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => { const cfg = { agents: { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 5ba17c1c751..c489682f607 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -189,8 +189,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { if (!plugin.security) { continue; } - const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + const { defaultAccountId, account, enabled, configured, diagnostics } = + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "doctor", + }); + for (const diagnostic of diagnostics) { + warnings.push(`- [secrets] ${diagnostic}`); + } if (!enabled) { continue; } diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 68d865996d2..11a382db241 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -122,4 +122,52 @@ describe("doctor command", () => { "openclaw config set gateway.auth.mode password", ); }); + + it("keeps doctor read-only when gateway token is SecretRef-managed but unresolved", async () => { + mockDoctorConfigSnapshot({ + config: { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + }); + + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + note.mockClear(); + try { + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + } finally { + if (previousToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + } + + const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth"); + expect(gatewayAuthNote).toBeTruthy(); + expect(String(gatewayAuthNote?.[0])).toContain( + "Gateway token is managed via SecretRef and is currently unavailable.", + ); + expect(String(gatewayAuthNote?.[0])).toContain( + "Doctor will not overwrite gateway.auth.token with a plaintext value.", + ); + }); }); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 452bcb3691b..46212816410 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -268,7 +268,7 @@ describe("gateway-status command", () => { expect(scopeLimitedWarning?.targetIds).toContain("localLoopback"); }); - it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { + it("suppresses unresolved SecretRef auth warnings when probe is reachable", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { mockLocalTokenEnvRefConfig(); @@ -276,6 +276,38 @@ describe("gateway-status command", () => { await runGatewayStatus(runtime, { timeout: "1000", json: true }); }); + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.token SecretRef is unresolved"), + ); + expect(unresolvedWarning).toBeUndefined(); + }); + + it("surfaces unresolved SecretRef auth diagnostics when probe fails", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { + mockLocalTokenEnvRefConfig(); + probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connection refused", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow( + "__exit__:1", + ); + }); + expect(runtimeErrors).toHaveLength(0); const parsed = JSON.parse(runtimeLogs.join("\n")) as { warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index be0b9abf69a..ecdeeaa9570 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -2,8 +2,6 @@ import { withProgress } from "../cli/progress.js"; import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; -import { resolveSshConfig } from "../infra/ssh-config.js"; -import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import type { RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -23,6 +21,19 @@ import { sanitizeSshTarget, } from "./gateway-status/helpers.js"; +let sshConfigModulePromise: Promise | undefined; +let sshTunnelModulePromise: Promise | undefined; + +function loadSshConfigModule() { + sshConfigModulePromise ??= import("../infra/ssh-config.js"); + return sshConfigModulePromise; +} + +function loadSshTunnelModule() { + sshTunnelModulePromise ??= import("../infra/ssh-tunnel.js"); + return sshTunnelModulePromise; +} + export async function gatewayStatusCommand( opts: { url?: string; @@ -87,6 +98,7 @@ export async function gatewayStatusCommand( return null; } try { + const { startSshPortForward } = await loadSshTunnelModule(); const tunnel = await startSshPortForward({ target: sshTarget, identity: sshIdentity ?? undefined, @@ -119,11 +131,13 @@ export async function gatewayStatusCommand( const base = user ? `${user}@${host.trim()}` : host.trim(); return sshPort !== 22 ? `${base}:${sshPort}` : base; }) - .filter((candidate): candidate is string => - Boolean(candidate && parseSshTarget(candidate)), - ); - if (candidates.length > 0) { - sshTarget = candidates[0] ?? null; + .filter((candidate): candidate is string => Boolean(candidate)); + const { parseSshTarget } = await loadSshTunnelModule(); + const validCandidates = candidates.filter((candidate) => + Boolean(parseSshTarget(candidate)), + ); + if (validCandidates.length > 0) { + sshTarget = validCandidates[0] ?? null; } } @@ -229,7 +243,7 @@ export async function gatewayStatusCommand( }); } for (const result of probed) { - if (result.authDiagnostics.length === 0) { + if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) { continue; } for (const diagnostic of result.authDiagnostics) { @@ -420,6 +434,10 @@ async function resolveSshTarget( identity: string | null, overallTimeoutMs: number, ): Promise<{ target: string; identity?: string } | null> { + const [{ resolveSshConfig }, { parseSshTarget }] = await Promise.all([ + loadSshConfigModule(), + loadSshTunnelModule(), + ]); const parsed = parseSshTarget(rawTarget); if (!parsed) { return null; diff --git a/src/commands/health.ts b/src/commands/health.ts index 56705c96270..ddfc308bda4 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,7 +1,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, readBestEffortConfig } from "../config/config.js"; @@ -161,17 +162,87 @@ const buildSessionSummary = (storePath: string) => { } satisfies HealthSummary["sessions"]; }; -const isAccountEnabled = (account: unknown): boolean => { - if (!account || typeof account !== "object") { - return true; - } - const enabled = (account as { enabled?: boolean }).enabled; - return enabled !== false; -}; - const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; +async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { + return ( + plugin.config.inspectAccount?.(cfg, accountId) ?? + (await inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + })) + ); +} + +function readBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +async function resolveHealthAccountContext(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; +}): Promise<{ + account: unknown; + enabled: boolean; + configured: boolean; + diagnostics: string[]; +}> { + const diagnostics: string[] = []; + let account: unknown; + try { + account = params.plugin.config.resolveAccount(params.cfg, params.accountId); + } catch (error) { + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + account = await inspectHealthAccount(params.plugin, params.cfg, params.accountId); + } + + if (!account) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } + + const enabledFallback = readBooleanField(account, "enabled") ?? true; + let enabled = enabledFallback; + if (params.plugin.config.isEnabled) { + try { + enabled = params.plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = enabledFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + const configuredFallback = readBooleanField(account, "configured") ?? true; + let configured = configuredFallback; + if (params.plugin.config.isConfigured) { + try { + configured = await params.plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = configuredFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; +} + const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { const record = asRecord(probe); if (!record) { @@ -416,13 +487,14 @@ export async function getHealthSnapshot(params?: { const accountSummaries: Record = {}; for (const accountId of accountIdsToProbe) { - const account = plugin.config.resolveAccount(cfg, accountId); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; + const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); + if (diagnostics.length > 0) { + debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics }); + } let probe: unknown; let lastProbeAt: number | null = null; @@ -588,16 +660,20 @@ export async function healthCommand( ` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`, ); for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + const { account, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); const record = asRecord(account); const tokenSource = record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; runtime.log( ` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`, ); + for (const diagnostic of diagnostics) { + runtime.log(` ! ${diagnostic}`); + } } } runtime.log(info("[debug] bindings map")); @@ -691,13 +767,31 @@ export async function healthCommand( defaultAccountId, boundAccounts, }); - const account = plugin.config.resolveAccount(cfg, accountId); - plugin.status.logSelfId({ - account, + const accountContext = await resolveHealthAccountContext({ + plugin, cfg, - runtime, - includeChannelPrefix: true, + accountId, }); + if (!accountContext.enabled || !accountContext.configured) { + continue; + } + if (accountContext.diagnostics.length > 0) { + continue; + } + try { + plugin.status.logSelfId({ + account: accountContext.account, + cfg, + runtime, + includeChannelPrefix: true, + }); + } catch (error) { + debugHealth("logSelfId.failed", { + channel: plugin.id, + accountId, + error: formatErrorMessage(error), + }); + } } if (resolvedAgents.length > 0) { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index c469f50a54e..faf1e7cfb7e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -5,11 +5,12 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { - patchChannelOnboardingAdapter, + patchChannelSetupFlowAdapter, setDefaultChannelPluginRegistryForTests, } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; import { + ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, reloadOnboardingPluginRegistry, } from "./onboarding/plugin-install.js"; @@ -19,6 +20,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn(), })); +const manifestRegistryMocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), +})); + function createPrompter(overrides: Partial): WizardPrompter { return createWizardPrompter( { @@ -91,8 +96,8 @@ function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig } as OpenClawConfig; } -function patchTelegramAdapter(overrides: Parameters[1]) { - return patchChannelOnboardingAdapter("telegram", { +function patchTelegramAdapter(overrides: Parameters[1]) { + return patchChannelSetupFlowAdapter("telegram", { ...overrides, getStatus: overrides.getStatus ?? @@ -197,6 +202,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboard-helpers.js", () => ({ detectBinary: vi.fn(async () => false), })); @@ -205,6 +218,10 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as Record), + ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + installed: true, + })), // Allow tests to simulate an empty plugin registry during onboarding. loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()), reloadOnboardingPluginRegistry: vi.fn(() => {}), @@ -215,6 +232,16 @@ describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); catalogMocks.listChannelPluginCatalogEntries.mockReset(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReset(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + vi.mocked(ensureOnboardingPluginInstalled).mockClear(); + vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + })); vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear(); vi.mocked(reloadOnboardingPluginRegistry).mockClear(); }); @@ -250,7 +277,7 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); - it("continues Telegram onboarding even when plugin registry is empty (avoids 'plugin not available' block)", async () => { + it("continues Telegram setup when the plugin registry is empty", async () => { // Simulate missing registry entries (the scenario reported in #25545). setActivePluginRegistry(createEmptyPluginRegistry()); // Avoid accidental env-token configuration changing the prompt path. @@ -284,11 +311,7 @@ describe("setupChannels", () => { ); }); expect(sawHardStop).toBe(false); - expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "telegram", - }), - ); + expect(loadOnboardingPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled(); }); @@ -404,6 +427,100 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("treats installed external plugin channels as installed without reinstall prompts", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + } satisfies ChannelPluginCatalogEntry, + ]); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "@openclaw/msteams-plugin", + channels: ["msteams"], + } as never, + ], + diagnostics: [], + }); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation( + ({ channel }: { channel: string }) => { + const registry = createEmptyPluginRegistry(); + if (channel === "msteams") { + registry.channelSetups.push({ + pluginId: "@openclaw/msteams-plugin", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + setupWizard: { + channel: "msteams", + status: { + configuredLabel: "configured", + unconfiguredLabel: "installed", + resolveConfigured: () => false, + resolveStatusLines: async () => [], + resolveSelectionHint: async () => "installed", + }, + credentials: [], + }, + outbound: { deliveryMode: "direct" }, + }, + } as never); + } + return registry; + }, + ); + + let channelSelectionCount = 0; + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select a channel") { + channelSelectionCount += 1; + return channelSelectionCount === 1 ? "msteams" : "__done__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels({} as OpenClawConfig, prompter); + + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("uses scoped plugin accounts when disabling a configured external channel", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); const setAccountEnabled = vi.fn( diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index cd269ac2cf9..67c78e7a72c 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,11 +1,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; +import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/setup-flow-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -16,35 +16,33 @@ import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; +import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; +import { resolveChannelSetupFlowAdapterForPlugin } from "./channel-setup/registry.js"; +import type { + ChannelSetupFlowAdapter, + ChannelSetupConfiguredResult, + ChannelSetupDmPolicy, + ChannelSetupResult, + ChannelSetupStatus, + SetupChannelsOptions, +} from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; -import { - loadBundledChannelOnboardingPlugin, - resolveChannelOnboardingAdapterForPlugin, -} from "./onboarding/registry.js"; -import type { - ChannelOnboardingAdapter, - ChannelOnboardingConfiguredResult, - ChannelOnboardingDmPolicy, - ChannelOnboardingResult, - ChannelOnboardingStatus, - SetupChannelsOptions, -} from "./onboarding/types.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; type ChannelStatusSummary = { installedPlugins: ReturnType; catalogEntries: ReturnType; - statusByChannel: Map; + installedCatalogEntries: ReturnType; + statusByChannel: Map; statusLines: string[]; }; @@ -93,7 +91,7 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; - plugin?: ChannelPlugin; + plugin?: ChannelOnboardingSetupPlugin; }): Promise { const { cfg, prompter, label, channel } = params; const plugin = params.plugin ?? getChannelSetupPlugin(channel); @@ -120,24 +118,20 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ChannelPlugin[]; - resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; + installedPlugins?: ChannelOnboardingSetupPlugin[]; + resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); - const installedChannelIds = new Set( - loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir, - env: process.env, - }).plugins.flatMap((plugin) => plugin.channels), - ); - const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); + const { installedCatalogEntries, installableCatalogEntries } = resolveChannelSetupEntries({ + cfg: params.cfg, + installedPlugins, + workspaceDir, + }); const resolveAdapter = params.resolveAdapter ?? ((channel: ChannelChoice) => - resolveChannelOnboardingAdapterForPlugin( + resolveChannelSetupFlowAdapterForPlugin( installedPlugins.find((plugin) => plugin.id === channel), )); const statusEntries = await Promise.all( @@ -167,8 +161,7 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const discoveredPluginStatuses = allCatalogEntries - .filter((entry) => installedChannelIds.has(entry.id)) + const discoveredPluginStatuses = installedCatalogEntries .filter((entry) => !statusByChannel.has(entry.id as ChannelChoice)) .map((entry) => { const configured = isChannelConfigured(params.cfg, entry.id); @@ -189,7 +182,7 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const catalogStatuses = catalogEntries.map((entry) => ({ + const catalogStatuses = installableCatalogEntries.map((entry) => ({ channel: entry.id, configured: false, statusLines: [`${entry.meta.label}: install plugin to enable`], @@ -206,7 +199,8 @@ async function collectChannelStatus(params: { const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { installedPlugins, - catalogEntries, + catalogEntries: installableCatalogEntries, + installedCatalogEntries, statusByChannel: mergedStatusByChannel, statusLines, }; @@ -277,13 +271,13 @@ async function maybeConfigureDmPolicies(params: { selection: ChannelChoice[]; prompter: WizardPrompter; accountIdsByChannel?: Map; - resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; + resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; - const resolve = params.resolveAdapter; + const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection .map((channel) => resolve(channel)?.dmPolicy) - .filter(Boolean) as ChannelOnboardingDmPolicy[]; + .filter(Boolean) as ChannelSetupDmPolicy[]; if (dmPolicies.length === 0) { return params.cfg; } @@ -297,7 +291,7 @@ async function maybeConfigureDmPolicies(params: { } let cfg = params.cfg; - const selectPolicy = async (policy: ChannelOnboardingDmPolicy) => { + const selectPolicy = async (policy: ChannelSetupDmPolicy) => { await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", @@ -340,7 +334,7 @@ async function maybeConfigureDmPolicies(params: { return cfg; } -// Channel-specific prompts moved into onboarding adapters. +// Channel-specific prompts moved into setup flow adapters. export async function setupChannels( cfg: OpenClawConfig, @@ -353,17 +347,19 @@ export async function setupChannels( const accountOverrides: Partial> = { ...options?.accountIds, }; - const scopedPluginsById = new Map(); + const scopedPluginsById = new Map(); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - const rememberScopedPlugin = (plugin: ChannelPlugin) => { + const rememberScopedPlugin = (plugin: ChannelOnboardingSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); options?.onResolvedPlugin?.(channel, plugin); }; - const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined => + const getVisibleChannelPlugin = ( + channel: ChannelChoice, + ): ChannelOnboardingSetupPlugin | undefined => scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelPlugin[] => { - const merged = new Map(); + const listVisibleInstalledPlugins = (): ChannelOnboardingSetupPlugin[] => { + const merged = new Map(); for (const plugin of listChannelSetupPlugins()) { merged.set(plugin.id, plugin); } @@ -375,7 +371,7 @@ export async function setupChannels( const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): Promise => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; @@ -394,21 +390,17 @@ export async function setupChannels( rememberScopedPlugin(plugin); return plugin; } - const bundledPlugin = await loadBundledChannelOnboardingPlugin(channel); - if (bundledPlugin) { - rememberScopedPlugin(bundledPlugin); - } - return bundledPlugin; + return undefined; }; - const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { + const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => { const scopedPlugin = scopedPluginsById.get(channel); if (scopedPlugin) { - return resolveChannelOnboardingAdapterForPlugin(scopedPlugin); + return resolveChannelSetupFlowAdapterForPlugin(scopedPlugin); } - return resolveChannelOnboardingAdapterForPlugin(getChannelSetupPlugin(channel)); + return resolveChannelSetupFlowAdapterForPlugin(getChannelSetupPlugin(channel)); }; const preloadConfiguredExternalPlugins = () => { - // Keep onboarding memory bounded by snapshot-loading only configured external plugins. + // Keep setup memory bounded by snapshot-loading only configured external plugins. const workspaceDir = resolveWorkspaceDir(); for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) { const channel = entry.id as ChannelChoice; @@ -428,14 +420,19 @@ export async function setupChannels( } preloadConfiguredExternalPlugins(); - const { installedPlugins, catalogEntries, statusByChannel, statusLines } = - await collectChannelStatus({ - cfg: next, - options, - accountOverrides, - installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleOnboardingAdapter, - }); + const { + installedPlugins, + catalogEntries, + installedCatalogEntries, + statusByChannel, + statusLines, + } = await collectChannelStatus({ + cfg: next, + options, + accountOverrides, + installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleSetupFlowAdapter, + }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -465,6 +462,13 @@ export async function setupChannels( label: plugin.meta.label, blurb: plugin.meta.blurb, })), + ...installedCatalogEntries + .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) + .map((entry) => ({ + id: entry.id as ChannelChoice, + label: entry.meta.label, + blurb: entry.meta.blurb, + })), ...catalogEntries .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) .map((entry) => ({ @@ -482,7 +486,7 @@ export async function setupChannels( const accountIdsByChannel = new Map(); const recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); accountIdsByChannel.set(channel, accountId); }; @@ -542,38 +546,20 @@ export async function setupChannels( }); const getChannelEntries = () => { - const core = listChatChannels(); - const installed = listVisibleInstalledPlugins(); - const installedIds = new Set(installed.map((plugin) => plugin.id)); - const workspaceDir = resolveWorkspaceDir(); - const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( - (entry) => !installedIds.has(entry.id), - ); - const metaById = new Map(); - for (const meta of core) { - metaById.set(meta.id, meta); - } - for (const plugin of installed) { - metaById.set(plugin.id, plugin.meta); - } - for (const entry of catalog) { - if (!metaById.has(entry.id)) { - metaById.set(entry.id, entry.meta); - } - } - const entries = Array.from(metaById, ([id, meta]) => ({ - id: id as ChannelChoice, - meta, - })); + const resolved = resolveChannelSetupEntries({ + cfg: next, + installedPlugins: listVisibleInstalledPlugins(), + workspaceDir: resolveWorkspaceDir(), + }); return { - entries, - catalog, - catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])), + entries: resolved.entries, + catalogById: resolved.installableCatalogById, + installedCatalogById: resolved.installedCatalogById, }; }; const refreshStatus = async (channel: ChannelChoice) => { - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { return; } @@ -596,11 +582,11 @@ export async function setupChannels( return false; } const plugin = await loadScopedChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!plugin) { if (adapter) { await prompter.note( - `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( + `${channel} plugin not available (continuing with setup). If the channel still doesn't work after setup, run \`${formatCliCommand( "openclaw plugins list", )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, "Channel setup", @@ -615,7 +601,7 @@ export async function setupChannels( return true; }; - const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => { + const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { next = result.cfg; if (result.accountId) { recordAccount(channel, result.accountId); @@ -624,21 +610,21 @@ export async function setupChannels( await refreshStatus(channel); }; - const applyCustomOnboardingResult = async ( + const applyCustomSetupResult = async ( channel: ChannelChoice, - result: ChannelOnboardingConfiguredResult, + result: ChannelSetupConfiguredResult, ) => { if (result === "skip") { return false; } - await applyOnboardingResult(channel, result); + await applySetupResult(channel, result); return true; }; const configureChannel = async (channel: ChannelChoice) => { - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { - await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup"); + await prompter.note(`${channel} does not support guided setup yet.`, "Channel setup"); return; } const result = await adapter.configure({ @@ -650,12 +636,12 @@ export async function setupChannels( shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); - await applyOnboardingResult(channel, result); + await applySetupResult(channel, result); }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { const plugin = getVisibleChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, @@ -668,7 +654,7 @@ export async function setupChannels( configured: true, label, }); - if (!(await applyCustomOnboardingResult(channel, custom))) { + if (!(await applyCustomSetupResult(channel, custom))) { return; } return; @@ -746,8 +732,9 @@ export async function setupChannels( }; const handleChannelChoice = async (channel: ChannelChoice) => { - const { catalogById } = getChannelEntries(); + const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); + const installedCatalogEntry = installedCatalogById.get(channel); if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); const result = await ensureOnboardingPluginInstalled({ @@ -763,6 +750,13 @@ export async function setupChannels( } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); + } else if (installedCatalogEntry) { + const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); + if (!plugin) { + await prompter.note(`${channel} plugin not available.`, "Channel setup"); + return; + } + await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); if (!enabled) { @@ -771,7 +765,7 @@ export async function setupChannels( } const plugin = getVisibleChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; @@ -787,7 +781,7 @@ export async function setupChannels( configured, label, }); - if (!(await applyCustomOnboardingResult(channel, custom))) { + if (!(await applyCustomSetupResult(channel, custom))) { return; } return; @@ -860,7 +854,7 @@ export async function setupChannels( selection, prompter, accountIdsByChannel, - resolveAdapter: getVisibleOnboardingAdapter, + resolveAdapter: getVisibleSetupFlowAdapter, }); } diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 93451a9d6e9..00bfd6382a6 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -116,6 +116,19 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test"); }); + it("sets provider and key for firecrawl and enables the plugin", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "firecrawl", + textValue: "fc-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBe("fc-test-key"); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + }); + it("sets provider and key for grok", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ @@ -331,9 +344,9 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); }); - it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { - expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); + it("exports all 6 providers in SEARCH_PROVIDER_OPTIONS", () => { + expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6); const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); - expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity"]); + expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity", "firecrawl"]); }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index af5f3cd9a8f..72fafe461d2 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,6 +6,7 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { enablePluginInConfig } from "../plugins/enable.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -15,7 +16,7 @@ export type SearchProvider = NonNullable< NonNullable["web"]>["search"]>["provider"] >; -const SEARCH_PROVIDER_IDS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const SEARCH_PROVIDER_IDS = ["brave", "firecrawl", "gemini", "grok", "kimi", "perplexity"] as const; function isSearchProvider(value: string): value is SearchProvider { return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value); @@ -114,17 +115,21 @@ export function applySearchKey( if (entry) { entry.setCredentialValue(search as Record, key); } - return { + const next = { ...config, tools: { ...config.tools, web: { ...config.tools?.web, search }, }, }; + if (provider !== "firecrawl") { + return next; + } + return enablePluginInConfig(next, "firecrawl").config; } function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { - return { + const next = { ...config, tools: { ...config.tools, @@ -138,6 +143,10 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op }, }, }; + if (provider !== "firecrawl") { + return next; + } + return enablePluginInConfig(next, "firecrawl").config; } function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts deleted file mode 100644 index 01bc0deeb7a..00000000000 --- a/src/commands/onboarding/registry.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; -import type { ChannelPlugin } from "../../channels/plugins/types.js"; -import type { ChannelChoice } from "../onboard-types.js"; -import type { ChannelOnboardingAdapter } from "./types.js"; - -const setupWizardAdapters = new WeakMap(); - -export function resolveChannelOnboardingAdapterForPlugin( - plugin?: ChannelPlugin, -): ChannelOnboardingAdapter | undefined { - if (plugin?.setupWizard) { - const cached = setupWizardAdapters.get(plugin); - if (cached) { - return cached; - } - const adapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin, - wizard: plugin.setupWizard, - }); - setupWizardAdapters.set(plugin, adapter); - return adapter; - } - return undefined; -} - -const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map(); - for (const plugin of listChannelSetupPlugins()) { - const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); - if (!adapter) { - continue; - } - adapters.set(plugin.id, adapter); - } - return adapters; -}; - -export function getChannelOnboardingAdapter( - channel: ChannelChoice, -): ChannelOnboardingAdapter | undefined { - return CHANNEL_ONBOARDING_ADAPTERS().get(channel); -} - -export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { - return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values()); -} - -export async function loadBundledChannelOnboardingPlugin( - channel: ChannelChoice, -): Promise { - switch (channel) { - case "discord": - return (await import("../../../extensions/discord/setup-entry.js")).default.plugin; - case "imessage": - return (await import("../../../extensions/imessage/setup-entry.js")).default.plugin; - case "signal": - return (await import("../../../extensions/signal/setup-entry.js")).default.plugin; - case "slack": - return (await import("../../../extensions/slack/setup-entry.js")).default.plugin; - case "telegram": - return (await import("../../../extensions/telegram/setup-entry.js")).default.plugin; - case "whatsapp": - return (await import("../../../extensions/whatsapp/setup-entry.js")).default.plugin; - default: - return undefined; - } -} - -// Legacy aliases (pre-rename). -export const getProviderOnboardingAdapter = getChannelOnboardingAdapter; -export const listProviderOnboardingAdapters = listChannelOnboardingAdapters; diff --git a/src/commands/onboarding/types.ts b/src/commands/onboarding/types.ts deleted file mode 100644 index fb0430abda0..00000000000 --- a/src/commands/onboarding/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../channels/plugins/onboarding-types.js"; diff --git a/src/commands/sandbox-display.ts b/src/commands/sandbox-display.ts index 181af6bcc1f..8eaf245c5bf 100644 --- a/src/commands/sandbox-display.ts +++ b/src/commands/sandbox-display.ts @@ -30,12 +30,15 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R displayItems( containers, { - emptyMessage: "No sandbox containers found.", - title: "📦 Sandbox Containers:", + emptyMessage: "No sandbox runtimes found.", + title: "📦 Sandbox Runtimes:", renderItem: (container, rt) => { - rt.log(` ${container.containerName}`); + rt.log(` ${container.runtimeLabel ?? container.containerName}`); rt.log(` Status: ${formatStatus(container.running)}`); - rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`); + rt.log( + ` ${container.configLabelKind ?? "Image"}: ${container.image} ${formatImageMatch(container.imageMatch)}`, + ); + rt.log(` Backend: ${container.backendId ?? "docker"}`); rt.log( ` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`, ); @@ -92,9 +95,9 @@ export function displaySummary( runtime.log(`Total: ${totalCount} (${runningCount} running)`); if (mismatchCount > 0) { - runtime.log(`\n⚠️ ${mismatchCount} container(s) with image mismatch detected.`); + runtime.log(`\n⚠️ ${mismatchCount} runtime(s) with config mismatch detected.`); runtime.log( - ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all containers.`, + ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all runtimes.`, ); } } @@ -104,12 +107,14 @@ export function displayRecreatePreview( browsers: SandboxBrowserInfo[], runtime: RuntimeEnv, ): void { - runtime.log("\nContainers to be recreated:\n"); + runtime.log("\nSandbox runtimes to be recreated:\n"); if (containers.length > 0) { - runtime.log("📦 Sandbox Containers:"); + runtime.log("📦 Sandbox Runtimes:"); for (const container of containers) { - runtime.log(` - ${container.containerName} (${formatSimpleStatus(container.running)})`); + runtime.log( + ` - ${container.runtimeLabel ?? container.containerName} [${container.backendId ?? "docker"}] (${formatSimpleStatus(container.running)})`, + ); } } @@ -121,7 +126,7 @@ export function displayRecreatePreview( } const total = containers.length + browsers.length; - runtime.log(`\nTotal: ${total} container(s)`); + runtime.log(`\nTotal: ${total} runtime(s)`); } export function displayRecreateResult( @@ -131,6 +136,6 @@ export function displayRecreateResult( runtime.log(`\nDone: ${result.successCount} removed, ${result.failCount} failed`); if (result.successCount > 0) { - runtime.log("\nContainers will be automatically recreated when the agent is next used."); + runtime.log("\nRuntimes will be automatically recreated when the agent is next used."); } } diff --git a/src/commands/sandbox.test.ts b/src/commands/sandbox.test.ts index 384dc2eef41..7425e712c6f 100644 --- a/src/commands/sandbox.test.ts +++ b/src/commands/sandbox.test.ts @@ -29,10 +29,14 @@ import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js"; const NOW = Date.now(); function createContainer(overrides: Partial = {}): SandboxContainerInfo { + const containerName = overrides.containerName ?? "openclaw-sandbox-test"; return { - containerName: "openclaw-sandbox-test", + containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: "test-session", image: "openclaw/sandbox:latest", + configLabelKind: "Image", imageMatch: true, running: true, createdAtMs: NOW - 3600000, @@ -104,7 +108,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expectLogContains(runtime, "📦 Sandbox Containers"); + expectLogContains(runtime, "📦 Sandbox Runtimes"); expectLogContains(runtime, container1.containerName); expectLogContains(runtime, container2.containerName); expectLogContains(runtime, "Total"); @@ -128,14 +132,14 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); expectLogContains(runtime, "⚠️"); - expectLogContains(runtime, "image mismatch"); + expectLogContains(runtime, "config mismatch"); expectLogContains(runtime, "sandbox recreate --all"); }); it("should display message when no containers found", async () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); @@ -161,7 +165,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); }); @@ -295,7 +299,7 @@ describe("sandboxRecreateCommand", () => { it("should show message when no containers match", async () => { await sandboxRecreateCommand({ all: true, browser: false, force: true }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No containers found matching the criteria."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found matching the criteria."); expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); }); diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index e9071ce7810..d6b494fc5aa 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -74,7 +74,7 @@ export async function sandboxRecreateCommand( const filtered = await fetchAndFilterContainers(opts); if (filtered.containers.length + filtered.browsers.length === 0) { - runtime.log("No containers found matching the criteria."); + runtime.log("No sandbox runtimes found matching the criteria."); return; } @@ -154,7 +154,7 @@ async function removeContainers( filtered: FilteredContainers, runtime: RuntimeEnv, ): Promise<{ successCount: number; failCount: number }> { - runtime.log("\nRemoving containers...\n"); + runtime.log("\nRemoving sandbox runtimes...\n"); let successCount = 0; let failCount = 0; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index fa4e3dcb435..b643c30ff33 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -48,7 +48,7 @@ export async function statusAllCommand( config: loadedRaw, commandName: "status --all", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index cf3a67a99b5..27e0eff43c6 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -91,14 +91,18 @@ function formatTokenHint(token: string, opts: { showSecrets: boolean }): string return `${head}…${tail} · len ${t.length}`; } -function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { +async function inspectChannelAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -106,8 +110,8 @@ async function resolveChannelAccountRow( params: ResolvedChannelAccountRowParams, ): Promise { const { plugin, cfg, sourceConfig, accountId } = params; - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, cfg, accountId); const resolvedInspection = resolvedInspectedAccount as { enabled?: boolean; configured?: boolean; diff --git a/src/commands/status.link-channel.test.ts b/src/commands/status.link-channel.test.ts new file mode 100644 index 00000000000..14315ef1a35 --- /dev/null +++ b/src/commands/status.link-channel.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] })); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => pluginRegistry.list, +})); + +import { resolveLinkChannelContext } from "./status.link-channel.js"; + +describe("resolveLinkChannelContext", () => { + it("returns linked context from read-only inspected account state", async () => { + const account = { configured: true, enabled: true }; + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => { + throw new Error("should not be called in read-only mode"); + }, + }, + status: { + buildChannelSummary: () => ({ linked: true, authAgeMs: 1234 }), + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result?.linked).toBe(true); + expect(result?.authAgeMs).toBe(1234); + expect(result?.account).toBe(account); + }); + + it("degrades safely when account resolution throws", async () => { + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result).toBeNull(); + }); +}); diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 2ee0eee4f2e..4f192f31623 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -16,7 +16,10 @@ export async function resolveLinkChannelContext( ): Promise { for (const plugin of listChannelPlugins()) { const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "status", + }); const snapshot = plugin.config.describeAccount ? plugin.config.describeAccount(account, cfg) : ({ diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 9d3399997bf..122e10076bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), buildChannelsTable: vi.fn(), + callGateway: vi.fn(), getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), @@ -51,7 +52,7 @@ vi.mock("../infra/tailscale.js", () => ({ vi.mock("../gateway/call.js", () => ({ buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, - callGateway: vi.fn(), + callGateway: mocks.callGateway, })); vi.mock("../gateway/probe.js", () => ({ @@ -193,7 +194,7 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); - it("preloads channel plugins for status --json when channel config exists", async () => { + it("preloads configured channel plugins for status --json when channel config exists", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, plugins: { enabled: false }, @@ -244,10 +245,18 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ detailLevel: "presence" }), + ); + expect(mocks.callGateway).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "channels.status" }), + ); }); - it("preloads channel plugins for status --json when channel auth is env-only", async () => { + it("preloads configured channel plugins for status --json when channel auth is env-only", async () => { const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; process.env.MATRIX_ACCESS_TOKEN = "token"; mocks.readBestEffortConfig.mockResolvedValue({ @@ -306,6 +315,8 @@ describe("scanStatus", () => { } } - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 0de308f17f2..7f1380964d5 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -98,6 +98,7 @@ async function resolveGatewayProbeSnapshot(params: { url: gatewayConnection.url, auth: gatewayProbeAuthResolution.auth, timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + detailLevel: "presence", }).catch(() => null); if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) { gatewayProbe.error = gatewayProbe.error @@ -197,11 +198,11 @@ async function scanStatusJsonFast(opts: { config: loadedRaw, commandName: "status --json", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: "channels" }); + ensurePluginRegistryLoaded({ scope: "configured-channels" }); } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; @@ -247,11 +248,9 @@ async function scanStatusJsonFast(opts: { const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) : null; - const channelsStatusPromise = resolveChannelsStatus({ cfg, gatewayReachable, opts }); const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); - const [channelsStatus, memory] = await Promise.all([channelsStatusPromise, memoryPromise]); - const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; + const memory = await memoryPromise; return { cfg, @@ -270,7 +269,7 @@ async function scanStatusJsonFast(opts: { gatewayProbe, gatewayReachable, gatewaySelf, - channelIssues, + channelIssues: [], agentStatus, channels: { rows: [], details: [] }, summary, @@ -304,7 +303,7 @@ export async function scanStatus( config: loadedRaw, commandName: "status", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5cc71b6e950..f3dfd37064a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -512,6 +512,11 @@ describe("statusCommand", () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull(); + if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) { + expect( + payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")), + ).toBe(true); + } expect(runtime.error).not.toHaveBeenCalled(); }); diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 9df692962f2..912e70ac5a4 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -16,6 +16,11 @@ vi.mock("../plugins/web-search-providers.js", () => { envVars: ["BRAVE_API_KEY"], getCredentialValue: (search?: Record) => search?.apiKey, }, + { + id: "firecrawl", + envVars: ["FIRECRAWL_API_KEY"], + getCredentialValue: getScoped("firecrawl"), + }, { id: "gemini", envVars: ["GEMINI_API_KEY"], @@ -75,6 +80,21 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("accepts firecrawl provider and config", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + enabled: true, + provider: "firecrawl", + providerConfig: { + apiKey: "fc-test-key", // pragma: allowlist secret + baseUrl: "https://api.firecrawl.dev", + }, + }), + ); + + expect(res.ok).toBe(true); + }); + it("accepts gemini provider with no extra config", () => { const res = validateConfigObject( buildWebSearchProviderConfig({ @@ -117,6 +137,7 @@ describe("web search provider auto-detection", () => { beforeEach(() => { delete process.env.BRAVE_API_KEY; + delete process.env.FIRECRAWL_API_KEY; delete process.env.GEMINI_API_KEY; delete process.env.KIMI_API_KEY; delete process.env.MOONSHOT_API_KEY; @@ -146,6 +167,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("gemini"); }); + it("auto-detects firecrawl when only FIRECRAWL_API_KEY is set", () => { + process.env.FIRECRAWL_API_KEY = "fc-test-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("firecrawl"); + }); + it("auto-detects kimi when only KIMI_API_KEY is set", () => { process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 0d03f9574b1..e5d30070317 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -665,13 +665,17 @@ export const FIELD_HELP: Record = { "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": - 'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', + 'Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", "tools.web.search.brave.mode": 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', + "tools.web.search.firecrawl.apiKey": + "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.search.firecrawl.baseUrl": + 'Firecrawl Search base URL override (default: "https://api.firecrawl.dev").', "tools.web.search.gemini.apiKey": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index dc5195fb766..d2c0cb29e48 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -221,6 +221,8 @@ export const FIELD_LABELS: Record = { "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", "tools.web.search.brave.mode": "Brave Search Mode", + "tools.web.search.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret + "tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL", "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 152c8973c11..1e398cc1c70 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -15,6 +15,8 @@ export type AgentModelConfig = export type AgentSandboxConfig = { mode?: "off" | "non-main" | "all"; + /** Sandbox runtime backend id. Default: "docker". */ + backend?: string; /** Agent workspace access inside the sandbox. */ workspaceAccess?: "none" | "ro" | "rw"; /** diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 43d39285b57..d1195ace393 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -457,8 +457,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */ - provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity"; + /** Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */ + provider?: "brave" | "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: SecretInput; /** Default search results count (1-10). */ @@ -479,6 +479,13 @@ export type ToolsConfig = { /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; + /** Firecrawl-specific configuration (used when provider="firecrawl"). */ + firecrawl?: { + /** Firecrawl API key (defaults to FIRECRAWL_API_KEY env var). */ + apiKey?: SecretInput; + /** Base URL for API requests (defaults to "https://api.firecrawl.dev"). */ + baseUrl?: string; + }; /** Grok-specific configuration (used when provider="grok"). */ grok?: { /** API key for xAI (defaults to XAI_API_KEY env var). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d7b1dd393e7..9ddbedf929e 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -266,6 +266,7 @@ export const ToolsWebSearchSchema = z provider: z .union([ z.literal("brave"), + z.literal("firecrawl"), z.literal("perplexity"), z.literal("grok"), z.literal("gemini"), @@ -301,6 +302,13 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + firecrawl: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + }) + .strict() + .optional(), kimi: z .object({ apiKey: SecretInputSchema.optional().register(sensitive), @@ -496,6 +504,7 @@ const ToolLoopDetectionSchema = z export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), + backend: z.string().min(1).optional(), workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 68dc4edb71c..920f4533297 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +const childProcessMocks = vi.hoisted(() => ({ + execFileSync: vi.fn(), +})); + const fsMocks = vi.hoisted(() => ({ access: vi.fn(), realpath: vi.fn(), @@ -12,6 +16,10 @@ vi.mock("node:fs/promises", () => ({ realpath: fsMocks.realpath, })); +vi.mock("node:child_process", () => ({ + execFileSync: childProcessMocks.execFileSync, +})); + import { resolveGatewayProgramArguments } from "./program-args.js"; const originalArgv = [...process.argv]; @@ -87,4 +95,28 @@ describe("resolveGatewayProgramArguments", () => { "18789", ]); }); + + it("uses src/entry.ts for bun dev mode", async () => { + const repoIndexPath = path.resolve("/repo/src/index.ts"); + const repoEntryPath = path.resolve("/repo/src/entry.ts"); + process.argv = ["/usr/local/bin/node", repoIndexPath]; + fsMocks.realpath.mockResolvedValue(repoIndexPath); + fsMocks.access.mockResolvedValue(undefined); + childProcessMocks.execFileSync.mockReturnValue("/usr/local/bin/bun\n"); + + const result = await resolveGatewayProgramArguments({ + dev: true, + port: 18789, + runtime: "bun", + }); + + expect(result.programArguments).toEqual([ + "/usr/local/bin/bun", + repoEntryPath, + "gateway", + "--port", + "18789", + ]); + expect(result.workingDirectory).toBe(path.resolve("/repo")); + }); }); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 76bad8fc1ce..9e60f26f761 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -123,7 +123,7 @@ function resolveRepoRootForDev(): string { const parts = normalized.split(path.sep); const srcIndex = parts.lastIndexOf("src"); if (srcIndex === -1) { - throw new Error("Dev mode requires running from repo (src/index.ts)"); + throw new Error("Dev mode requires running from repo (src/entry.ts)"); } return parts.slice(0, srcIndex).join(path.sep); } @@ -180,7 +180,7 @@ async function resolveCliProgramArguments(params: { if (runtime === "bun") { if (params.dev) { const repoRoot = resolveRepoRootForDev(); - const devCliPath = path.join(repoRoot, "src", "index.ts"); + const devCliPath = path.join(repoRoot, "src", "entry.ts"); await fs.access(devCliPath); const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); return { @@ -213,7 +213,7 @@ async function resolveCliProgramArguments(params: { // Dev mode: use bun to run TypeScript directly const repoRoot = resolveRepoRootForDev(); - const devCliPath = path.join(repoRoot, "src", "index.ts"); + const devCliPath = path.join(repoRoot, "src", "entry.ts"); await fs.access(devCliPath); // If already running under bun, use current execPath diff --git a/src/gateway/call.ts b/src/gateway/call.ts index f163a45ef06..300391b6047 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -330,11 +330,8 @@ async function resolveGatewaySecretInputString(params: { value: params.value, env: params.env, normalize: trimToUndefined, - onResolveRefError: (error) => { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); + onResolveRefError: () => { + throw new GatewaySecretRefUnavailableError(params.path); }, }); if (!value) { diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 64980be601e..2c624acaa00 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -54,10 +54,22 @@ export function resolveGatewayProbeAuthSafe(params: { cfg: OpenClawConfig; mode: "local" | "remote"; env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; }): { auth: { token?: string; password?: string }; warning?: string; } { + const explicitToken = params.explicitAuth?.token?.trim(); + const explicitPassword = params.explicitAuth?.password?.trim(); + if (explicitToken || explicitPassword) { + return { + auth: { + ...(explicitToken ? { token: explicitToken } : {}), + ...(explicitPassword ? { password: explicitPassword } : {}), + }, + }; + } + try { return { auth: resolveGatewayProbeAuth(params) }; } catch (error) { diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 6cd7d64fc51..f91dc5148d5 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -81,4 +81,18 @@ describe("probeGateway", () => { expect(result.ok).toBe(true); expect(gatewayClientState.requests).toEqual([]); }); + + it("fetches only presence for presence-only probes", async () => { + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + timeoutMs: 1_000, + detailLevel: "presence", + }); + + expect(result.ok).toBe(true); + expect(gatewayClientState.requests).toEqual(["system-presence"]); + expect(result.health).toBeNull(); + expect(result.status).toBeNull(); + expect(result.configSnapshot).toBeNull(); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 40740987fb0..87a77b8bfef 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -34,6 +34,7 @@ export async function probeGateway(opts: { auth?: GatewayProbeAuth; timeoutMs: number; includeDetails?: boolean; + detailLevel?: "none" | "presence" | "full"; }): Promise { const startedAt = Date.now(); const instanceId = randomUUID(); @@ -49,6 +50,8 @@ export async function probeGateway(opts: { } })(); + const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full"); + return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -79,7 +82,7 @@ export async function probeGateway(opts: { }, onHelloOk: async () => { connectLatencyMs = Date.now() - startedAt; - if (opts.includeDetails === false) { + if (detailLevel === "none") { settle({ ok: true, connectLatencyMs, @@ -93,6 +96,20 @@ export async function probeGateway(opts: { return; } try { + if (detailLevel === "presence") { + const presence = await client.request("system-presence"); + settle({ + ok: true, + connectLatencyMs, + error: null, + close, + health: null, + status: null, + presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null, + configSnapshot: null, + }); + return; + } const [health, status, presence, configSnapshot] = await Promise.all([ client.request("health"), client.request("status"), diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000000..d53d492c527 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const runtimeMocks = vi.hoisted(() => ({ + runCli: vi.fn(async () => {}), +})); + +vi.mock("./cli/run-main.js", () => ({ + runCli: runtimeMocks.runCli, +})); + +describe("legacy root entry", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("routes the package root export to the pure library entry", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { + exports?: Record; + main?: string; + }; + + expect(packageJson.main).toBe("dist/index.js"); + expect(packageJson.exports?.["."]).toBe("./dist/index.js"); + }); + + it("does not run CLI bootstrap when imported as a library dependency", async () => { + const mod = await import("./index.js"); + + expect(typeof mod.runLegacyCliEntry).toBe("function"); + expect(runtimeMocks.runCli).not.toHaveBeenCalled(); + }); + + it("delegates legacy direct-entry execution to run-main", async () => { + const mod = await import("./index.js"); + const argv = ["node", "dist/index.js", "status"]; + + await mod.runLegacyCliEntry(argv); + + expect(runtimeMocks.runCli).toHaveBeenCalledOnce(); + expect(runtimeMocks.runCli).toHaveBeenCalledWith(argv); + }); +}); diff --git a/src/index.ts b/src/index.ts index 61d96ccee33..4daf6521df7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,76 +1,40 @@ #!/usr/bin/env node import process from "node:process"; import { fileURLToPath } from "node:url"; -import { getReplyFromConfig } from "./auto-reply/reply.js"; -import { applyTemplate } from "./auto-reply/templating.js"; -import { monitorWebChannel } from "./channel-web.js"; -import { createDefaultDeps } from "./cli/deps.js"; -import { promptYesNo } from "./cli/prompt.js"; -import { waitForever } from "./cli/wait.js"; -import { loadConfig } from "./config/config.js"; -import { - deriveSessionKey, - loadSessionStore, - resolveSessionKey, - resolveStorePath, - saveSessionStore, -} from "./config/sessions.js"; -import { ensureBinary } from "./infra/binaries.js"; -import { loadDotEnv } from "./infra/dotenv.js"; -import { normalizeEnv } from "./infra/env.js"; import { formatUncaughtError } from "./infra/errors.js"; import { isMainModule } from "./infra/is-main.js"; -import { ensureOpenClawCliOnPath } from "./infra/path-env.js"; -import { - describePortOwner, - ensurePortAvailable, - handlePortError, - PortInUseError, -} from "./infra/ports.js"; -import { assertSupportedRuntime } from "./infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; -import { enableConsoleCapture } from "./logging.js"; -import { runCommandWithTimeout, runExec } from "./process/exec.js"; -import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; -loadDotEnv({ quiet: true }); -normalizeEnv(); -ensureOpenClawCliOnPath(); +const library = await import("./library.js"); -// Capture all console output into structured logs while keeping stdout/stderr behavior. -enableConsoleCapture(); +export const assertWebChannel = library.assertWebChannel; +export const applyTemplate = library.applyTemplate; +export const createDefaultDeps = library.createDefaultDeps; +export const deriveSessionKey = library.deriveSessionKey; +export const describePortOwner = library.describePortOwner; +export const ensureBinary = library.ensureBinary; +export const ensurePortAvailable = library.ensurePortAvailable; +export const getReplyFromConfig = library.getReplyFromConfig; +export const handlePortError = library.handlePortError; +export const loadConfig = library.loadConfig; +export const loadSessionStore = library.loadSessionStore; +export const monitorWebChannel = library.monitorWebChannel; +export const normalizeE164 = library.normalizeE164; +export const PortInUseError = library.PortInUseError; +export const promptYesNo = library.promptYesNo; +export const resolveSessionKey = library.resolveSessionKey; +export const resolveStorePath = library.resolveStorePath; +export const runCommandWithTimeout = library.runCommandWithTimeout; +export const runExec = library.runExec; +export const saveSessionStore = library.saveSessionStore; +export const toWhatsappJid = library.toWhatsappJid; +export const waitForever = library.waitForever; -// Enforce the minimum supported runtime before doing any work. -assertSupportedRuntime(); - -import { buildProgram } from "./cli/program.js"; - -const program = buildProgram(); - -export { - assertWebChannel, - applyTemplate, - createDefaultDeps, - deriveSessionKey, - describePortOwner, - ensureBinary, - ensurePortAvailable, - getReplyFromConfig, - handlePortError, - loadConfig, - loadSessionStore, - monitorWebChannel, - normalizeE164, - PortInUseError, - promptYesNo, - resolveSessionKey, - resolveStorePath, - runCommandWithTimeout, - runExec, - saveSessionStore, - toWhatsappJid, - waitForever, -}; +// Legacy direct file entrypoint only. Package root exports now live in library.ts. +export async function runLegacyCliEntry(argv: string[] = process.argv): Promise { + const { runCli } = await import("./cli/run-main.js"); + await runCli(argv); +} const isMain = isMainModule({ currentFile: fileURLToPath(import.meta.url), @@ -86,7 +50,7 @@ if (isMain) { process.exit(1); }); - void program.parseAsync(process.argv).catch((err) => { + void runLegacyCliEntry(process.argv).catch((err) => { console.error("[openclaw] CLI failed:", formatUncaughtError(err)); process.exit(1); }); diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 08fd35d9327..d537b5eb317 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -105,14 +105,18 @@ const buildAccountDetails = (params: { return details; }; -function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { +async function inspectChannelAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -135,8 +139,8 @@ export async function buildChannelSummary( const entries: ChannelAccountEntry[] = []; for (const accountId of resolvedAccountIds) { - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, effective, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, effective, accountId); const resolvedInspection = resolvedInspectedAccount as { enabled?: boolean; configured?: boolean; diff --git a/src/infra/gateway-process-argv.test.ts b/src/infra/gateway-process-argv.test.ts index 81e6da2210a..8f072a80ca6 100644 --- a/src/infra/gateway-process-argv.test.ts +++ b/src/infra/gateway-process-argv.test.ts @@ -26,6 +26,7 @@ describe("isGatewayArgv", () => { expect(isGatewayArgv(["NODE", "C:\\OpenClaw\\DIST\\ENTRY.JS", "gateway"])).toBe(true); expect(isGatewayArgv(["bun", "/srv/openclaw/scripts/run-node.mjs", "gateway"])).toBe(true); expect(isGatewayArgv(["node", "/srv/openclaw/openclaw.mjs", "gateway"])).toBe(true); + expect(isGatewayArgv(["tsx", "/srv/openclaw/src/entry.ts", "gateway"])).toBe(true); expect(isGatewayArgv(["tsx", "/srv/openclaw/src/index.ts", "gateway"])).toBe(true); }); diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts index 59f042ead88..47eab54fce2 100644 --- a/src/infra/gateway-process-argv.ts +++ b/src/infra/gateway-process-argv.ts @@ -20,6 +20,7 @@ export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: bool "dist/entry.js", "openclaw.mjs", "scripts/run-node.mjs", + "src/entry.ts", "src/index.ts", ]; if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index da605dcdb63..9448b919312 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), + resolveOutboundChannelPlugin: vi.fn(), })); vi.mock("../../channels/plugins/index.js", () => ({ listChannelPlugins: mocks.listChannelPlugins, })); +vi.mock("./channel-resolution.js", () => ({ + resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, +})); + import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -36,6 +41,10 @@ describe("listConfiguredMessageChannels", () => { beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); + mocks.resolveOutboundChannelPlugin.mockReset(); + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ + id: channel, + })); }); it("skips unknown plugin ids and plugins without accounts", async () => { @@ -158,6 +167,35 @@ describe("resolveMessageChannelSelection", () => { ).rejects.toThrow("Unknown channel: channel:c123"); }); + it("falls back when the explicit known channel is unavailable in the active plugin registry", async () => { + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => + channel === "slack" ? { id: "slack" } : undefined, + ); + + const selection = await resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + fallbackChannel: "slack", + }); + + expect(selection).toEqual({ + channel: "slack", + configured: [], + source: "tool-context-fallback", + }); + }); + + it("throws unavailable when a known channel has no active plugin", async () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined); + + await expect( + resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + }), + ).rejects.toThrow("Channel is unavailable: discord"); + }); + it("throws when no channel is provided and nothing is configured", async () => { await expect( resolveMessageChannelSelection({ diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 9fbd592a589..024fc2273f6 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -7,6 +7,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; export type MessageChannelId = DeliverableMessageChannel; export type MessageChannelSelectionSource = @@ -34,6 +35,22 @@ function resolveKnownChannel(value?: string | null): MessageChannelId | undefine return normalized as MessageChannelId; } +function resolveAvailableKnownChannel(params: { + cfg: OpenClawConfig; + value?: string | null; +}): MessageChannelId | undefined { + const normalized = resolveKnownChannel(params.value); + if (!normalized) { + return undefined; + } + return resolveOutboundChannelPlugin({ + channel: normalized, + cfg: params.cfg, + }) + ? normalized + : undefined; +} + function isAccountEnabled(account: unknown): boolean { if (!account || typeof account !== "object") { return true; @@ -94,8 +111,15 @@ export async function resolveMessageChannelSelection(params: { }> { const normalized = normalizeMessageChannel(params.channel); if (normalized) { - if (!isKnownChannel(normalized)) { - const fallback = resolveKnownChannel(params.fallbackChannel); + const availableExplicit = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: normalized, + }); + if (!availableExplicit) { + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, @@ -103,16 +127,22 @@ export async function resolveMessageChannelSelection(params: { source: "tool-context-fallback", }; } - throw new Error(`Unknown channel: ${String(normalized)}`); + if (!isKnownChannel(normalized)) { + throw new Error(`Unknown channel: ${String(normalized)}`); + } + throw new Error(`Channel is unavailable: ${String(normalized)}`); } return { - channel: normalized as MessageChannelId, + channel: availableExplicit, configured: await listConfiguredMessageChannels(params.cfg), source: "explicit", }; } - const fallback = resolveKnownChannel(params.fallbackChannel); + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index b3033c5cf21..909cc7ce9ef 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -20,6 +20,7 @@ import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js"; import { throwIfAborted } from "./abort.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -671,6 +672,11 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise([ - "anthropic", - "github-copilot", - "google-gemini-cli", - "openai-codex", -]); - -function parseGoogleToken(apiKey: string): { token: string } | null { - try { - const parsed = JSON.parse(apiKey) as { token?: unknown }; - if (parsed && typeof parsed.token === "string") { - return { token: parsed.token }; - } - } catch { - // ignore - } - return null; -} - -function resolveLegacyZaiApiKey(state: UsageAuthState): string | undefined { - try { - const authPath = path.join( - resolveRequiredHomeDir(state.env, os.homedir), - ".pi", - "agent", - "auth.json", - ); - if (!fs.existsSync(authPath)) { - return undefined; - } - const data = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record< - string, - { access?: string } - >; - return data["z-ai"]?.access || data.zai?.access; - } catch { - return undefined; - } -} - function resolveProviderApiKeyFromConfigAndStore(params: { state: UsageAuthState; providerIds: string[]; @@ -236,66 +192,7 @@ export async function resolveProviderAuths(params: { }); if (pluginAuth) { auths.push(pluginAuth); - continue; } - - if (provider === "zai") { - const apiKey = - resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["zai", "z-ai"], - envDirect: [state.env.ZAI_API_KEY, state.env.Z_AI_API_KEY], - }) ?? resolveLegacyZaiApiKey(state); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (provider === "minimax") { - const apiKey = resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["minimax"], - envDirect: [state.env.MINIMAX_CODE_PLAN_KEY, state.env.MINIMAX_API_KEY], - }); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (provider === "xiaomi") { - const apiKey = resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["xiaomi"], - envDirect: [state.env.XIAOMI_API_KEY], - }); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (!LEGACY_OAUTH_USAGE_PROVIDERS.has(provider)) { - continue; - } - - const auth = await resolveOAuthToken({ - state, - provider, - }); - if (!auth) { - continue; - } - if (provider === "google-gemini-cli") { - const parsed = parseGoogleToken(auth.token); - auths.push({ - ...auth, - token: parsed?.token ?? auth.token, - }); - continue; - } - auths.push(auth); } return auths; diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index e0bcd60c94b..87f216eef24 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,6 +1,5 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; -export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; export { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index cf78ac667da..55cff6cad72 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -22,7 +22,7 @@ describe("provider-usage.load plugin seam", () => { resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); }); - it("prefers plugin-owned usage snapshots before the legacy core switch", async () => { + it("prefers plugin-owned usage snapshots", async () => { resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({ provider: "github-copilot", displayName: "Copilot", diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 9b50285c64f..d34c55c22d3 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -2,14 +2,6 @@ import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; -import { - fetchClaudeUsage, - fetchCodexUsage, - fetchCopilotUsage, - fetchGeminiUsage, - fetchMinimaxUsage, - fetchZaiUsage, -} from "./provider-usage.fetch.js"; import { DEFAULT_TIMEOUT_MS, ignoredErrors, @@ -64,44 +56,12 @@ async function fetchProviderUsageSnapshot(params: { if (pluginSnapshot) { return pluginSnapshot; } - - switch (params.auth.provider) { - case "anthropic": - return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "github-copilot": - return await fetchCopilotUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "google-gemini-cli": - return await fetchGeminiUsage( - params.auth.token, - params.timeoutMs, - params.fetchFn, - params.auth.provider, - ); - case "openai-codex": - return await fetchCodexUsage( - params.auth.token, - params.auth.accountId, - params.timeoutMs, - params.fetchFn, - ); - case "minimax": - return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "xiaomi": - return { - provider: "xiaomi", - displayName: PROVIDER_LABELS.xiaomi, - windows: [], - }; - case "zai": - return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn); - default: - return { - provider: params.auth.provider, - displayName: PROVIDER_LABELS[params.auth.provider], - windows: [], - error: "Unsupported provider", - }; - } + return { + provider: params.auth.provider, + displayName: PROVIDER_LABELS[params.auth.provider], + windows: [], + error: "Unsupported provider", + }; } export async function loadProviderUsageSummary( diff --git a/src/library.ts b/src/library.ts new file mode 100644 index 00000000000..faaf7ea5998 --- /dev/null +++ b/src/library.ts @@ -0,0 +1,48 @@ +import { getReplyFromConfig } from "./auto-reply/reply.js"; +import { applyTemplate } from "./auto-reply/templating.js"; +import { monitorWebChannel } from "./channel-web.js"; +import { createDefaultDeps } from "./cli/deps.js"; +import { promptYesNo } from "./cli/prompt.js"; +import { waitForever } from "./cli/wait.js"; +import { loadConfig } from "./config/config.js"; +import { + deriveSessionKey, + loadSessionStore, + resolveSessionKey, + resolveStorePath, + saveSessionStore, +} from "./config/sessions.js"; +import { ensureBinary } from "./infra/binaries.js"; +import { + describePortOwner, + ensurePortAvailable, + handlePortError, + PortInUseError, +} from "./infra/ports.js"; +import { runCommandWithTimeout, runExec } from "./process/exec.js"; +import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; + +export { + assertWebChannel, + applyTemplate, + createDefaultDeps, + deriveSessionKey, + describePortOwner, + ensureBinary, + ensurePortAvailable, + getReplyFromConfig, + handlePortError, + loadConfig, + loadSessionStore, + monitorWebChannel, + normalizeE164, + PortInUseError, + promptYesNo, + resolveSessionKey, + resolveStorePath, + runCommandWithTimeout, + runExec, + saveSessionStore, + toWhatsappJid, + waitForever, +}; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 4527f24917d..3c8fc8c194c 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -35,7 +35,7 @@ export { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 4f403343b34..a792af23816 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,7 @@ export type { AnyAgentTool, OpenClawPluginApi, + OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, ProviderCatalogResult, @@ -25,6 +26,22 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, } from "../plugins/types.js"; +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxFsBridge, + SandboxFsStat, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, + SandboxContext, + SandboxResolvedPath, +} from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -36,6 +53,12 @@ export type { } from "../infra/provider-usage.types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { applyProviderDefaultModel, diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts new file mode 100644 index 00000000000..04b7902de9e --- /dev/null +++ b/src/plugin-sdk/entrypoints.ts @@ -0,0 +1,36 @@ +import pluginSdkEntryList from "../../scripts/lib/plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; + +export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); + +export function buildPluginSdkEntrySources() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), + ); +} + +export function buildPluginSdkSpecifiers() { + return pluginSdkEntrypoints.map((entry) => + entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, + ); +} + +export function buildPluginSdkPackageExports() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [ + entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, + { + types: `./dist/plugin-sdk/${entry}.d.ts`, + default: `./dist/plugin-sdk/${entry}.js`, + }, + ]), + ); +} + +export function listPluginSdkDistArtifacts() { + return pluginSdkEntrypoints.flatMap((entry) => [ + `dist/plugin-sdk/${entry}.js`, + `dist/plugin-sdk/${entry}.d.ts`, + ]); +} diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 65f0773105b..03c48b7e414 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -21,8 +21,8 @@ export { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export type { BaseProbeResult, @@ -62,10 +62,8 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - feishuSetupAdapter, - feishuSetupWizard, -} from "../../extensions/feishu/src/setup-surface.js"; +export { feishuSetupWizard } from "../../extensions/feishu/src/setup-surface.js"; +export { feishuSetupAdapter } from "../../extensions/feishu/src/setup-core.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 464af58776b..130b3d2fc14 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -27,10 +27,9 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, - splitOnboardingEntries, + splitSetupEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index dd99550b122..d634f80ce66 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -9,7 +9,7 @@ import { buildPluginSdkPackageExports, buildPluginSdkSpecifiers, pluginSdkEntrypoints, -} from "../../scripts/lib/plugin-sdk-entries.mjs"; +} from "./entrypoints.js"; import * as sdk from "./index.js"; const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index dd6a0a9cc3c..c34165f96ec 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -230,7 +230,7 @@ export { export { promptSingleChannelSecretInput, type SingleChannelSecretInputPromptResult, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 472c46ea2e5..2b2a86badda 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -15,10 +15,9 @@ export { } from "../channels/plugins/helpers.js"; export { addWildcardAllowFrom, - promptAccountId, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { patchScopedAccountConfig } from "../channels/plugins/setup-helpers.js"; export type { BaseProbeResult } from "../channels/plugins/types.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 8a62aa9ae10..58234ca86fe 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -38,7 +38,7 @@ export { mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 6cfeeacd918..4787d5e8ac3 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -32,7 +32,7 @@ export { buildSingleChannelSecretPromptState, promptSingleChannelSecretInput, runSingleChannelSecretStep, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 2f5a91d8989..96e296af04a 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -38,8 +38,8 @@ export { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export type { BaseProbeResult, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index f0d2e1de29d..960ac32af0b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -24,7 +24,7 @@ export { promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 381e5e71a8a..a2997c5702c 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/nostr. export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -18,3 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6e4b942b9a9..8a57148f430 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -4,12 +4,13 @@ import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; +import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; -import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; +import { pluginSdkSubpaths } from "./entrypoints.js"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); @@ -93,6 +94,11 @@ describe("plugin-sdk subpath exports", () => { expect(typeof msteamsSdk.msteamsSetupAdapter).toBe("object"); }); + it("exports Nostr helpers", () => { + expect(typeof nostrSdk.nostrSetupWizard).toBe("object"); + expect(typeof nostrSdk.nostrSetupAdapter).toBe("object"); + }); + it("exports Google Chat helpers", async () => { const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); @@ -105,6 +111,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); }); + it("exports Synology Chat helpers", async () => { + const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); + expect(typeof synologyChatSdk.synologyChatSetupWizard).toBe("object"); + expect(typeof synologyChatSdk.synologyChatSetupAdapter).toBe("object"); + }); + it("exports Zalouser helpers", async () => { const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index dcce2ea760b..f5fae73fbb2 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -3,6 +3,7 @@ export { setAccountEnabledInConfigSection } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { isRequestBodyLimitError, readRequestBodyWithLimit, @@ -10,8 +11,13 @@ export { } from "../infra/http-body.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; +export { + synologyChatSetupAdapter, + synologyChatSetupWizard, +} from "../../extensions/synology-chat/src/setup-surface.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index f1415103398..291834b9648 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -3,7 +3,6 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 4323ae4eb6e..775f2817ca1 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -15,11 +15,10 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, @@ -64,7 +63,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; -export { zaloSetupAdapter, zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; +export { zaloSetupAdapter } from "../../extensions/zalo/src/setup-core.js"; +export { zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 47fc787570c..9e4910b1c85 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -14,9 +14,8 @@ export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, @@ -55,10 +54,8 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { - zalouserSetupAdapter, - zalouserSetupWizard, -} from "../../extensions/zalouser/src/setup-surface.js"; +export { zalouserSetupAdapter } from "../../extensions/zalouser/src/setup-core.js"; +export { zalouserSetupWizard } from "../../extensions/zalouser/src/setup-surface.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts new file mode 100644 index 00000000000..81523392e7a --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; + +describe("bundled provider auth env vars", () => { + it("reads bundled provider auth env vars from plugin manifests", () => { + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([ + "QWEN_OAUTH_TOKEN", + "QWEN_PORTAL_API_KEY", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([ + "MINIMAX_OAUTH_TOKEN", + "MINIMAX_API_KEY", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["openai-codex"]).toBeUndefined(); + }); +}); diff --git a/src/plugins/bundled-provider-auth-env-vars.ts b/src/plugins/bundled-provider-auth-env-vars.ts new file mode 100644 index 00000000000..5c152de0566 --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.ts @@ -0,0 +1,91 @@ +import ANTHROPIC_MANIFEST from "../../extensions/anthropic/openclaw.plugin.json" with { type: "json" }; +import BYTEPLUS_MANIFEST from "../../extensions/byteplus/openclaw.plugin.json" with { type: "json" }; +import CLOUDFLARE_AI_GATEWAY_MANIFEST from "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json" with { type: "json" }; +import COPILOT_PROXY_MANIFEST from "../../extensions/copilot-proxy/openclaw.plugin.json" with { type: "json" }; +import GITHUB_COPILOT_MANIFEST from "../../extensions/github-copilot/openclaw.plugin.json" with { type: "json" }; +import GOOGLE_MANIFEST from "../../extensions/google/openclaw.plugin.json" with { type: "json" }; +import HUGGINGFACE_MANIFEST from "../../extensions/huggingface/openclaw.plugin.json" with { type: "json" }; +import KILOCODE_MANIFEST from "../../extensions/kilocode/openclaw.plugin.json" with { type: "json" }; +import KIMI_CODING_MANIFEST from "../../extensions/kimi-coding/openclaw.plugin.json" with { type: "json" }; +import MINIMAX_MANIFEST from "../../extensions/minimax/openclaw.plugin.json" with { type: "json" }; +import MISTRAL_MANIFEST from "../../extensions/mistral/openclaw.plugin.json" with { type: "json" }; +import MODELSTUDIO_MANIFEST from "../../extensions/modelstudio/openclaw.plugin.json" with { type: "json" }; +import MOONSHOT_MANIFEST from "../../extensions/moonshot/openclaw.plugin.json" with { type: "json" }; +import NVIDIA_MANIFEST from "../../extensions/nvidia/openclaw.plugin.json" with { type: "json" }; +import OLLAMA_MANIFEST from "../../extensions/ollama/openclaw.plugin.json" with { type: "json" }; +import OPENAI_MANIFEST from "../../extensions/openai/openclaw.plugin.json" with { type: "json" }; +import OPENCODE_GO_MANIFEST from "../../extensions/opencode-go/openclaw.plugin.json" with { type: "json" }; +import OPENCODE_MANIFEST from "../../extensions/opencode/openclaw.plugin.json" with { type: "json" }; +import OPENROUTER_MANIFEST from "../../extensions/openrouter/openclaw.plugin.json" with { type: "json" }; +import QIANFAN_MANIFEST from "../../extensions/qianfan/openclaw.plugin.json" with { type: "json" }; +import QWEN_PORTAL_AUTH_MANIFEST from "../../extensions/qwen-portal-auth/openclaw.plugin.json" with { type: "json" }; +import SGLANG_MANIFEST from "../../extensions/sglang/openclaw.plugin.json" with { type: "json" }; +import SYNTHETIC_MANIFEST from "../../extensions/synthetic/openclaw.plugin.json" with { type: "json" }; +import TOGETHER_MANIFEST from "../../extensions/together/openclaw.plugin.json" with { type: "json" }; +import VENICE_MANIFEST from "../../extensions/venice/openclaw.plugin.json" with { type: "json" }; +import VERCEL_AI_GATEWAY_MANIFEST from "../../extensions/vercel-ai-gateway/openclaw.plugin.json" with { type: "json" }; +import VLLM_MANIFEST from "../../extensions/vllm/openclaw.plugin.json" with { type: "json" }; +import VOLCENGINE_MANIFEST from "../../extensions/volcengine/openclaw.plugin.json" with { type: "json" }; +import XIAOMI_MANIFEST from "../../extensions/xiaomi/openclaw.plugin.json" with { type: "json" }; +import ZAI_MANIFEST from "../../extensions/zai/openclaw.plugin.json" with { type: "json" }; + +type ProviderAuthEnvVarManifest = { + id?: string; + providerAuthEnvVars?: Record; +}; + +function collectBundledProviderAuthEnvVars( + manifests: readonly ProviderAuthEnvVarManifest[], +): Record { + const entries: Record = {}; + for (const manifest of manifests) { + const providerAuthEnvVars = manifest.providerAuthEnvVars; + if (!providerAuthEnvVars) { + continue; + } + for (const [providerId, envVars] of Object.entries(providerAuthEnvVars)) { + const normalizedProviderId = providerId.trim(); + const normalizedEnvVars = envVars.map((value) => value.trim()).filter(Boolean); + if (!normalizedProviderId || normalizedEnvVars.length === 0) { + continue; + } + entries[normalizedProviderId] = normalizedEnvVars; + } + } + return entries; +} + +// Read bundled provider auth env metadata from manifests so env-based auth +// lookup stays cheap and does not need to boot plugin runtime code. +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = collectBundledProviderAuthEnvVars([ + ANTHROPIC_MANIFEST, + BYTEPLUS_MANIFEST, + CLOUDFLARE_AI_GATEWAY_MANIFEST, + COPILOT_PROXY_MANIFEST, + GITHUB_COPILOT_MANIFEST, + GOOGLE_MANIFEST, + HUGGINGFACE_MANIFEST, + KILOCODE_MANIFEST, + KIMI_CODING_MANIFEST, + MINIMAX_MANIFEST, + MISTRAL_MANIFEST, + MODELSTUDIO_MANIFEST, + MOONSHOT_MANIFEST, + NVIDIA_MANIFEST, + OLLAMA_MANIFEST, + OPENAI_MANIFEST, + OPENCODE_GO_MANIFEST, + OPENCODE_MANIFEST, + OPENROUTER_MANIFEST, + QIANFAN_MANIFEST, + QWEN_PORTAL_AUTH_MANIFEST, + SGLANG_MANIFEST, + SYNTHETIC_MANIFEST, + TOGETHER_MANIFEST, + VENICE_MANIFEST, + VERCEL_AI_GATEWAY_MANIFEST, + VLLM_MANIFEST, + VOLCENGINE_MANIFEST, + XIAOMI_MANIFEST, + ZAI_MANIFEST, +]); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index c4195a5e6e3..8becf375f96 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -213,4 +213,9 @@ describe("resolveEnableState", () => { reason: "workspace plugin (disabled by default)", }); }); + + it("keeps bundled provider plugins enabled when they are bundled-default providers", () => { + const state = resolveEnableState("google", "bundled", normalizePluginsConfig({})); + expect(state).toEqual({ enabled: true }); + }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 493ad885f51..6cd04424fe2 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -29,6 +29,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "cloudflare-ai-gateway", "device-pair", "github-copilot", + "google", "huggingface", "kilocode", "kimi-coding", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a58d0a640a2..90f9b210398 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -28,7 +28,7 @@ import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import { setActivePluginRegistry } from "./runtime.js"; -import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js"; +import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; import type { @@ -163,6 +163,25 @@ const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string return null; }; +function resolvePluginRuntimeModulePath(params: { modulePath?: string } = {}): string | null { + try { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const moduleDir = path.dirname(modulePath); + const candidates = [ + path.join(moduleDir, "runtime", "index.ts"), + path.join(moduleDir, "runtime", "index.js"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +} + const cachedPluginSdkExportedSubpaths = new Map(); function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { @@ -747,11 +766,58 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi clearPluginInteractiveHandlers(); } + // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). + let jitiLoader: ReturnType | null = null; + const getJiti = () => { + if (jitiLoader) { + return jitiLoader; + } + const pluginSdkAlias = resolvePluginSdkAlias(); + const extensionApiAlias = resolveExtensionApiAlias(); + const aliasMap = { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap(), + }; + jitiLoader = createJiti(import.meta.url, { + interopDefault: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + ...(Object.keys(aliasMap).length > 0 + ? { + alias: aliasMap, + } + : {}), + }); + return jitiLoader; + }; + + let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null = + null; + const resolveCreatePluginRuntime = (): (( + options?: CreatePluginRuntimeOptions, + ) => PluginRuntime) => { + if (createPluginRuntimeFactory) { + return createPluginRuntimeFactory; + } + const runtimeModulePath = resolvePluginRuntimeModulePath(); + if (!runtimeModulePath) { + throw new Error("Unable to resolve plugin runtime module"); + } + const runtimeModule = getJiti()(runtimeModulePath) as { + createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime; + }; + if (typeof runtimeModule.createPluginRuntime !== "function") { + throw new Error("Plugin runtime module missing createPluginRuntime export"); + } + createPluginRuntimeFactory = runtimeModule.createPluginRuntime; + return createPluginRuntimeFactory; + }; + // Lazily initialize the runtime so startup paths that discover/skip plugins do - // not eagerly load every channel runtime dependency. + // not eagerly load every channel/runtime dependency tree. let resolvedRuntime: PluginRuntime | null = null; const resolveRuntime = (): PluginRuntime => { - resolvedRuntime ??= createPluginRuntime(options.runtimeOptions); + resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions); return resolvedRuntime; }; const runtime = new Proxy({} as PluginRuntime, { @@ -780,6 +846,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return Reflect.getPrototypeOf(resolveRuntime() as object); }, }); + const { registry, createApi } = createPluginRegistry({ logger, runtime, @@ -823,31 +890,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi env, }); - // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - let jitiLoader: ReturnType | null = null; - const getJiti = () => { - if (jitiLoader) { - return jitiLoader; - } - const pluginSdkAlias = resolvePluginSdkAlias(); - const extensionApiAlias = resolveExtensionApiAlias(); - const aliasMap = { - ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap(), - }; - jitiLoader = createJiti(import.meta.url, { - interopDefault: true, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(Object.keys(aliasMap).length > 0 - ? { - alias: aliasMap, - } - : {}), - }); - return jitiLoader; - }; - const manifestByRoot = new Map( manifestRegistry.plugins.map((record) => [record.rootDir, record]), ); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 84e5f13fd98..5156ea8a4a3 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -199,6 +199,28 @@ describe("loadPluginManifestRegistry", () => { ).toBe(true); }); + it("preserves provider auth env metadata from plugin manifests", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "openai", + providers: ["openai", "openai-codex"], + providerAuthEnvVars: { + openai: ["OPENAI_API_KEY"], + }, + configSchema: { type: "object" }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "openai", + rootDir: dir, + origin: "bundled", + }); + + expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ + openai: ["OPENAI_API_KEY"], + }); + }); + it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => { const bundledDir = makeTempDir(); const globalDir = makeTempDir(); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 4f43cff8e2b..3a96d3036d5 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -41,6 +41,7 @@ export type PluginManifestRecord = { kind?: PluginKind; channels: string[]; providers: string[]; + providerAuthEnvVars?: Record; skills: string[]; settingsFiles?: string[]; hooks: string[]; @@ -152,6 +153,7 @@ function buildRecord(params: { kind: params.manifest.kind, channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], + providerAuthEnvVars: params.manifest.providerAuthEnvVars, skills: params.manifest.skills ?? [], settingsFiles: [], hooks: [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 0cbdd9264f3..103ee620bf0 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -14,6 +14,7 @@ export type PluginManifest = { kind?: PluginKind; channels?: string[]; providers?: string[]; + providerAuthEnvVars?: Record; skills?: string[]; name?: string; description?: string; @@ -32,6 +33,25 @@ function normalizeStringList(value: unknown): string[] { return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } +function normalizeStringListRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = {}; + for (const [key, rawValues] of Object.entries(value)) { + const providerId = typeof key === "string" ? key.trim() : ""; + if (!providerId) { + continue; + } + const values = normalizeStringList(rawValues); + if (values.length === 0) { + continue; + } + normalized[providerId] = values; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); @@ -93,6 +113,7 @@ export function loadPluginManifest( const version = typeof raw.version === "string" ? raw.version.trim() : undefined; const channels = normalizeStringList(raw.channels); const providers = normalizeStringList(raw.providers); + const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const skills = normalizeStringList(raw.skills); let uiHints: Record | undefined; @@ -108,6 +129,7 @@ export function loadPluginManifest( kind, channels, providers, + providerAuthEnvVars, skills, name, description, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 24bd47a915f..e38d6553080 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]); +const resolveOwningPluginIdsForProviderMock = vi.fn( + (_: unknown) => undefined as string[] | undefined, +); vi.mock("./providers.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), })); import { @@ -41,6 +46,8 @@ describe("provider-runtime", () => { beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); }); it("matches providers by alias for runtime hook lookup", () => { @@ -56,9 +63,13 @@ describe("provider-runtime", () => { const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" }); expect(plugin?.id).toBe("openrouter"); - expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "Open Router", + }), + ); + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e7ee62d8ebf..9e5104f7f86 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,6 +1,6 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolvePluginProviders } from "./providers.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; import type { ProviderAugmentModelCatalogContext, ProviderBuildMissingAuthMessageContext, @@ -60,9 +60,15 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolveProviderPluginsForHooks(params).find((plugin) => - matchesProviderId(plugin, params.provider), - ); + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds: resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }), + }).find((plugin) => matchesProviderId(plugin, params.provider)); } export function runProviderDynamicModel(params: { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 86ffb8e5ffc..a601336e5b9 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -1,18 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolvePluginProviders } from "./providers.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; const loadOpenClawPluginsMock = vi.fn(); +const loadPluginManifestRegistryMock = vi.fn(); vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), })); +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistryMock(...args), +})); + describe("resolvePluginProviders", () => { beforeEach(() => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ providers: [{ pluginId: "google", provider: { id: "demo-provider" } }], }); + loadPluginManifestRegistryMock.mockReset(); + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [], + diagnostics: [], + }); }); it("forwards an explicit env to plugin loading", () => { @@ -86,4 +96,18 @@ describe("resolvePluginProviders", () => { expect(allow).toContain("google"); expect(allow).not.toContain("google-gemini-cli-auth"); }); + + it("maps provider ids to owning plugin ids via manifests", () => { + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [ + { id: "minimax", providers: ["minimax", "minimax-portal"] }, + { id: "openai", providers: ["openai", "openai-codex"] }, + ], + diagnostics: [], + }); + + expect(resolveOwningPluginIdsForProvider({ provider: "minimax-portal" })).toEqual(["minimax"]); + expect(resolveOwningPluginIdsForProvider({ provider: "openai-codex" })).toEqual(["openai"]); + expect(resolveOwningPluginIdsForProvider({ provider: "gemini-cli" })).toBeUndefined(); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index c1de0680359..e3215f2c6da 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,7 +1,9 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); @@ -86,6 +88,32 @@ function withBundledProviderVitestCompat(params: { }, }; } + +export function resolveOwningPluginIdsForProvider(params: { + provider: string; + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] | undefined { + const normalizedProvider = normalizeProviderId(params.provider); + if (!normalizedProvider) { + return undefined; + } + + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const pluginIds = registry.plugins + .filter((plugin) => + plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider), + ) + .map((plugin) => plugin.id); + + return pluginIds.length > 0 ? pluginIds : undefined; +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6e2a746db90..0971f61ea79 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -337,8 +337,6 @@ export type ProviderResolvedUsageAuth = { * This hook runs after `resolveUsageAuth` succeeds. Core still owns summary * fan-out, timeout wrapping, filtering, and formatting; the provider plugin * owns the provider-specific HTTP request + response normalization. - * - * Return `null`/`undefined` to fall back to legacy core fetchers. */ export type ProviderFetchUsageSnapshotContext = { config: OpenClawConfig; @@ -499,6 +497,12 @@ export type ProviderPlugin = { label: string; docsPath?: string; aliases?: string[]; + /** + * Provider-related env vars shown in onboarding/search/help surfaces. + * + * Keep entries in preferred display order. This can include direct auth env + * vars or setup inputs such as OAuth client id/secret vars. + */ envVars?: string[]; auth: ProviderAuthMethod[]; /** @@ -584,10 +588,9 @@ export type ProviderPlugin = { /** * Usage/billing auth resolution hook. * - * Called by provider-usage surfaces (`/usage`, status snapshots, reporting) - * before OpenClaw falls back to legacy core auth resolution. Use this when a - * provider's usage endpoint needs provider-owned token extraction, blob - * parsing, or alias handling. + * Called by provider-usage surfaces (`/usage`, status snapshots, reporting). + * Use this when a provider's usage endpoint needs provider-owned token + * extraction, blob parsing, or alias handling. */ resolveUsageAuth?: ( ctx: ProviderResolveUsageAuthContext, diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 2e7b79c64d2..26c9f847bf9 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -96,6 +96,7 @@ describe("resolvePluginWebSearchProviders", () => { entries: expect.objectContaining({ openrouter: { enabled: true }, brave: { enabled: true }, + firecrawl: { enabled: true }, google: { enabled: true }, moonshot: { enabled: true }, perplexity: { enabled: true }, diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index f59cf95f51a..c44bb6f2a93 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -11,6 +11,7 @@ const log = createSubsystemLogger("plugins"); const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "brave", + "firecrawl", "google", "moonshot", "perplexity", diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts index 6e5b78f6643..6405d322e2f 100644 --- a/src/secrets/provider-env-vars.test.ts +++ b/src/secrets/provider-env-vars.test.ts @@ -10,10 +10,12 @@ describe("provider env vars", () => { expect(listKnownProviderAuthEnvVarNames()).toEqual( expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), ); - expect(listKnownSecretEnvVarNames()).not.toEqual(listKnownProviderAuthEnvVarNames()); - expect(listKnownSecretEnvVarNames()).not.toEqual( + expect(listKnownSecretEnvVarNames()).toEqual( expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), ); + expect(listKnownProviderAuthEnvVarNames()).toEqual( + expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]), + ); expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY"); }); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 88900893376..af89b57bf8d 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -1,50 +1,42 @@ -export const PROVIDER_ENV_VARS: Record = { - openai: ["OPENAI_API_KEY"], - anthropic: ["ANTHROPIC_API_KEY"], +import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../plugins/bundled-provider-auth-env-vars.js"; + +const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { + chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], google: ["GEMINI_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - "minimax-cn": ["MINIMAX_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - synthetic: ["SYNTHETIC_API_KEY"], - venice: ["VENICE_API_KEY"], - zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], - xiaomi: ["XIAOMI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + voyage: ["VOYAGE_API_KEY"], + groq: ["GROQ_API_KEY"], + deepgram: ["DEEPGRAM_API_KEY"], + cerebras: ["CEREBRAS_API_KEY"], litellm: ["LITELLM_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - together: ["TOGETHER_API_KEY"], - huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - qianfan: ["QIANFAN_API_KEY"], - xai: ["XAI_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - kilocode: ["KILOCODE_API_KEY"], - modelstudio: ["MODELSTUDIO_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], +} as const; + +/** + * Provider auth env candidates used by generic auth resolution. + * + * Order matters: the first non-empty value wins for helpers such as + * `resolveEnvApiKey()`. Bundled providers source this from plugin manifest + * metadata so auth probes do not need to load plugin runtime. + */ +export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record = { + ...BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES, + ...CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES, }; -const EXTRA_PROVIDER_AUTH_ENV_VARS = [ - "VOYAGE_API_KEY", - "GROQ_API_KEY", - "DEEPGRAM_API_KEY", - "CEREBRAS_API_KEY", - "NVIDIA_API_KEY", - "COPILOT_GITHUB_TOKEN", - "GH_TOKEN", - "GITHUB_TOKEN", - "ANTHROPIC_OAUTH_TOKEN", - "CHUTES_OAUTH_TOKEN", - "CHUTES_API_KEY", - "QWEN_OAUTH_TOKEN", - "QWEN_PORTAL_API_KEY", - "MINIMAX_OAUTH_TOKEN", - "OLLAMA_API_KEY", - "VLLM_API_KEY", -] as const; +/** + * Provider env vars used for onboarding/default secret refs and broad secret + * scrubbing. This can include non-model providers and may intentionally choose + * a different preferred first env var than auth resolution. + */ +export const PROVIDER_ENV_VARS: Record = { + ...PROVIDER_AUTH_ENV_VAR_CANDIDATES, + anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], + chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + google: ["GEMINI_API_KEY"], + "minimax-cn": ["MINIMAX_API_KEY"], + xai: ["XAI_API_KEY"], +}; + +const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY"] as const; const KNOWN_SECRET_ENV_VARS = [ ...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)), @@ -53,7 +45,11 @@ const KNOWN_SECRET_ENV_VARS = [ // OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must // remain available to child bridge/runtime processes. const KNOWN_PROVIDER_AUTH_ENV_VARS = [ - ...new Set([...KNOWN_SECRET_ENV_VARS, ...EXTRA_PROVIDER_AUTH_ENV_VARS]), + ...new Set([ + ...Object.values(PROVIDER_AUTH_ENV_VAR_CANDIDATES).flatMap((keys) => keys), + ...KNOWN_SECRET_ENV_VARS, + ...EXTRA_PROVIDER_AUTH_ENV_VARS, + ]), ]; export function listKnownProviderAuthEnvVarNames(): string[] { diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index ca0e69722e3..ce1484f6513 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -14,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; @@ -143,17 +144,17 @@ export async function collectChannelSecurityFindings(params: { const findings: SecurityAuditFinding[] = []; const sourceConfig = params.sourceConfig ?? params.cfg; - const inspectChannelAccount = ( + const inspectChannelAccount = async ( plugin: (typeof params.plugins)[number], cfg: OpenClawConfig, accountId: string, ) => plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }); + })); const asAccountRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) @@ -164,8 +165,9 @@ export async function collectChannelSecurityFindings(params: { plugin: (typeof params.plugins)[number], accountId: string, ) => { - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); + const diagnostics: string[] = []; + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, params.cfg, accountId); const sourceInspection = sourceInspectedAccount as { enabled?: boolean; configured?: boolean; @@ -174,8 +176,27 @@ export async function collectChannelSecurityFindings(params: { enabled?: boolean; configured?: boolean; } | null; - const resolvedAccount = - resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId); + let resolvedAccount = resolvedInspectedAccount; + if (!resolvedAccount) { + try { + resolvedAccount = plugin.config.resolveAccount(params.cfg, accountId); + } catch (error) { + diagnostics.push( + `${plugin.id}:${accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + } + } + if (!resolvedAccount && sourceInspectedAccount) { + resolvedAccount = sourceInspectedAccount; + } + if (!resolvedAccount) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } const useSourceUnavailableAccount = Boolean( sourceInspectedAccount && hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) && @@ -185,23 +206,49 @@ export async function collectChannelSecurityFindings(params: { const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount; const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection; const accountRecord = asAccountRecord(account); - const enabled = + let enabled = typeof selectedInspection?.enabled === "boolean" ? selectedInspection.enabled : typeof accountRecord?.enabled === "boolean" ? accountRecord.enabled - : plugin.config.isEnabled - ? plugin.config.isEnabled(account, params.cfg) - : true; - const configured = + : true; + if ( + typeof selectedInspection?.enabled !== "boolean" && + typeof accountRecord?.enabled !== "boolean" && + plugin.config.isEnabled + ) { + try { + enabled = plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + let configured = typeof selectedInspection?.configured === "boolean" ? selectedInspection.configured : typeof accountRecord?.configured === "boolean" ? accountRecord.configured - : plugin.config.isConfigured - ? await plugin.config.isConfigured(account, params.cfg) - : true; - return { account, enabled, configured }; + : true; + if ( + typeof selectedInspection?.configured !== "boolean" && + typeof accountRecord?.configured !== "boolean" && + plugin.config.isConfigured + ) { + try { + configured = await plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; }; const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => { @@ -298,7 +345,20 @@ export async function collectChannelSecurityFindings(params: { plugin.id, accountId, ); - const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId); + const { account, enabled, configured, diagnostics } = await resolveChannelAuditAccount( + plugin, + accountId, + ); + for (const diagnostic of diagnostics) { + findings.push({ + checkId: `channels.${plugin.id}.account.read_only_resolution`, + severity: "warn", + title: `${plugin.meta.label ?? plugin.id} account could not be fully resolved`, + detail: diagnostic, + remediation: + "Ensure referenced secrets are available in this shell or run with a running gateway snapshot so security audit can inspect the full channel configuration.", + }); + } if (!enabled) { continue; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 84fcadf1f98..dedc789773c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -346,6 +346,43 @@ description: test skill expectNoFinding(res, "gateway.bind_no_auth"); }); + it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => { + const sourceConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + secrets: sourceConfig.secrets, + }; + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expectNoFinding(res, "gateway.bind_no_auth"); + }); + it("evaluates gateway auth rate-limit warning based on configuration", async () => { const cases: Array<{ name: string; @@ -1803,7 +1840,10 @@ description: test skill }); it("warns when multiple DM senders share the main session", async () => { - const cfg: OpenClawConfig = { session: { dmScope: "main" } }; + const cfg: OpenClawConfig = { + session: { dmScope: "main" }, + channels: { whatsapp: { enabled: true } }, + }; const plugins: ChannelPlugin[] = [ { id: "whatsapp", @@ -1977,6 +2017,40 @@ description: test skill }); }); + it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { + const plugin = stubChannelPlugin({ + id: "zalouser", + label: "Zalo Personal", + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing SecretRef"); + }, + }); + + const cfg: OpenClawConfig = { + channels: { + zalouser: { + enabled: true, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [plugin], + }); + + const finding = res.findings.find( + (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.title).toContain("could not be fully resolved"); + expect(finding?.detail).toContain("zalouser:default: failed to resolve account"); + expect(finding?.detail).toContain("missing SecretRef"); + }); + it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { await withChannelSecurityStateDir(async () => { const sourceConfig: OpenClawConfig = { diff --git a/src/security/audit.ts b/src/security/audit.ts index dbbfb9651be..d3c1337e042 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -113,6 +113,8 @@ export type SecurityAuditOptions = { configSnapshot?: ConfigFileSnapshot | null; /** Optional cache for code-safety summaries across repeated deep audits. */ codeSafetySummaryCache?: Map>; + /** Optional explicit auth for deep gateway probe. */ + deepProbeAuth?: { token?: string; password?: string }; }; type AuditExecutionContext = { @@ -132,6 +134,7 @@ type AuditExecutionContext = { plugins?: ReturnType; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; + deepProbeAuth?: { token?: string; password?: string }; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { @@ -341,6 +344,7 @@ async function collectFilesystemFindings(params: { function collectGatewayConfigFindings( cfg: OpenClawConfig, + sourceConfig: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; @@ -365,18 +369,18 @@ function collectGatewayConfigFindings( hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) || hasNonEmptyString(env.CLAWDBOT_GATEWAY_PASSWORD); const tokenConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.token, + sourceConfig.secrets?.defaults, ); const passwordConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.password, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.password, + sourceConfig.secrets?.defaults, ); const remoteTokenConfigured = hasConfiguredSecretInput( - cfg.gateway?.remote?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.remote?.token, + sourceConfig.secrets?.defaults, ); - const explicitAuthMode = cfg.gateway?.auth?.mode; + const explicitAuthMode = sourceConfig.gateway?.auth?.mode; const tokenCanWin = hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured; const passwordCanWin = @@ -1062,6 +1066,7 @@ async function maybeProbeGateway(params: { env: NodeJS.ProcessEnv; timeoutMs: number; probe: typeof probeGateway; + explicitAuth?: { token?: string; password?: string }; }): Promise<{ deep: SecurityAuditReport["deep"]; authWarning?: string; @@ -1075,8 +1080,18 @@ async function maybeProbeGateway(params: { const authResolution = !isRemoteMode || remoteUrlMissing - ? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" }) - : resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" }); + ? resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "local", + explicitAuth: params.explicitAuth, + }) + : resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "remote", + explicitAuth: params.explicitAuth, + }); const res = await params .probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs }) .catch((err) => ({ @@ -1144,6 +1159,7 @@ async function createAuditExecutionContext( plugins: opts.plugins, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(), + deepProbeAuth: opts.deepProbeAuth, }; } @@ -1155,7 +1171,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise