diff --git a/Directory.Build.props b/Directory.Build.props index 50bfdee..d9d5b8c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -23,6 +23,14 @@ snupkg + + standard + $(OpenClawFeatureVariant)-maf + $(OpenClawFeatureVariant)-opensandbox + obj/$(OpenClawFeatureVariant)/ + $(DefaultItemExcludes);obj/** + + diff --git a/README.md b/README.md index 435f5c7..b8926dc 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,12 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) ![NativeAOT-friendly](https://img.shields.io/badge/NativeAOT-friendly-blue) ![Plugin compatibility](https://img.shields.io/badge/plugin%20compatibility-practical-green) +![Tools](https://img.shields.io/badge/native%20tools-48-green) +![Channels](https://img.shields.io/badge/channels-9-green) > **Disclaimer**: This project is not affiliated with, endorsed by, or associated with [OpenClaw](https://github.com/openclaw/openclaw). It is an independent .NET implementation inspired by their work. -Self-hosted **AI agent runtime and gateway for .NET** with a NativeAOT-friendly `aot` lane, an expanded `jit` compatibility lane, explicit tool execution, practical OpenClaw ecosystem compatibility, observability, and security hardening controls. +Self-hosted **AI agent runtime and gateway for .NET** with 48 native tools, 9 channel adapters, multi-agent routing, built-in tool presets, NativeAOT support, and practical OpenClaw ecosystem compatibility. ## Why This Project Exists @@ -19,23 +21,109 @@ Most agent stacks assume Python- or Node-first runtimes. That works until you wa OpenClaw.NET takes a different path: - **NativeAOT-friendly runtime and gateway** for .NET agent workloads -- **Practical reuse of existing OpenClaw TS/JS plugins and `SKILL.md` packages** +- **Practical reuse of existing OpenClaw TS/JS plugins and `SKILL.md` packages** — install directly with `openclaw plugins install` - A real **tool execution layer** with approval hooks, timeout handling, usage tracking, and optional sandbox routing -- Explicit **compatibility boundaries by runtime mode** instead of vague parity claims -- A foundation for experimenting with **production-oriented agent infrastructure in .NET** - -The goal is to explore what a dependable runtime and gateway layer for AI agents can look like in the .NET ecosystem. +- **48 native tools** covering file ops, sessions, memory, web search, messaging, home automation, databases, email, calendar, and more +- **9 channel adapters** (Telegram, SMS, WhatsApp, Teams, Slack, Discord, Signal, email, webhooks) with DM policy, allowlists, and signature validation +- A foundation for **production-oriented agent infrastructure in .NET** If this repo is useful to you, please star it. -## What It Does Today +## Key Features + +### Agent Runtime +- Multi-step execution with tool calling, retries, per-call timeouts, streaming, and circuit-breaker behavior +- Context compaction (`/compact` command or automatic) with LLM-powered summarization +- Configurable reasoning effort (`/think off|low|medium|high`) +- Delegated sub-agents with configurable profiles, tool restrictions, and depth limits +- Multi-agent routing — route channels/senders with per-route model, prompt, tool preset, and tool allowlist overrides + +### 48 Native Tools + +| Category | Tools | +|----------|-------| +| **File & Code** | `shell`, `read_file`, `write_file`, `edit_file`, `apply_patch`, `process`, `git`, `code_exec`, `browser` | +| **Sessions** | `sessions`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `session_status`, `session_search`, `agents_list` | +| **Memory** | `memory`, `memory_search`, `memory_get`, `project_memory` | +| **Web & Data** | `web_search`, `web_fetch`, `x_search`, `pdf_read`, `database` | +| **Communication** | `message`, `email`, `inbox_zero`, `calendar` | +| **Home & IoT** | `home_assistant`, `home_assistant_write`, `mqtt`, `mqtt_publish` | +| **Productivity** | `notion`, `notion_write`, `todo`, `automation`, `cron`, `delegate_agent` | +| **Media & AI** | `image_gen`, `vision_analyze`, `text_to_speech` | +| **System** | `gateway`, `profile_read`, `profile_write` | + +### Tool Presets & Groups + +Named presets control which tools are available per surface: + +| Preset | Description | +|--------|-------------| +| `full` | All tools, no restrictions | +| `coding` | File I/O, shell, git, code execution, browser, memory, sessions | +| `messaging` | Message, sessions, memory, profiles, todo | +| `minimal` | `session_status` only | +| `web` / `telegram` / `automation` / `readonly` | Channel-specific defaults | + +Presets compose from reusable **tool groups**: `group:runtime`, `group:fs`, `group:sessions`, `group:memory`, `group:web`, `group:automation`, `group:messaging`. + +### 9 Channel Adapters + +| Channel | Transport | Features | +|---------|-----------|----------| +| **Telegram** | Webhook | Media markers, photo upload, signature validation | +| **Twilio SMS** | Webhook | Rate limiting, opt-out handling | +| **WhatsApp** | Webhook / Bridge | Official Cloud API + Baileys bridge, typing indicators, read receipts | +| **Teams** | Bot Framework | JWT validation, conversation references, group/DM policy | +| **Slack** | Events API | Thread-to-session mapping, slash commands, HMAC-SHA256, mrkdwn conversion | +| **Discord** | Gateway WebSocket | Persistent connection, slash commands, Ed25519 interaction webhook, rate limiting | +| **Signal** | signald / signal-cli | Unix socket or subprocess bridge, privacy mode (no-content logging) | +| **Email** | IMAP/SMTP | MailKit-based | +| **Webhooks** | HTTP POST | Generic webhook triggers with HMAC validation | -- A multi-step **agent runtime** with tool calling, retries, per-call timeouts, streaming, circuit-breaker behavior, context compaction, and optional parallel tool execution -- A dedicated **tool execution layer** with approval flows, hooks, usage tracking, deterministic failure handling, and sandbox routing -- **Skills**, memory-backed sessions, memory recall injection, project memory, and delegated sub-agents -- A **gateway layer** for browser UI, WebSocket, OpenAI-compatible endpoints, a typed integration API, MCP, webhooks, auth, rate limits, and observability -- Practical **OpenClaw ecosystem** compatibility: JS/TS bridge plugins, standalone `SKILL.md` packages, and native dynamic plugins in `jit` -- Two runtime lanes (**`aot`** and **`jit`**) plus an optional **MAF orchestrator** in MAF-enabled artifacts +All channels support DM policy (`open` / `pairing` / `closed`), sender allowlists, and deduplication. + +### Skills + +7 bundled skills: `daily-news-digest`, `data-analyst`, `deep-researcher`, `email-triage`, `homeassistant-operator`, `mqtt-operator`, `software-developer`. + +Skills are loaded from workspace (`skills/`), global (`~/.openclaw/skills/`), or plugins. Install from ClawHub with `openclaw clawhub install `. + +### Chat Commands + +| Command | Description | +|---------|-------------| +| `/status` | Session info (model, turns, tokens) | +| `/new` / `/reset` | Clear conversation history | +| `/model ` | Override LLM model for session | +| `/think off\|low\|medium\|high` | Set reasoning effort level | +| `/compact` | Trigger history compaction | +| `/verbose on\|off` | Show tool calls and token usage per turn | +| `/usage` | Show total token counts | +| `/help` | List commands | + +### Plugin System + +Install community plugins directly from npm/ClawHub: + +```bash +openclaw plugins install @sliverp/qqbot +openclaw plugins install @opik/opik-openclaw +openclaw plugins list +openclaw plugins search openclaw dingtalk +``` + +Supports JS/TS bridge plugins, native dynamic .NET plugins (`jit` mode), and standalone `SKILL.md` packages. See [Plugin Compatibility Guide](docs/COMPATIBILITY.md). + +### Integrations + +| Integration | Description | +|-------------|-------------| +| **Tailscale Serve/Funnel** | Zero-config remote access via Tailscale | +| **Gmail Pub/Sub** | Email event triggers via Google Pub/Sub push notifications | +| **mDNS/Bonjour** | Local network service discovery | +| **Semantic Kernel** | Host SK tools/agents behind the gateway | +| **MAF Orchestrator** | Microsoft Agent Framework backend (optional) | +| **MCP** | Model Context Protocol facade for tools, resources, prompts | ## Architecture @@ -43,85 +131,72 @@ If this repo is useful to you, please star it. flowchart TB subgraph Clients -A1[Web UI / CLI / Companion] -A2[WebSocket / HTTP / SDK Clients] -A3[Telegram / Twilio / WhatsApp / Teams / Email / Webhooks] +A1[Web UI / CLI / TUI / Companion] +A2[WebSocket / HTTP / SDK] +A3["Channels: Telegram, SMS, WhatsApp,
Teams, Slack, Discord, Signal, Email"] end -subgraph OpenClawNET -B1[Gateway] -B2[Agent Runtime] -B3[Tool Execution Layer] -B4[Policy / Approval / Hardening] -B5[Observability] +subgraph "OpenClaw.NET Gateway" +B1[HTTP / WebSocket Endpoints] +B2[Message Pipeline] +B3[Session Manager] +B4[Agent Runtime] +B5[Tool Execution + Presets] +B6[Policy / Approval / Routing] +B7[Observability + Metrics] end -subgraph ExecutionBackends -C1[Native C# Tools] +subgraph "Tool Backends" +C1["48 Native Tools"] C2[TS/JS Plugin Bridge] -C3[Native Dynamic Plugins JIT] -C4[Optional Sandbox Backend] +C3["Native Dynamic Plugins (JIT)"] +C4[Sandbox / Docker / SSH] end subgraph Infrastructure D1[LLM Providers] -D2[Memory / Sessions] -D3[External APIs / Local Resources] +D2[Memory Store + Sessions] +D3[Cron + Automations] +D4[External APIs] end Clients --> B1 B1 --> B2 B2 --> B3 -B3 --> C1 -B3 --> C2 -B3 --> C3 -B3 --> C4 -B2 --> D1 -B2 --> D2 -C1 --> D3 -C2 --> D3 -C3 --> D3 -C4 --> D3 -B1 --> B4 -B1 --> B5 -B2 --> B5 -B3 --> B5 +B3 --> B4 +B4 --> B5 +B5 --> C1 & C2 & C3 & C4 +B4 --> D1 +B3 --> D2 +B4 --> D3 +C1 & C2 & C3 --> D4 +B1 --> B6 +B1 --> B7 +B4 --> B7 ``` -The gateway sits between clients, models, tools, and infrastructure, handling agent execution, tool routing, security/hardening, compatibility diagnostics, and observability. - -### Startup flow - -1. **`Bootstrap/`** — Loads config, resolves runtime mode and orchestrator, applies validation and hardening, handles early exits (`--doctor`). -2. **`Composition/` and `Profiles/`** — Registers services and applies the effective runtime lane (`aot` or `jit`). -3. **`Pipeline/` and `Endpoints/`** — Wires middleware, channel startup, workers, shutdown handling, and HTTP/WebSocket surfaces. - ### Runtime flow ```mermaid -graph TD - Client[Web UI / CLI / Companion / WebSocket Client] <--> Gateway[Gateway] - Webhooks[Telegram / Twilio / WhatsApp / Generic Webhooks] --> Gateway - - subgraph Runtime - Gateway <--> Agent[Agent Runtime] - Agent <--> Tools[Native Tools] - Agent <--> Memory[(Memory + Sessions)] - Agent <-->|Provider API| LLM{LLM Provider} - Agent <-->|Bridge transport| Bridge[Node.js Plugin Bridge] - Bridge <--> JSPlugins[TS / JS Plugins] - end +graph LR + Inbound["Inbound Message"] --> Route["Route Resolution"] + Route --> Session["Session Get/Create"] + Session --> Middleware["Middleware Pipeline"] + Middleware --> Agent["Agent Runtime"] + Agent --> LLM["LLM Call"] + LLM --> Tools["Tool Execution"] + Tools --> Agent + Agent --> Outbound["Outbound Message"] + Outbound --> Channel["Channel Adapter"] ``` ### Runtime modes | Mode | Description | |------|-------------| -| `aot` | Trim-safe, low-memory lane | -| `jit` | Expanded bridge surfaces + native dynamic plugins | -| `auto` | Selects `jit` when dynamic code is available, `aot` otherwise | - -For the full startup-module breakdown, see [docs/architecture-startup-refactor.md](docs/architecture-startup-refactor.md). +| `aot` | Trim-safe, low-memory lane. Native tools and bridge plugins only. | +| `jit` | Full plugin surfaces: channels, commands, providers, dynamic .NET plugins. | +| `auto` | Selects `jit` when dynamic code is available, `aot` otherwise. | ## Quickstart @@ -142,81 +217,45 @@ Then open one of: | Surface | URL | |---------|-----| -| Web UI | `http://127.0.0.1:18789/chat` | +| Web UI / Live Chat | `http://127.0.0.1:18789/chat` | | WebSocket | `ws://127.0.0.1:18789/ws` | +| Live WebSocket | `ws://127.0.0.1:18789/ws/live` | | Integration API | `http://127.0.0.1:18789/api/integration/status` | | MCP endpoint | `http://127.0.0.1:18789/mcp` | | OpenAI-compatible | `http://127.0.0.1:18789/v1/responses` | -**Optional environment variables:** - -| Variable | Default | Purpose | -|----------|---------|---------| -| `OPENCLAW_WORKSPACE` | — | Workspace directory for file tools | -| `OpenClaw__Runtime__Mode` | `auto` | Runtime lane (`aot`, `jit`, or `auto`) | -| `OPENCLAW_BASE_URL` | `http://127.0.0.1:18789` | CLI base URL | -| `OPENCLAW_AUTH_TOKEN` | — | Auth token (required for non-loopback) | - **Other entry points:** ```bash # CLI chat dotnet run --project src/OpenClaw.Cli -c Release -- chat +# CLI live session +dotnet run --project src/OpenClaw.Cli -c Release -- live --provider gemini + # One-shot CLI run dotnet run --project src/OpenClaw.Cli -c Release -- run "summarize this README" --file ./README.md +# Install a plugin +dotnet run --project src/OpenClaw.Cli -c Release -- plugins install @sliverp/qqbot + # Desktop companion (Avalonia) dotnet run --project src/OpenClaw.Companion -c Release -# Admin commands -dotnet run --project src/OpenClaw.Cli -c Release -- admin posture -dotnet run --project src/OpenClaw.Cli -c Release -- admin approvals simulate --tool shell --sender user1 --approval-tool shell -dotnet run --project src/OpenClaw.Cli -c Release -- admin incident export +# Terminal UI +dotnet run --project src/OpenClaw.Cli -c Release -- tui ``` -See the full [Quickstart Guide](docs/QUICKSTART.md) for runtime mode selection and deployment notes. - -## Ecosystem Compatibility +**Key environment variables:** -OpenClaw.NET targets **practical compatibility**, especially around the mainstream tool and skill path. Compatibility is mode-specific and intentionally explicit. - -| Surface | `aot` | `jit` | Notes | -| --- | --- | --- | --- | -| Standalone `SKILL.md` packages | yes | yes | Native skill loading; no JS bridge required. | -| `api.registerTool()` / `api.registerService()` | yes | yes | Core bridge path supported in both lanes. | -| `api.registerChannel()` / `registerCommand()` / `registerProvider()` / `api.on(...)` | — | yes | Dynamic plugin surfaces are JIT-only. | -| Standalone `.js` / `.mjs` / `.ts` plugins | yes | yes | `.ts` plugins require `jiti`. | -| Native dynamic .NET plugins | — | yes | Enabled through `OpenClaw:Plugins:DynamicNative`. | -| Unsupported bridge surfaces | fail fast | fail fast | Explicit diagnostics instead of partial load. | - -Pinned public smoke coverage includes `peekaboo`, `@agentseo/openclaw-plugin`, and `openclaw-tavily`, plus expected-fail cases such as `@supermemory/openclaw-supermemory`. - -For the detailed matrix, see [Plugin Compatibility Guide](docs/COMPATIBILITY.md). - -## Typed Integration API and MCP - -The gateway exposes three complementary remote surfaces: - -| Surface | Path | Purpose | -|---------|------|---------| -| OpenAI-compatible | `/v1/*` | Drop-in for OpenAI clients | -| Typed integration API | `/api/integration/*` | Status, dashboard, approvals, providers, plugins, sessions, events, message enqueueing | -| MCP facade | `/mcp` | JSON-RPC facade (`initialize`, `tools/*`, `resources/*`, `prompts/*`) | - -The shared `OpenClaw.Client` package exposes matching .NET methods for both surfaces: - -```csharp -using OpenClaw.Client; - -using var client = new OpenClawHttpClient("http://127.0.0.1:18789", authToken: null); +| Variable | Default | Purpose | +|----------|---------|---------| +| `MODEL_PROVIDER_KEY` | — | LLM provider API key | +| `OPENCLAW_WORKSPACE` | — | Workspace directory for file tools | +| `OPENCLAW_AUTH_TOKEN` | — | Auth token (required for non-loopback) | +| `OpenClaw__Runtime__Mode` | `auto` | Runtime lane (`aot`, `jit`, or `auto`) | -var dashboard = await client.GetIntegrationDashboardAsync(CancellationToken.None); -var statusTool = await client.CallMcpToolAsync( - "openclaw.get_status", - JsonDocument.Parse("{}").RootElement.Clone(), - CancellationToken.None); -``` +See the full [Quickstart Guide](docs/QUICKSTART.md) for deployment notes. ## Docker Deployment @@ -232,97 +271,42 @@ export OPENCLAW_DOMAIN="openclaw.example.com" docker compose --profile with-tls up -d ``` -### Build from source - -```bash -docker build -t openclaw.net . -docker run -d -p 18789:18789 \ - -e MODEL_PROVIDER_KEY="sk-..." \ - -e OPENCLAW_AUTH_TOKEN="change-me" \ - -v openclaw-memory:/app/memory \ - openclaw.net -``` - ### Published images -Available on all three registries: - - `ghcr.io/clawdotnet/openclaw.net:latest` - `tellikoroma/openclaw.net:latest` - `public.ecr.aws/u6i5b9b7/openclaw.net:latest` -### Volumes - -| Path | Purpose | -|------|---------| +| Volume | Purpose | +|--------|---------| | `/app/memory` | Session history + memory notes (persist across restarts) | | `/app/workspace` | Mounted workspace for file tools (optional) | -See [Docker Image Notes](docs/DOCKERHUB.md) for multi-arch push commands and image details. +See [Docker Image Notes](docs/DOCKERHUB.md) for details. ## Security and Hardening -When binding to a non-loopback address, the gateway **refuses to start** unless dangerous settings are explicitly hardened or opted in: - -- Auth token is **required** for non-loopback binds -- Wildcard tooling roots, shell access, and plugin execution are blocked by default -- WhatsApp webhooks require signature validation -- `raw:` secret refs are rejected on public binds +When binding to a non-loopback address, the gateway **refuses to start** unless dangerous settings are explicitly hardened: -**Quick checklist:** - -- [ ] Set `OPENCLAW_AUTH_TOKEN` to a strong random value -- [ ] Set `MODEL_PROVIDER_KEY` via environment variable (never in config files) -- [ ] Use `appsettings.Production.json` (`AllowShell=false`, restricted roots) -- [ ] Enable TLS (reverse proxy or Kestrel HTTPS) -- [ ] Set `AllowedOrigins` if serving a web frontend -- [ ] Configure rate limits (`MaxConnectionsPerIp`, `MessagesPerMinutePerConnection`, `SessionRateLimitPerMinute`) -- [ ] Monitor `/health` and `/metrics` endpoints -- [ ] Pin a specific Docker image tag in production +- Auth token **required** for non-loopback binds +- Wildcard tooling roots, shell access, and plugin execution blocked by default +- Webhook signature validation enforced (Slack HMAC-SHA256, Discord Ed25519, WhatsApp X-Hub-Signature-256) +- `raw:` secret refs rejected on public binds +- DM policy enforcement per channel (`open`, `pairing`, `closed`) +- Rate limiting per-IP, per-connection, and per-session See [Security Guide](SECURITY.md) for full hardening guidance and [Sandboxing Guide](docs/sandboxing.md) for sandbox routing. -## Channels - -OpenClaw.NET supports inbound channels for **Telegram**, **Twilio SMS**, **WhatsApp**, and **generic webhooks**, each with configurable body limits, allowlists, and signature validation. - -| Channel | Webhook path | Setup guide | -|---------|-------------|-------------| -| Telegram | `/telegram/inbound` | [User Guide](docs/USER_GUIDE.md) | -| Twilio SMS | `/twilio/sms/inbound` | [User Guide](docs/USER_GUIDE.md) | -| WhatsApp | configurable | [WhatsApp Setup](docs/WHATSAPP_SETUP.md) | -| Generic webhooks | `/webhooks/{name}` | [User Guide](docs/USER_GUIDE.md) | - ## Observability | Endpoint | Description | |----------|-------------| -| `GET /health` | Health check (`status`, `uptime`) | -| `GET /metrics` | Runtime counters (requests, tokens, tool calls, circuit breaker, retention) | -| `GET /memory/retention/status` | Retention config + last run state | -| `POST /memory/retention/sweep` | Trigger manual retention sweep (`?dryRun=true` supported) | - -All agent operations emit structured logs and `.NET Activity` traces with correlation IDs, exportable to OTLP collectors (Jaeger, Prometheus, Grafana). - -## Optional Integrations +| `GET /health` | Health check | +| `GET /metrics` | Runtime counters (requests, tokens, tool calls, circuit breaker) | +| `GET /memory/retention/status` | Retention config + last sweep | +| `POST /memory/retention/sweep` | Manual retention sweep (`?dryRun=true`) | -### Semantic Kernel - -OpenClaw.NET can act as the **production gateway host** (auth, rate limits, channels, OTEL, policy) around your existing SK code. See [Semantic Kernel Guide](docs/SEMANTIC_KERNEL.md). - -Available: `src/OpenClaw.SemanticKernelAdapter` (adapter library) and `samples/OpenClaw.SemanticKernelInteropHost` (runnable sample). - -### MAF Orchestrator - -Microsoft Agent Framework is supported as an optional backend in MAF-enabled build artifacts. Set `Runtime.Orchestrator=maf` (default remains `native`). See the [publish script](eng/publish-gateway-artifacts.sh) for artifact details. - -### Notion Scratchpad - -Optional native Notion integration for shared scratchpads and notes (`notion` / `notion_write` tools). See [Tool Guide](docs/TOOLS_GUIDE.md). - -### Memory Retention - -Background retention sweeper for sessions and branches (opt-in). Default TTLs: sessions 30 days, branches 14 days. See [User Guide](docs/USER_GUIDE.md). +All operations emit structured logs and `.NET Activity` traces with correlation IDs, exportable to OTLP collectors. ## Docs @@ -349,11 +333,10 @@ GitHub Actions (`.github/workflows/ci.yml`): Looking for: -- Security review +- Security review and penetration testing - NativeAOT trimming improvements -- Tool sandboxing ideas +- Tool sandboxing and isolation ideas +- New channel adapters and integrations - Performance benchmarks -If this aligns with your interests, open an issue. - -If this project helps your .NET AI work, consider starring it. +If this aligns with your interests, open an issue. If this project helps your .NET AI work, consider starring it. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 372fc92..4df1395 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,13 @@ ## Recently Completed +- **Channel expansion**: Discord (Gateway WebSocket + interaction webhook), Slack (Events API + slash commands), Signal (signald/signal-cli bridge) channel adapters with DM policy, allowlists, thread-to-session mapping, and signature validation. +- **Tool expansion** (34 → 48 native tools): edit_file, apply_patch, message, x_search, memory_get, sessions_history, sessions_send, sessions_spawn, session_status, sessions_yield, agents_list, cron, gateway, profile_write. +- **Tool presets and groups**: 4 new built-in presets (full, coding, messaging, minimal) and 7 built-in tool groups (group:runtime, group:fs, group:sessions, group:memory, group:web, group:automation, group:messaging). +- **Chat commands**: /think (reasoning effort), /compact (history compaction), /verbose (tool call/token output). +- **Multi-agent routing**: per-channel/sender routing with model override, route-scoped prompt instructions, tool presets, and tool allowlist restrictions. +- **Integrations**: Tailscale Serve/Funnel, Gmail Pub/Sub event bridge, mDNS/Bonjour service discovery. +- **Plugin installer**: built-in `openclaw plugins install/remove/list/search` for npm/ClawHub packages. - Security audit closure for plugin IPC hardening, plugin-root containment, browser cancellation recovery, strict session-cap admission, and session-lock disposal. - Admin/operator tooling: - posture diagnostics @@ -12,6 +19,56 @@ - Startup/runtime composition split into explicit service, channel, plugin, and runtime assembly stages. - Optional native Notion scratchpad integration with scoped read/write tools (`notion`, `notion_write`), allowlists, and write approvals by default. +## Runtime and Platform Expansion + +These are strong candidates for the next roadmap phases because they extend the current runtime, channel, and operator model without fighting the existing architecture. + +### Multimodal and Input Expansion + +4. **Voice memo transcription** + - Detect inbound audio across supported channels and route it through a transcription provider. + - Inject transcript text into the runtime before the normal agent turn starts. + - Provide clear degraded behavior when transcription is disabled or unavailable. + +5. **Checkpoint and resume for long-running tasks** + - Persist structured save points during multi-step execution. + - Allow interrupted or restarted sessions to resume from the last completed checkpoint. + - Start with checkpointing after successful tool batches instead of trying to snapshot every internal runtime state transition. + +6. **Mixture-of-agents execution** + - Fan out a prompt to multiple providers and synthesize a final answer from their outputs. + - Expose this as an optional high-cost/high-confidence runtime mode or explicit tool. + - Keep it profile-driven so it can be limited to selected models and use cases. + +### Execution and Deployment Options + +7. **Daytona execution backend** + - Add a remote workspace backend with hibernation and resume support. + - Fit it into the existing `IExecutionBackend` and process execution model rather than adding a separate tool path. + - Useful for persistent remote development-style sandboxes. + +8. **Modal execution backend** + - Add a serverless execution backend for short-lived compute-heavy tasks. + - Focus on one-shot and bounded process execution first. + - Treat GPU-enabled workloads as an optional extension once the base backend is stable. + +### Operator Visibility and Safety + +9. **CLI/TUI insights** + - Add an `openclaw insights` command and matching TUI panel. + - Summarize provider usage, token spend, tool frequency, and session counts from existing telemetry. + - Prefer operator-readable summaries over introducing a new analytics subsystem. + +10. **URL safety validation** + - Add SSRF-oriented URL validation in web fetch and browser tooling. + - Block loopback/private targets by default and allow optional blocklists. + - Keep this configurable, but make the safe path easy to enable globally. + +11. **Trajectory export** + - Export prompts, tool calls, results, and responses as JSONL for analysis or training pipelines. + - Support date-range or session-scoped export plus optional anonymization. + - Expose it through admin and CLI surfaces instead of burying it in storage internals. + ## Security Hardening (Likely Breaking) These are worthwhile changes, but they can break existing deployments or require new configuration. diff --git a/src/OpenClaw.Agent/AgentRuntime.cs b/src/OpenClaw.Agent/AgentRuntime.cs index 087d1b6..819a131 100644 --- a/src/OpenClaw.Agent/AgentRuntime.cs +++ b/src/OpenClaw.Agent/AgentRuntime.cs @@ -234,6 +234,12 @@ public async Task RunAsync( : null }; + if (!string.IsNullOrWhiteSpace(session.ReasoningEffort)) + { + chatOptions.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + chatOptions.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort; + } + for (var i = 0; i < _maxIterations; i++) { // Mid-turn budget check: stop if token budget is exceeded @@ -392,6 +398,12 @@ public async IAsyncEnumerable RunStreamingAsync( Tools = _toolExecutor.GetToolDeclarations(session) }; + if (!string.IsNullOrWhiteSpace(session.ReasoningEffort)) + { + chatOptions.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + chatOptions.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort; + } + for (var i = 0; i < _maxIterations; i++) { // Mid-turn budget check: stop if token budget is exceeded @@ -1089,7 +1101,7 @@ private static bool IsTransient(Exception ex) /// Compacts session history by summarizing older turns via the LLM. /// Keeps the most recent turns verbatim and replaces older ones with a summary. /// - internal async Task CompactHistoryAsync(Session session, CancellationToken ct) + public async Task CompactHistoryAsync(Session session, CancellationToken ct) { if (session.History.Count <= _compactionThreshold) { @@ -1187,15 +1199,9 @@ internal async Task CompactHistoryAsync(Session session, CancellationToken ct) private List BuildMessages(Session session) { - string systemPrompt; - lock (_skillGate) - { - systemPrompt = _systemPrompt; - } - var messages = new List { - new(ChatRole.System, systemPrompt) + new(ChatRole.System, GetSystemPrompt(session)) }; // Add history (bounded to avoid context overflow) @@ -1227,6 +1233,20 @@ private List BuildMessages(Session session) return messages; } + private string GetSystemPrompt(Session session) + { + string systemPrompt; + lock (_skillGate) + { + systemPrompt = _systemPrompt; + } + + if (string.IsNullOrWhiteSpace(session.SystemPromptOverride)) + return systemPrompt; + + return systemPrompt + "\n\n[Route Instructions]\n" + session.SystemPromptOverride.Trim(); + } + private static IList BuildTurnContents(string content) { var (markers, remainingText) = MediaMarkerProtocol.Extract(content); diff --git a/src/OpenClaw.Agent/OpenClawToolExecutor.cs b/src/OpenClaw.Agent/OpenClawToolExecutor.cs index 7b303cb..a7f7d75 100644 --- a/src/OpenClaw.Agent/OpenClawToolExecutor.cs +++ b/src/OpenClaw.Agent/OpenClawToolExecutor.cs @@ -80,12 +80,9 @@ public OpenClawToolExecutor( public IList GetToolDeclarations(Session session) { - if (_toolPresetResolver is null) - return _toolDeclarations; - - var preset = _toolPresetResolver.Resolve(session, _toolsByName.Keys); + var preset = _toolPresetResolver?.Resolve(session, _toolsByName.Keys); return _toolDeclarations - .Where(item => preset.AllowedTools.Contains(item.Name)) + .Where(item => IsToolAllowedForSession(session, item.Name, preset)) .ToArray(); } @@ -140,9 +137,11 @@ public async Task ExecuteAsync( } var preset = _toolPresetResolver?.Resolve(session, _toolsByName.Keys); - if (preset is not null && !preset.AllowedTools.Contains(tool.Name)) + if (!IsToolAllowedForSession(session, tool.Name, preset)) { - var deniedByPreset = $"Tool '{tool.Name}' is not allowed for preset '{preset.PresetId}'."; + var deniedByPreset = preset is not null + ? $"Tool '{tool.Name}' is not allowed for preset '{preset.PresetId}'." + : $"Tool '{tool.Name}' is not allowed for this session."; _logger?.LogInformation("[{CorrelationId}] {Message}", turnCtx.CorrelationId, deniedByPreset); return CreateImmediateResult(toolName, argsJson, deniedByPreset); } @@ -308,6 +307,17 @@ private static ToolExecutionResult CreateImmediateResult(string toolName, string }; } + private static bool IsToolAllowedForSession(Session session, string toolName, ResolvedToolPreset? preset) + { + if (preset is not null && !preset.AllowedTools.Contains(toolName)) + return false; + + if (session.RouteAllowedTools is { Length: > 0 }) + return session.RouteAllowedTools.Contains(toolName, StringComparer.OrdinalIgnoreCase); + + return true; + } + private async Task ExecuteStreamingToolCollectAsync( IStreamingTool tool, string argsJson, diff --git a/src/OpenClaw.Agent/Tools/ApplyPatchTool.cs b/src/OpenClaw.Agent/Tools/ApplyPatchTool.cs new file mode 100644 index 0000000..6b8adf5 --- /dev/null +++ b/src/OpenClaw.Agent/Tools/ApplyPatchTool.cs @@ -0,0 +1,151 @@ +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; + +namespace OpenClaw.Agent.Tools; + +/// +/// Apply a unified diff patch to a file. Supports multi-hunk patches. +/// +public sealed class ApplyPatchTool : ITool +{ + private readonly ToolingConfig _config; + + public ApplyPatchTool(ToolingConfig config) => _config = config; + + public string Name => "apply_patch"; + public string Description => "Apply a unified diff patch to a file. Supports multi-hunk patches for complex edits."; + public string ParameterSchema => """{"type":"object","properties":{"path":{"type":"string","description":"File path to patch"},"patch":{"type":"string","description":"Unified diff patch content (lines starting with +/- and @@ hunk headers)"}},"required":["path","patch"]}"""; + + public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + if (_config.ReadOnlyMode) + return "Error: apply_patch is disabled because Tooling.ReadOnlyMode is enabled."; + + using var args = System.Text.Json.JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var path = GetString(root, "path"); + if (string.IsNullOrWhiteSpace(path)) + return "Error: 'path' is required."; + + var patch = GetString(root, "patch"); + if (string.IsNullOrWhiteSpace(patch)) + return "Error: 'patch' is required."; + + var resolvedPath = ToolPathPolicy.ResolveRealPath(path); + + if (!ToolPathPolicy.IsWriteAllowed(_config, resolvedPath)) + return $"Error: Write access denied for path: {path}"; + + if (!File.Exists(resolvedPath)) + return $"Error: File not found: {path}"; + + var originalLines = await File.ReadAllLinesAsync(resolvedPath, ct); + var hunks = ParseHunks(patch); + + if (hunks.Count == 0) + return "Error: No valid hunks found in patch. Use @@ -start,count +start,count @@ headers."; + + var result = new List(originalLines); + var offset = 0; + + foreach (var hunk in hunks) + { + var startLine = hunk.OriginalStart - 1 + offset; + if (startLine < 0 || startLine > result.Count) + return $"Error: Hunk at line {hunk.OriginalStart} is out of range (file has {result.Count} lines)."; + + // Validate removed lines match file content + if (startLine + hunk.RemoveLines.Count > result.Count) + return $"Error: Hunk at line {hunk.OriginalStart} expects {hunk.RemoveLines.Count} lines to remove, but only {result.Count - startLine} lines remain."; + + for (var i = 0; i < hunk.RemoveLines.Count; i++) + { + var expected = hunk.RemoveLines[i]; + var actual = result[startLine + i]; + if (!string.Equals(expected.TrimEnd(), actual.TrimEnd(), StringComparison.Ordinal)) + return $"Error: Hunk at line {hunk.OriginalStart + i} mismatch. Expected: \"{Truncate(expected, 60)}\" Got: \"{Truncate(actual, 60)}\""; + } + + // Remove old lines (validated above) + for (var i = 0; i < hunk.RemoveLines.Count; i++) + result.RemoveAt(startLine); + + // Insert new lines + for (var i = hunk.AddLines.Count - 1; i >= 0; i--) + result.Insert(startLine, hunk.AddLines[i]); + + offset += hunk.AddLines.Count - hunk.RemoveLines.Count; + } + + var tmp = resolvedPath + ".tmp"; + try + { + await File.WriteAllLinesAsync(tmp, result, ct); + File.Move(tmp, resolvedPath, overwrite: true); + } + catch + { + try { File.Delete(tmp); } catch { /* best-effort cleanup */ } + throw; + } + + return $"Applied {hunks.Count} hunk(s) to {path}."; + } + + private sealed record Hunk(int OriginalStart, List RemoveLines, List AddLines); + + private static List ParseHunks(string patch) + { + var hunks = new List(); + var lines = patch.Split('\n'); + Hunk? current = null; + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + + if (line.StartsWith("@@", StringComparison.Ordinal)) + { + if (current is not null) + hunks.Add(current); + + var origStart = ParseHunkStart(line); + current = new Hunk(origStart, [], []); + } + else if (current is not null) + { + if (line.StartsWith('-')) + current.RemoveLines.Add(line[1..]); + else if (line.StartsWith('+')) + current.AddLines.Add(line[1..]); + // Context lines (starting with space) are skipped — we trust line numbers + } + } + + if (current is not null) + hunks.Add(current); + + return hunks; + } + + private static int ParseHunkStart(string header) + { + // Parse @@ -start,count +start,count @@ + var idx = header.IndexOf('-', 3); + if (idx < 0) return 1; + var comma = header.IndexOf(',', idx); + var end = comma > 0 ? comma : header.IndexOf(' ', idx + 1); + if (end < 0) end = header.Length; + return int.TryParse(header.AsSpan(idx + 1, end - idx - 1), out var start) ? start : 1; + } + + private static string Truncate(string s, int maxLen) + => s.Length <= maxLen ? s : s[..maxLen] + "…"; + + private static string? GetString(System.Text.Json.JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Agent/Tools/EditFileTool.cs b/src/OpenClaw.Agent/Tools/EditFileTool.cs new file mode 100644 index 0000000..f7aff47 --- /dev/null +++ b/src/OpenClaw.Agent/Tools/EditFileTool.cs @@ -0,0 +1,107 @@ +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; + +namespace OpenClaw.Agent.Tools; + +/// +/// Targeted search-and-replace editing of files. Safer than full write_file for small changes. +/// +public sealed class EditFileTool : ITool +{ + private readonly ToolingConfig _config; + + public EditFileTool(ToolingConfig config) => _config = config; + + public string Name => "edit_file"; + public string Description => "Edit a file by replacing a specific text string with new text. Safer than write_file for targeted changes."; + public string ParameterSchema => """{"type":"object","properties":{"path":{"type":"string","description":"File path to edit"},"old_text":{"type":"string","description":"Exact text to find and replace"},"new_text":{"type":"string","description":"Replacement text"},"replace_all":{"type":"boolean","description":"Replace all occurrences (default: false)"}},"required":["path","old_text","new_text"]}"""; + + public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + if (_config.ReadOnlyMode) + return "Error: edit_file is disabled because Tooling.ReadOnlyMode is enabled."; + + using var args = System.Text.Json.JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var path = GetString(root, "path"); + if (string.IsNullOrWhiteSpace(path)) + return "Error: 'path' is required."; + + var oldText = GetString(root, "old_text"); + if (oldText is null || oldText.Length == 0) + return "Error: 'old_text' is required and must not be empty."; + + var newText = GetString(root, "new_text"); + if (newText is null) + return "Error: 'new_text' is required."; + + var replaceAll = root.TryGetProperty("replace_all", out var ra) && + ra.ValueKind == System.Text.Json.JsonValueKind.True; + + var resolvedPath = ToolPathPolicy.ResolveRealPath(path); + + if (!ToolPathPolicy.IsWriteAllowed(_config, resolvedPath)) + return $"Error: Write access denied for path: {path}"; + + if (!File.Exists(resolvedPath)) + return $"Error: File not found: {path}"; + + var content = await File.ReadAllTextAsync(resolvedPath, ct); + + if (!content.Contains(oldText, StringComparison.Ordinal)) + return "Error: 'old_text' not found in file."; + + if (!replaceAll) + { + var firstIdx = content.IndexOf(oldText, StringComparison.Ordinal); + var lastIdx = content.LastIndexOf(oldText, StringComparison.Ordinal); + if (firstIdx != lastIdx) + return "Error: 'old_text' appears multiple times. Set replace_all=true or provide more context to make it unique."; + } + + var updated = replaceAll + ? content.Replace(oldText, newText, StringComparison.Ordinal) + : ReplaceFirst(content, oldText, newText); + + var tmp = resolvedPath + ".tmp"; + try + { + await File.WriteAllTextAsync(tmp, updated, ct); + File.Move(tmp, resolvedPath, overwrite: true); + } + catch + { + try { File.Delete(tmp); } catch { /* best-effort cleanup */ } + throw; + } + + var count = replaceAll ? CountOccurrences(content, oldText) : 1; + return $"Replaced {count} occurrence(s) in {path}."; + } + + private static string ReplaceFirst(string source, string oldValue, string newValue) + { + var idx = source.IndexOf(oldValue, StringComparison.Ordinal); + if (idx < 0) return source; + return string.Concat(source.AsSpan(0, idx), newValue, source.AsSpan(idx + oldValue.Length)); + } + + private static int CountOccurrences(string source, string value) + { + var count = 0; + var idx = 0; + while ((idx = source.IndexOf(value, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += value.Length; + } + return count; + } + + private static string? GetString(System.Text.Json.JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Agent/Tools/MemoryGetTool.cs b/src/OpenClaw.Agent/Tools/MemoryGetTool.cs new file mode 100644 index 0000000..1c71d2c --- /dev/null +++ b/src/OpenClaw.Agent/Tools/MemoryGetTool.cs @@ -0,0 +1,36 @@ +using OpenClaw.Core.Abstractions; + +namespace OpenClaw.Agent.Tools; + +/// +/// Retrieve a specific memory note by key. Direct get without action parameter. +/// +public sealed class MemoryGetTool : ITool +{ + private readonly IMemoryStore _store; + + public MemoryGetTool(IMemoryStore store) => _store = store; + + public string Name => "memory_get"; + public string Description => "Retrieve a specific memory note by its key. Returns the note content or an error if not found."; + public string ParameterSchema => """{"type":"object","properties":{"key":{"type":"string","description":"The memory note key to retrieve"}},"required":["key"]}"""; + + public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + using var args = System.Text.Json.JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var key = root.TryGetProperty("key", out var k) && k.ValueKind == System.Text.Json.JsonValueKind.String + ? k.GetString() : null; + if (string.IsNullOrWhiteSpace(key)) + return "Error: 'key' is required."; + + var keyError = OpenClaw.Core.Security.InputSanitizer.CheckMemoryKey(key); + if (keyError is not null) + return $"Error: {keyError}"; + + var content = await _store.LoadNoteAsync(key, ct); + return content ?? $"Note '{key}' not found."; + } +} diff --git a/src/OpenClaw.Agent/Tools/MessageTool.cs b/src/OpenClaw.Agent/Tools/MessageTool.cs new file mode 100644 index 0000000..4725b19 --- /dev/null +++ b/src/OpenClaw.Agent/Tools/MessageTool.cs @@ -0,0 +1,60 @@ +using System.Threading.Channels; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; + +namespace OpenClaw.Agent.Tools; + +/// +/// Send messages across channels. Routes outbound messages through the pipeline. +/// +public sealed class MessageTool : ITool +{ + private readonly ChannelWriter _outbound; + + public MessageTool(MessagePipeline pipeline) + { + _outbound = pipeline.OutboundWriter; + } + + public string Name => "message"; + public string Description => "Send a message to a specific channel and recipient. Use to communicate across channels."; + public string ParameterSchema => """{"type":"object","properties":{"channel_id":{"type":"string","description":"Target channel (e.g. 'telegram', 'slack', 'discord', 'sms', 'email', 'websocket')"},"recipient_id":{"type":"string","description":"Recipient identifier (chat ID, user ID, phone number, etc.)"},"text":{"type":"string","description":"Message text to send"},"reply_to":{"type":"string","description":"Optional message ID to reply to"}},"required":["channel_id","recipient_id","text"]}"""; + + public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + using var args = System.Text.Json.JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var channelId = GetString(root, "channel_id"); + if (string.IsNullOrWhiteSpace(channelId)) + return "Error: 'channel_id' is required."; + + var recipientId = GetString(root, "recipient_id"); + if (string.IsNullOrWhiteSpace(recipientId)) + return "Error: 'recipient_id' is required."; + + var text = GetString(root, "text"); + if (string.IsNullOrWhiteSpace(text)) + return "Error: 'text' is required."; + + var replyTo = GetString(root, "reply_to"); + + var message = new OutboundMessage + { + ChannelId = channelId, + RecipientId = recipientId, + Text = text, + ReplyToMessageId = replyTo, + }; + + await _outbound.WriteAsync(message, ct); + return $"Message queued for delivery to {channelId}:{recipientId}."; + } + + private static string? GetString(System.Text.Json.JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Agent/Tools/XSearchTool.cs b/src/OpenClaw.Agent/Tools/XSearchTool.cs new file mode 100644 index 0000000..0f2f43f --- /dev/null +++ b/src/OpenClaw.Agent/Tools/XSearchTool.cs @@ -0,0 +1,94 @@ +using System.Net.Http; +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Http; +using OpenClaw.Core.Models; +using OpenClaw.Core.Security; + +namespace OpenClaw.Agent.Tools; + +/// +/// Search posts on the X (Twitter) platform using the X API v2. +/// +public sealed class XSearchTool : ITool, IDisposable +{ + private readonly HttpClient _http; + private readonly string? _bearerToken; + + public XSearchTool() + { + _http = HttpClientFactory.Create(); + _bearerToken = SecretResolver.Resolve("env:X_BEARER_TOKEN"); + } + + public string Name => "x_search"; + public string Description => "Search posts on the X (Twitter) platform. Requires X_BEARER_TOKEN environment variable."; + public string ParameterSchema => """{"type":"object","properties":{"query":{"type":"string","description":"Search query (supports X search operators)"},"max_results":{"type":"integer","description":"Maximum results to return (10-100, default 10)"}},"required":["query"]}"""; + + public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_bearerToken)) + return "Error: X API bearer token not configured. Set X_BEARER_TOKEN environment variable."; + + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var query = GetString(root, "query"); + if (string.IsNullOrWhiteSpace(query)) + return "Error: 'query' is required."; + + var maxResults = 10; + if (root.TryGetProperty("max_results", out var mr) && mr.ValueKind == JsonValueKind.Number) + maxResults = Math.Clamp(mr.GetInt32(), 10, 100); + + try + { + var url = $"https://api.x.com/2/tweets/search/recent?query={Uri.EscapeDataString(query)}&max_results={maxResults}&tweet.fields=created_at,author_id,text,public_metrics"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _bearerToken); + + var response = await _http.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(ct); + return $"Error: X API returned {(int)response.StatusCode}. {errorBody}"; + } + + var body = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement; + + if (!data.TryGetProperty("data", out var tweets) || tweets.ValueKind != JsonValueKind.Array) + return "No results found."; + + var sb = new System.Text.StringBuilder(); + var count = 0; + foreach (var tweet in tweets.EnumerateArray()) + { + count++; + var text = tweet.TryGetProperty("text", out var t) ? t.GetString() : ""; + var authorId = tweet.TryGetProperty("author_id", out var a) ? a.GetString() : "unknown"; + var createdAt = tweet.TryGetProperty("created_at", out var c) ? c.GetString() : ""; + sb.AppendLine($"[{count}] @{authorId} ({createdAt})"); + sb.AppendLine(text); + sb.AppendLine(); + } + + return sb.Length > 0 ? sb.ToString().TrimEnd() : "No results found."; + } + catch (Exception ex) + { + return $"Error: X search failed: {ex.Message}"; + } + } + + public void Dispose() => _http.Dispose(); + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Channels/DiscordChannel.cs b/src/OpenClaw.Channels/DiscordChannel.cs new file mode 100644 index 0000000..45ff044 --- /dev/null +++ b/src/OpenClaw.Channels/DiscordChannel.cs @@ -0,0 +1,503 @@ +using System.Buffers; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Http; +using OpenClaw.Core.Models; +using OpenClaw.Core.Security; + +namespace OpenClaw.Channels; + +/// +/// A channel adapter for the Discord Bot API. +/// Uses a Gateway WebSocket for receiving messages and REST API for sending. +/// Interaction webhooks (slash commands) are handled separately in the gateway. +/// +public sealed class DiscordChannel : IChannelAdapter +{ + private const string ApiBase = "https://discord.com/api/v10"; + private const string GatewayUrl = "wss://gateway.discord.gg/?v=10&encoding=json"; + private const int GatewayIntentGuildMessages = 1 << 9; + private const int GatewayIntentDirectMessages = 1 << 12; + private const int GatewayIntentMessageContent = 1 << 15; + + private readonly DiscordChannelConfig _config; + private readonly HttpClient _http; + private readonly ILogger _logger; + private readonly string _botToken; + private readonly string? _applicationId; + private readonly bool _ownsHttp; + + private ClientWebSocket? _gateway; + private CancellationTokenSource? _cts; + private Task? _receiveLoop; + private int? _lastSequence; + private string? _sessionId; + private string? _resumeGatewayUrl; + + public DiscordChannel(DiscordChannelConfig config, ILogger logger) + : this(config, logger, http: null) + { + } + + public DiscordChannel(DiscordChannelConfig config, ILogger logger, HttpClient? http) + { + _config = config; + _logger = logger; + _http = http ?? HttpClientFactory.Create(); + _ownsHttp = http is null; + + var tokenSource = SecretResolver.Resolve(config.BotTokenRef) ?? config.BotToken; + _botToken = tokenSource ?? throw new InvalidOperationException("Discord bot token not configured or missing from environment."); + + _applicationId = SecretResolver.Resolve(config.ApplicationIdRef) ?? config.ApplicationId; + } + + public string ChannelType => "discord"; + public string ChannelId => "discord"; + + public event Func? OnMessageReceived; + + public async Task StartAsync(CancellationToken ct) + { + if (_config.RegisterSlashCommands && !string.IsNullOrWhiteSpace(_applicationId)) + await RegisterSlashCommandsAsync(ct); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _receiveLoop = RunGatewayLoopAsync(_cts.Token); + } + + public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(outbound.Text)) return; + + try + { + var (markers, remaining) = MediaMarkerProtocol.Extract(outbound.Text); + var text = string.IsNullOrWhiteSpace(remaining) ? outbound.Text : remaining; + + // Discord message limit is 2000 chars + if (text.Length > 2000) + text = text[..2000]; + + var payload = new DiscordCreateMessageRequest { Content = text }; + var response = await SendMessageRequestAsync(outbound.RecipientId, payload, ct); + + // Handle rate limiting + if ((int)response.StatusCode == 429) + { + var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); + _logger.LogWarning("Discord rate limited, retrying after {RetryAfter}ms.", retryAfter.TotalMilliseconds); + response.Dispose(); + await Task.Delay(retryAfter, ct); + response = await SendMessageRequestAsync(outbound.RecipientId, payload, ct); + } + + response.EnsureSuccessStatusCode(); + response.Dispose(); + _logger.LogInformation("Sent Discord message to channel {ChannelId}.", outbound.RecipientId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Discord message to {RecipientId}.", outbound.RecipientId); + } + } + + private async Task SendMessageRequestAsync( + string recipientId, + DiscordCreateMessageRequest payload, + CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiBase}/channels/{recipientId}/messages"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bot", _botToken); + request.Content = JsonContent.Create(payload, DiscordJsonContext.Default.DiscordCreateMessageRequest); + return await _http.SendAsync(request, ct); + } + + private async Task RunGatewayLoopAsync(CancellationToken ct) + { + var backoff = TimeSpan.FromSeconds(1); + const int maxBackoff = 60; + + while (!ct.IsCancellationRequested) + { + try + { + _gateway = new ClientWebSocket(); + var url = _resumeGatewayUrl ?? GatewayUrl; + await _gateway.ConnectAsync(new Uri(url), ct); + _logger.LogInformation("Connected to Discord Gateway."); + + backoff = TimeSpan.FromSeconds(1); + await ProcessGatewayMessagesAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Discord Gateway disconnected. Reconnecting in {Backoff}s.", backoff.TotalSeconds); + } + finally + { + if (_gateway?.State == WebSocketState.Open) + { + try { await _gateway.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); } + catch { /* best-effort close */ } + } + _gateway?.Dispose(); + _gateway = null; + } + + await Task.Delay(backoff, ct); + backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, maxBackoff)); + } + } + + private async Task ProcessGatewayMessagesAsync(CancellationToken ct) + { + var buffer = new ArrayBufferWriter(4096); + Task? heartbeatTask = null; + CancellationTokenSource? heartbeatCts = null; + + try + { + while (!ct.IsCancellationRequested && _gateway?.State == WebSocketState.Open) + { + buffer.Clear(); + ValueWebSocketReceiveResult result; + do + { + var memory = buffer.GetMemory(4096); + result = await _gateway.ReceiveAsync(memory, ct); + buffer.Advance(result.Count); + } while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) + return; + + using var doc = JsonDocument.Parse(buffer.WrittenMemory); + var root = doc.RootElement; + + var op = root.GetProperty("op").GetInt32(); + if (root.TryGetProperty("s", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number) + _lastSequence = seqProp.GetInt32(); + + switch (op) + { + case 10: // Hello — start heartbeat and identify + var interval = root.GetProperty("d").GetProperty("heartbeat_interval").GetInt32(); + heartbeatCts?.Cancel(); + heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + heartbeatTask = RunHeartbeatAsync(interval, heartbeatCts.Token); + + if (_sessionId is not null) + await SendResumeAsync(ct); + else + await SendIdentifyAsync(ct); + break; + + case 0: // Dispatch + var eventName = root.TryGetProperty("t", out var tProp) ? tProp.GetString() : null; + if (root.TryGetProperty("d", out var data)) + await HandleDispatchAsync(eventName, data, ct); + break; + + case 7: // Reconnect + _logger.LogInformation("Discord Gateway requested reconnect."); + return; + + case 9: // Invalid Session — re-identify + _sessionId = null; + _lastSequence = null; + await Task.Delay(Random.Shared.Next(1000, 5000), ct); + await SendIdentifyAsync(ct); + break; + + case 11: // Heartbeat ACK — no action needed + break; + } + } + } + finally + { + heartbeatCts?.Cancel(); + if (heartbeatTask is not null) + { + try { await heartbeatTask; } + catch (OperationCanceledException) { } + } + heartbeatCts?.Dispose(); + } + } + + private async Task RunHeartbeatAsync(int intervalMs, CancellationToken ct) + { + // Jitter the first heartbeat + await Task.Delay(Random.Shared.Next(0, intervalMs), ct); + while (!ct.IsCancellationRequested) + { + await SendGatewayPayloadAsync(1, _lastSequence?.ToString() ?? "null", ct); + await Task.Delay(intervalMs, ct); + } + } + + private async Task SendIdentifyAsync(CancellationToken ct) + { + var intents = GatewayIntentGuildMessages | GatewayIntentDirectMessages | GatewayIntentMessageContent; + var payload = "{\"op\":2,\"d\":{\"token\":\"" + _botToken + "\",\"intents\":" + intents + + ",\"properties\":{\"os\":\"linux\",\"browser\":\"openclaw\",\"device\":\"openclaw\"}}}"; + await SendRawAsync(payload, ct); + } + + private async Task SendResumeAsync(CancellationToken ct) + { + var payload = "{\"op\":6,\"d\":{\"token\":\"" + _botToken + "\",\"session_id\":\"" + _sessionId + + "\",\"seq\":" + (_lastSequence ?? 0) + "}}"; + await SendRawAsync(payload, ct); + } + + private async Task SendGatewayPayloadAsync(int op, string data, CancellationToken ct) + { + var payload = $"{{\"op\":{op},\"d\":{data}}}"; + await SendRawAsync(payload, ct); + } + + private async Task SendRawAsync(string payload, CancellationToken ct) + { + if (_gateway?.State != WebSocketState.Open) return; + var bytes = Encoding.UTF8.GetBytes(payload); + await _gateway.SendAsync(bytes, WebSocketMessageType.Text, true, ct); + } + + private async Task HandleDispatchAsync(string? eventName, JsonElement data, CancellationToken ct) + { + switch (eventName) + { + case "READY": + _sessionId = data.TryGetProperty("session_id", out var sid) ? sid.GetString() : null; + _resumeGatewayUrl = data.TryGetProperty("resume_gateway_url", out var rgu) ? rgu.GetString() : null; + _logger.LogInformation("Discord Gateway ready. Session: {SessionId}.", _sessionId); + break; + + case "MESSAGE_CREATE": + await HandleMessageCreateAsync(data, ct); + break; + } + } + + private async Task HandleMessageCreateAsync(JsonElement data, CancellationToken ct) + { + // Ignore bot messages + if (data.TryGetProperty("author", out var author) && + author.TryGetProperty("bot", out var bot) && + bot.ValueKind == JsonValueKind.True) + return; + + var userId = author.TryGetProperty("id", out var uid) ? uid.GetString() : null; + var text = data.TryGetProperty("content", out var content) ? content.GetString() : null; + var channelId = data.TryGetProperty("channel_id", out var cid) ? cid.GetString() : null; + var messageId = data.TryGetProperty("id", out var mid) ? mid.GetString() : null; + var guildId = data.TryGetProperty("guild_id", out var gid) ? gid.GetString() : null; + + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(text)) + return; + + // Guild allowlist + if (_config.AllowedGuildIds.Length > 0 && !string.IsNullOrWhiteSpace(guildId)) + { + if (!Array.Exists(_config.AllowedGuildIds, id => string.Equals(id, guildId, StringComparison.Ordinal))) + return; + } + + // Channel allowlist + if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channelId)) + { + if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channelId, StringComparison.Ordinal))) + return; + } + + // User allowlist + if (_config.AllowedFromUserIds.Length > 0) + { + if (!Array.Exists(_config.AllowedFromUserIds, id => string.Equals(id, userId, StringComparison.Ordinal))) + return; + } + + if (text.Length > _config.MaxInboundChars) + text = text[.._config.MaxInboundChars]; + + // Thread detection + var isThread = data.TryGetProperty("thread", out _) || + data.TryGetProperty("message_reference", out _) && + data.TryGetProperty("position", out _); // thread messages have position + var isDm = guildId is null; + + // Session mapping + string? sessionId; + if (isThread && channelId is not null) + sessionId = $"discord:thread:{channelId}"; + else if (isDm) + sessionId = null; // default: discord:{userId} + else if (channelId is not null) + sessionId = $"discord:{channelId}:{userId}"; + else + sessionId = null; + + // Reply-to mapping + string? replyToId = null; + if (data.TryGetProperty("message_reference", out var msgRef) && + msgRef.TryGetProperty("message_id", out var refId)) + replyToId = refId.GetString(); + + var message = new InboundMessage + { + ChannelId = "discord", + SenderId = userId, + SessionId = sessionId, + Text = text, + MessageId = messageId, + ReplyToMessageId = replyToId, + IsGroup = !isDm, + GroupId = guildId, + }; + + if (OnMessageReceived is not null) + await OnMessageReceived(message, ct); + } + + private async Task RegisterSlashCommandsAsync(CancellationToken ct) + { + try + { + var commandName = _config.SlashCommandPrefix; + var payload = $$"""[{"name":"{{commandName}}","description":"Send a message to OpenClaw","type":1,"options":[{"name":"message","description":"Your message","type":3,"required":true}]}]"""; + + using var request = new HttpRequestMessage(HttpMethod.Put, $"{ApiBase}/applications/{_applicationId}/commands"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bot", _botToken); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + var response = await _http.SendAsync(request, ct); + if (response.IsSuccessStatusCode) + _logger.LogInformation("Registered Discord slash command '/{Command}'.", commandName); + else + _logger.LogWarning("Failed to register Discord slash commands: {Status}.", response.StatusCode); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to register Discord slash commands."); + } + } + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_receiveLoop is not null) + { + try { await _receiveLoop; } + catch (OperationCanceledException) { } + } + _cts?.Dispose(); + _gateway?.Dispose(); + if (_ownsHttp) + _http.Dispose(); + } +} + +public sealed class DiscordCreateMessageRequest +{ + [JsonPropertyName("content")] + public required string Content { get; set; } +} + +public sealed class DiscordInteraction +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("data")] + public DiscordInteractionData? Data { get; set; } + + [JsonPropertyName("guild_id")] + public string? GuildId { get; set; } + + [JsonPropertyName("channel_id")] + public string? ChannelId { get; set; } + + [JsonPropertyName("member")] + public DiscordInteractionMember? Member { get; set; } + + [JsonPropertyName("user")] + public DiscordInteractionUser? User { get; set; } + + [JsonPropertyName("token")] + public string? Token { get; set; } +} + +public sealed class DiscordInteractionData +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("options")] + public DiscordInteractionOption[]? Options { get; set; } +} + +public sealed class DiscordInteractionOption +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public JsonElement? Value { get; set; } +} + +public sealed class DiscordInteractionMember +{ + [JsonPropertyName("user")] + public DiscordInteractionUser? User { get; set; } +} + +public sealed class DiscordInteractionUser +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("username")] + public string? Username { get; set; } +} + +public sealed class DiscordInteractionResponse +{ + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("data")] + public DiscordInteractionResponseData? Data { get; set; } +} + +public sealed class DiscordInteractionResponseData +{ + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +[JsonSerializable(typeof(DiscordCreateMessageRequest))] +[JsonSerializable(typeof(DiscordInteraction))] +[JsonSerializable(typeof(DiscordInteractionData))] +[JsonSerializable(typeof(DiscordInteractionOption))] +[JsonSerializable(typeof(DiscordInteractionMember))] +[JsonSerializable(typeof(DiscordInteractionUser))] +[JsonSerializable(typeof(DiscordInteractionResponse))] +[JsonSerializable(typeof(DiscordInteractionResponseData))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public partial class DiscordJsonContext : JsonSerializerContext; diff --git a/src/OpenClaw.Channels/SignalChannel.cs b/src/OpenClaw.Channels/SignalChannel.cs new file mode 100644 index 0000000..23c7dc6 --- /dev/null +++ b/src/OpenClaw.Channels/SignalChannel.cs @@ -0,0 +1,374 @@ +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Security; + +namespace OpenClaw.Channels; + +/// +/// A channel adapter for Signal messaging via signald or signal-cli bridge. +/// Communicates over a Unix domain socket (signald) or subprocess JSON-RPC. +/// +public sealed class SignalChannel : IChannelAdapter +{ + private readonly SignalChannelConfig _config; + private readonly ILogger _logger; + private readonly string _accountNumber; + + private CancellationTokenSource? _cts; + private Task? _receiveLoop; + + public SignalChannel(SignalChannelConfig config, ILogger logger) + { + _config = config; + _logger = logger; + + var phone = SecretResolver.Resolve(config.AccountPhoneNumberRef) ?? config.AccountPhoneNumber; + _accountNumber = phone ?? throw new InvalidOperationException("Signal account phone number not configured or missing from environment."); + } + + public string ChannelType => "signal"; + public string ChannelId => "signal"; + + public event Func? OnMessageReceived; + + public Task StartAsync(CancellationToken ct) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + + if (string.Equals(_config.Driver, "signald", StringComparison.OrdinalIgnoreCase)) + _receiveLoop = RunSignaldLoopAsync(_cts.Token); + else if (string.Equals(_config.Driver, "signal_cli", StringComparison.OrdinalIgnoreCase)) + _receiveLoop = RunSignalCliLoopAsync(_cts.Token); + else + throw new InvalidOperationException($"Unknown Signal driver: '{_config.Driver}'. Expected 'signald' or 'signal_cli'."); + + return Task.CompletedTask; + } + + public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(outbound.Text)) return; + + try + { + if (string.Equals(_config.Driver, "signald", StringComparison.OrdinalIgnoreCase)) + await SendViaSignaldAsync(outbound.RecipientId, outbound.Text, ct); + else + await SendViaSignalCliAsync(outbound.RecipientId, outbound.Text, ct); + + LogSend(outbound.RecipientId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Signal message to {Recipient}.", outbound.RecipientId); + } + } + + private void LogSend(string recipient) + { + if (_config.NoContentLogging) + _logger.LogInformation("Sent Signal message to {Recipient}. [content redacted]", recipient); + else + _logger.LogInformation("Sent Signal message to {Recipient}.", recipient); + } + + // ── signald driver ────────────────────────────────────────── + + private async Task RunSignaldLoopAsync(CancellationToken ct) + { + var backoff = TimeSpan.FromSeconds(1); + const int maxBackoff = 60; + + while (!ct.IsCancellationRequested) + { + try + { + using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + var endpoint = new UnixDomainSocketEndPoint(_config.SocketPath); + await socket.ConnectAsync(endpoint, ct); + _logger.LogInformation("Connected to signald at {Path}.", _config.SocketPath); + + backoff = TimeSpan.FromSeconds(1); + + // Subscribe to account + var subscribe = $$"""{"type":"subscribe","account":"{{_accountNumber}}"}"""; + await SendToSocketAsync(socket, subscribe, ct); + + if (_config.TrustAllKeys) + { + var trust = $$"""{"type":"trust","account":"{{_accountNumber}}","trust_level":"TRUSTED_UNVERIFIED"}"""; + await SendToSocketAsync(socket, trust, ct); + } + + using var stream = new NetworkStream(socket, ownsSocket: false); + using var reader = new StreamReader(stream, Encoding.UTF8); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) break; + + await ProcessSignaldMessageAsync(line, ct); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "signald connection lost. Reconnecting in {Backoff}s.", backoff.TotalSeconds); + } + + await Task.Delay(backoff, ct); + backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, maxBackoff)); + } + } + + private async Task ProcessSignaldMessageAsync(string json, CancellationToken ct) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var type = root.TryGetProperty("type", out var t) ? t.GetString() : null; + if (!string.Equals(type, "IncomingMessage", StringComparison.OrdinalIgnoreCase)) + return; + + if (!root.TryGetProperty("data", out var data)) + return; + + // Skip group messages (DM-only in v1) + if (data.TryGetProperty("data_message", out var dataMsg)) + { + if (dataMsg.TryGetProperty("group", out _) || dataMsg.TryGetProperty("groupV2", out _)) + { + _logger.LogDebug("Ignoring Signal group message (DM-only mode)."); + return; + } + + var body = dataMsg.TryGetProperty("body", out var bodyProp) ? bodyProp.GetString() : null; + var source = data.TryGetProperty("source", out var src) ? src.GetString() : null; + + if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(source)) + return; + + await DispatchInboundAsync(source, body, ct); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process signald message."); + } + } + + private async Task SendViaSignaldAsync(string recipient, string text, CancellationToken ct) + { + using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + var endpoint = new UnixDomainSocketEndPoint(_config.SocketPath); + await socket.ConnectAsync(endpoint, ct); + + var payload = $$"""{"type":"send","username":"{{_accountNumber}}","recipientAddress":{"number":"{{recipient}}"},"messageBody":"{{EscapeJson(text)}}"}"""; + await SendToSocketAsync(socket, payload, ct); + } + + private static async Task SendToSocketAsync(Socket socket, string message, CancellationToken ct) + { + var bytes = Encoding.UTF8.GetBytes(message + "\n"); + await socket.SendAsync(bytes, SocketFlags.None, ct); + } + + // ── signal-cli driver ─────────────────────────────────────── + + private async Task RunSignalCliLoopAsync(CancellationToken ct) + { + var cliPath = _config.SignalCliPath ?? "signal-cli"; + var backoff = TimeSpan.FromSeconds(1); + const int maxBackoff = 60; + + while (!ct.IsCancellationRequested) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = cliPath, + Arguments = $"-u {_accountNumber} daemon --json", + RedirectStandardOutput = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(psi); + if (process is null) + { + _logger.LogError("Failed to start signal-cli process."); + break; + } + + _logger.LogInformation("Started signal-cli daemon for {Account}.", _accountNumber); + backoff = TimeSpan.FromSeconds(1); + + try + { + using var reader = process.StandardOutput; + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) break; + + await ProcessSignalCliMessageAsync(line, ct); + } + } + finally + { + // Ensure the daemon process is terminated to avoid orphans + if (!process.HasExited) + { + try { process.Kill(entireProcessTree: true); } + catch { /* best effort */ } + } + } + + await process.WaitForExitAsync(CancellationToken.None); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "signal-cli process exited. Restarting in {Backoff}s.", backoff.TotalSeconds); + } + + await Task.Delay(backoff, ct); + backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, maxBackoff)); + } + } + + private async Task ProcessSignalCliMessageAsync(string json, CancellationToken ct) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("envelope", out var envelope)) + return; + + var source = envelope.TryGetProperty("sourceNumber", out var src) ? src.GetString() : null; + + if (!envelope.TryGetProperty("dataMessage", out var dataMsg)) + return; + + // Skip group messages + if (dataMsg.TryGetProperty("groupInfo", out _)) + { + _logger.LogDebug("Ignoring Signal group message (DM-only mode)."); + return; + } + + var body = dataMsg.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null; + if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(source)) + return; + + await DispatchInboundAsync(source, body, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process signal-cli message."); + } + } + + private async Task SendViaSignalCliAsync(string recipient, string text, CancellationToken ct) + { + var cliPath = _config.SignalCliPath ?? "signal-cli"; + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = cliPath, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + psi.ArgumentList.Add("-u"); + psi.ArgumentList.Add(_accountNumber); + psi.ArgumentList.Add("send"); + psi.ArgumentList.Add("-m"); + psi.ArgumentList.Add(text); + psi.ArgumentList.Add(recipient); + + using var process = System.Diagnostics.Process.Start(psi); + if (process is not null) + await process.WaitForExitAsync(ct); + } + + // ── common ────────────────────────────────────────────────── + + private async Task DispatchInboundAsync(string senderNumber, string text, CancellationToken ct) + { + // Allowlist check + if (_config.AllowedFromNumbers.Length > 0) + { + if (!Array.Exists(_config.AllowedFromNumbers, n => string.Equals(n, senderNumber, StringComparison.Ordinal))) + return; + } + + if (text.Length > _config.MaxInboundChars) + text = text[.._config.MaxInboundChars]; + + if (_config.NoContentLogging) + _logger.LogInformation("Received Signal message from {Sender}. [content redacted]", senderNumber); + else + _logger.LogInformation("Received Signal message from {Sender}.", senderNumber); + + var message = new InboundMessage + { + ChannelId = "signal", + SenderId = senderNumber, + Text = text, + IsGroup = false, + }; + + if (OnMessageReceived is not null) + await OnMessageReceived(message, ct); + } + + private static string EscapeJson(string s) => + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_receiveLoop is not null) + { + try { await _receiveLoop; } + catch (OperationCanceledException) { } + } + _cts?.Dispose(); + } +} + +[JsonSerializable(typeof(SignalSendRequest))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public partial class SignalJsonContext : JsonSerializerContext; + +public sealed class SignalSendRequest +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "send"; + + [JsonPropertyName("username")] + public required string Username { get; set; } + + [JsonPropertyName("messageBody")] + public required string MessageBody { get; set; } +} diff --git a/src/OpenClaw.Channels/SlackChannel.cs b/src/OpenClaw.Channels/SlackChannel.cs new file mode 100644 index 0000000..59dc771 --- /dev/null +++ b/src/OpenClaw.Channels/SlackChannel.cs @@ -0,0 +1,183 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Http; +using OpenClaw.Core.Models; +using OpenClaw.Core.Security; + +namespace OpenClaw.Channels; + +/// +/// A channel adapter for the Slack Web API. +/// Inbound traffic arrives via Slack Events API webhooks handled in the gateway. +/// +public sealed partial class SlackChannel : IChannelAdapter +{ + private readonly SlackChannelConfig _config; + private readonly HttpClient _http; + private readonly ILogger _logger; + private readonly string _botToken; + + public SlackChannel(SlackChannelConfig config, ILogger logger) + { + _config = config; + _logger = logger; + _http = HttpClientFactory.Create(); + + var tokenSource = SecretResolver.Resolve(config.BotTokenRef) ?? config.BotToken; + _botToken = tokenSource ?? throw new InvalidOperationException("Slack bot token not configured or missing from environment."); + } + + public string ChannelType => "slack"; + public string ChannelId => "slack"; +#pragma warning disable CS0067 // Event is never used + public event Func? OnMessageReceived; +#pragma warning restore CS0067 + + public Task StartAsync(CancellationToken ct) => Task.CompletedTask; + + public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(outbound.Text)) return; + + try + { + var (markers, remaining) = MediaMarkerProtocol.Extract(outbound.Text); + var text = ConvertToMrkdwn(string.IsNullOrWhiteSpace(remaining) ? outbound.Text : remaining); + + var payload = new SlackPostMessageRequest + { + Channel = outbound.RecipientId, + Text = text, + ThreadTs = outbound.ReplyToMessageId + }; + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _botToken); + request.Content = JsonContent.Create(payload, SlackJsonContext.Default.SlackPostMessageRequest); + + var response = await _http.SendAsync(request, ct); + + if ((int)response.StatusCode == 429) + { + var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); + _logger.LogWarning("Slack rate limited for {Channel}. Retry-After: {RetryAfter}s.", outbound.RecipientId, retryAfter.TotalSeconds); + return; + } + + response.EnsureSuccessStatusCode(); + + // Slack returns 200 with ok=false for application-level errors + var responseBody = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.False) + { + var error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "unknown"; + _logger.LogError("Slack API error sending to {Channel}: {Error}", outbound.RecipientId, error); + return; + } + + _logger.LogInformation("Sent Slack message to {Channel}", outbound.RecipientId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send Slack message to {Channel}", outbound.RecipientId); + } + } + + /// + /// Converts basic Markdown to Slack mrkdwn format. + /// + internal static string ConvertToMrkdwn(string markdown) + { + // Convert **bold** to *bold* + var result = BoldRegex().Replace(markdown, "*$1*"); + // Convert [text](url) to + result = LinkRegex().Replace(result, "<$2|$1>"); + return result; + } + + [GeneratedRegex(@"\*\*(.+?)\*\*")] + private static partial Regex BoldRegex(); + + [GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")] + private static partial Regex LinkRegex(); + + public ValueTask DisposeAsync() + { + _http.Dispose(); + return ValueTask.CompletedTask; + } +} + +public sealed class SlackPostMessageRequest +{ + [JsonPropertyName("channel")] + public required string Channel { get; set; } + + [JsonPropertyName("text")] + public required string Text { get; set; } + + [JsonPropertyName("thread_ts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ThreadTs { get; set; } +} + +public sealed class SlackEventWrapper +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("challenge")] + public string? Challenge { get; set; } + + [JsonPropertyName("team_id")] + public string? TeamId { get; set; } + + [JsonPropertyName("event")] + public SlackEvent? Event { get; set; } + + [JsonPropertyName("event_id")] + public string? EventId { get; set; } +} + +public sealed class SlackEvent +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("subtype")] + public string? Subtype { get; set; } + + [JsonPropertyName("user")] + public string? User { get; set; } + + [JsonPropertyName("bot_id")] + public string? BotId { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + [JsonPropertyName("channel_type")] + public string? ChannelType { get; set; } + + [JsonPropertyName("ts")] + public string? Ts { get; set; } + + [JsonPropertyName("thread_ts")] + public string? ThreadTs { get; set; } +} + +[JsonSerializable(typeof(SlackPostMessageRequest))] +[JsonSerializable(typeof(SlackEventWrapper))] +[JsonSerializable(typeof(SlackEvent))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public partial class SlackJsonContext : JsonSerializerContext; diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs new file mode 100644 index 0000000..977092e --- /dev/null +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -0,0 +1,457 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using OpenClaw.Core.Models; +using OpenClaw.Core.Plugins; + +namespace OpenClaw.Cli; + +/// +/// Built-in plugin management commands: install, remove, list, search. +/// Fetches plugins from npm (which also hosts ClawHub packages) and installs +/// them into the extensions directory for the plugin bridge to discover. +/// +internal static class PluginCommands +{ + private const string EnvWorkspace = "OPENCLAW_WORKSPACE"; + + public static async Task RunAsync(string[] args) + { + if (args.Length == 0 || args[0] is "-h" or "--help") + { + PrintHelp(); + return 0; + } + + var subcommand = args[0]; + var rest = args.Skip(1).ToArray(); + + return subcommand switch + { + "install" => await InstallAsync(rest), + "remove" or "uninstall" => await RemoveAsync(rest), + "list" or "ls" => ListInstalled(rest), + "search" => await SearchAsync(rest), + _ => UnknownSubcommand(subcommand) + }; + } + + private static async Task InstallAsync(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Usage: openclaw plugins install "); + return 2; + } + + var packageSpec = args[0]; + var global = args.Contains("--global") || args.Contains("-g"); + var extensionsDir = ResolveExtensionsDir(global); + + Directory.CreateDirectory(extensionsDir); + + // Check if it's a local path + if (Directory.Exists(packageSpec) || File.Exists(packageSpec)) + { + return await InstallFromLocalAsync(packageSpec, extensionsDir); + } + + // Install from npm/ClawHub + return await InstallFromNpmAsync(packageSpec, extensionsDir); + } + + private static async Task InstallFromNpmAsync(string packageSpec, string extensionsDir) + { + Console.WriteLine($"Installing {packageSpec} from npm..."); + + // Use npm pack to download the tarball, then extract into extensions dir + var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-install-{Guid.NewGuid():N}"[..24]); + Directory.CreateDirectory(tempDir); + + try + { + // Step 1: npm pack to download tarball + var packResult = await RunNpmAsync($"pack {packageSpec} --pack-destination {Quote(tempDir)}", tempDir); + if (packResult.ExitCode != 0) + { + Console.Error.WriteLine($"Failed to download package: {packResult.Stderr}"); + return 1; + } + + // Find the downloaded tarball + var tarballs = Directory.GetFiles(tempDir, "*.tgz"); + if (tarballs.Length == 0) + { + Console.Error.WriteLine("No tarball downloaded."); + return 1; + } + + var tarball = tarballs[0]; + + // Step 2: Extract tarball into a temp staging directory + var stagingDir = Path.Combine(tempDir, "staging"); + Directory.CreateDirectory(stagingDir); + + var extractResult = await RunProcessAsync("tar", $"xzf {Quote(tarball)} -C {Quote(stagingDir)}", tempDir); + if (extractResult.ExitCode != 0) + { + Console.Error.WriteLine($"Failed to extract package: {extractResult.Stderr}"); + return 1; + } + + // npm pack creates a 'package' directory inside the tarball + var packageDir = Path.Combine(stagingDir, "package"); + if (!Directory.Exists(packageDir)) + { + // Some tarballs use a different root + var dirs = Directory.GetDirectories(stagingDir); + packageDir = dirs.Length > 0 ? dirs[0] : stagingDir; + } + + // Step 3: Determine plugin name from manifest or package.json + var pluginName = ResolvePluginName(packageDir) ?? SanitizePackageName(packageSpec); + + // Step 4: Move to extensions directory + var targetDir = Path.Combine(extensionsDir, pluginName); + if (Directory.Exists(targetDir)) + { + Console.WriteLine($"Replacing existing plugin '{pluginName}'..."); + Directory.Delete(targetDir, recursive: true); + } + + CopyDirectory(packageDir, targetDir); + + // Step 5: Install npm dependencies if package.json exists + var packageJson = Path.Combine(targetDir, "package.json"); + if (File.Exists(packageJson)) + { + Console.WriteLine("Installing dependencies..."); + var npmInstall = await RunNpmAsync("install --production --no-optional", targetDir); + if (npmInstall.ExitCode != 0) + Console.Error.WriteLine($"Warning: npm install failed: {npmInstall.Stderr}"); + } + + Console.WriteLine($"Installed '{pluginName}' to {targetDir}"); + Console.WriteLine("Restart the gateway to load the plugin."); + return 0; + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ } + } + } + + private static async Task InstallFromLocalAsync(string localPath, string extensionsDir) + { + var sourcePath = Path.GetFullPath(localPath); + + if (File.Exists(sourcePath) && sourcePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) + { + // Extract tarball + var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-install-{Guid.NewGuid():N}"[..24]); + Directory.CreateDirectory(tempDir); + + try + { + var extractResult = await RunProcessAsync("tar", $"xzf {Quote(sourcePath)} -C {Quote(tempDir)}", tempDir); + if (extractResult.ExitCode != 0) + { + Console.Error.WriteLine($"Failed to extract: {extractResult.Stderr}"); + return 1; + } + + var packageDir = Path.Combine(tempDir, "package"); + if (!Directory.Exists(packageDir)) + { + var dirs = Directory.GetDirectories(tempDir); + packageDir = dirs.Length > 0 ? dirs[0] : tempDir; + } + + var pluginName = ResolvePluginName(packageDir) ?? Path.GetFileNameWithoutExtension(localPath); + var targetDir = Path.Combine(extensionsDir, pluginName); + if (Directory.Exists(targetDir)) + Directory.Delete(targetDir, recursive: true); + + CopyDirectory(packageDir, targetDir); + Console.WriteLine($"Installed '{pluginName}' from tarball to {targetDir}"); + return 0; + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ } + } + } + + if (Directory.Exists(sourcePath)) + { + var pluginName = ResolvePluginName(sourcePath) ?? Path.GetFileName(sourcePath); + var targetDir = Path.Combine(extensionsDir, pluginName); + if (Directory.Exists(targetDir)) + Directory.Delete(targetDir, recursive: true); + + CopyDirectory(sourcePath, targetDir); + Console.WriteLine($"Installed '{pluginName}' from local directory to {targetDir}"); + return 0; + } + + Console.Error.WriteLine($"Path not found: {localPath}"); + return 1; + } + + private static async Task RemoveAsync(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Usage: openclaw plugins remove "); + return 2; + } + + var pluginName = args[0]; + var global = args.Contains("--global") || args.Contains("-g"); + var extensionsDir = ResolveExtensionsDir(global); + + var targetDir = Path.Combine(extensionsDir, pluginName); + if (!Directory.Exists(targetDir)) + { + // Try sanitized name + targetDir = Path.Combine(extensionsDir, SanitizePackageName(pluginName)); + } + + if (!Directory.Exists(targetDir)) + { + Console.Error.WriteLine($"Plugin '{pluginName}' not found in {extensionsDir}"); + return 1; + } + + Directory.Delete(targetDir, recursive: true); + Console.WriteLine($"Removed '{pluginName}' from {extensionsDir}"); + Console.WriteLine("Restart the gateway to unload the plugin."); + return 0; + } + + private static int ListInstalled(string[] args) + { + var global = args.Contains("--global") || args.Contains("-g"); + var extensionsDir = ResolveExtensionsDir(global); + + if (!Directory.Exists(extensionsDir)) + { + Console.WriteLine("No plugins installed."); + return 0; + } + + var plugins = PluginDiscovery.Discover(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [extensionsDir] } + }); + + if (plugins.Count == 0) + { + Console.WriteLine("No plugins installed."); + return 0; + } + + Console.WriteLine($"Installed plugins ({plugins.Count}):"); + foreach (var plugin in plugins) + { + var name = plugin.Manifest.Name ?? plugin.Manifest.Id ?? Path.GetFileName(plugin.RootPath); + var version = plugin.Manifest.Version ?? "?"; + var desc = plugin.Manifest.Description ?? ""; + Console.WriteLine($" {name} ({version}) - {desc}"); + Console.WriteLine($" Path: {plugin.RootPath}"); + } + + return 0; + } + + private static async Task SearchAsync(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Usage: openclaw plugins search "); + return 2; + } + + var query = string.Join(' ', args); + Console.WriteLine($"Searching npm for '{query}'..."); + + var result = await RunNpmAsync($"search openclaw-plugin {query} --json", Directory.GetCurrentDirectory()); + if (result.ExitCode != 0) + { + // Fallback to non-JSON search + var textResult = await RunNpmAsync($"search openclaw {query}", Directory.GetCurrentDirectory()); + Console.WriteLine(textResult.Stdout); + return textResult.ExitCode; + } + + try + { + using var doc = JsonDocument.Parse(result.Stdout); + var packages = doc.RootElement; + if (packages.ValueKind != JsonValueKind.Array || packages.GetArrayLength() == 0) + { + Console.WriteLine("No packages found."); + return 0; + } + + Console.WriteLine($"Found {packages.GetArrayLength()} package(s):"); + foreach (var pkg in packages.EnumerateArray()) + { + var name = pkg.TryGetProperty("name", out var n) ? n.GetString() : "?"; + var desc = pkg.TryGetProperty("description", out var d) ? d.GetString() : ""; + var version = pkg.TryGetProperty("version", out var v) ? v.GetString() : ""; + Console.WriteLine($" {name}@{version} - {desc}"); + } + } + catch + { + Console.WriteLine(result.Stdout); + } + + return 0; + } + + // ── Helpers ────────────────────────────────────────────────── + + private static string ResolveExtensionsDir(bool global) + { + if (global) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".openclaw", "extensions"); + } + + var workspace = Environment.GetEnvironmentVariable(EnvWorkspace); + if (!string.IsNullOrWhiteSpace(workspace)) + return Path.Combine(workspace, ".openclaw", "extensions"); + + var home2 = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home2, ".openclaw", "extensions"); + } + + private static string? ResolvePluginName(string packageDir) + { + // Try manifest + var manifestPath = Path.Combine(packageDir, "openclaw.plugin.json"); + if (File.Exists(manifestPath)) + { + try + { + var json = File.ReadAllText(manifestPath); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String) + return id.GetString(); + if (doc.RootElement.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String) + return SanitizePackageName(name.GetString()!); + } + catch { /* fall through */ } + } + + // Try package.json + var packageJsonPath = Path.Combine(packageDir, "package.json"); + if (File.Exists(packageJsonPath)) + { + try + { + var json = File.ReadAllText(packageJsonPath); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String) + return SanitizePackageName(name.GetString()!); + } + catch { /* fall through */ } + } + + return null; + } + + private static string SanitizePackageName(string name) + { + // @scope/package → scope-package + return name.Replace('@', ' ').Replace('/', '-').Trim().Replace(' ', '-'); + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunNpmAsync(string arguments, string workingDirectory) + { + var npmCmd = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "npm.cmd" : "npm"; + return await RunProcessAsync(npmCmd, arguments, workingDirectory); + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunProcessAsync( + string fileName, string arguments, string workingDirectory) + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + process.Start(); + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + return (process.ExitCode, stdout, stderr); + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 2) + { + return (127, "", $"Command not found: {fileName}. Ensure npm is installed."); + } + } + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (var file in Directory.GetFiles(source)) + File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + + foreach (var dir in Directory.GetDirectories(source)) + { + var dirName = Path.GetFileName(dir); + if (dirName is "node_modules" or ".git") + continue; + CopyDirectory(dir, Path.Combine(destination, dirName)); + } + } + + private static string Quote(string path) + => path.Contains(' ') ? $"\"{path}\"" : path; + + private static int UnknownSubcommand(string subcommand) + { + Console.Error.WriteLine($"Unknown subcommand: {subcommand}"); + PrintHelp(); + return 2; + } + + private static void PrintHelp() + { + Console.WriteLine(""" + openclaw plugins — Manage OpenClaw plugins + + Usage: + openclaw plugins install Install a plugin from npm/ClawHub or local source + openclaw plugins remove Remove an installed plugin + openclaw plugins list List installed plugins + openclaw plugins search Search npm for OpenClaw plugins + + Options: + -g, --global Use global extensions directory (~/.openclaw/extensions) + + Examples: + openclaw plugins install @sliverp/qqbot + openclaw plugins install @opik/opik-openclaw + openclaw plugins install ./my-local-plugin + openclaw plugins install ./my-plugin.tgz + openclaw plugins remove qqbot + openclaw plugins list + openclaw plugins search openclaw dingtalk + """); + } +} diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs index 0a10479..bad66b5 100644 --- a/src/OpenClaw.Cli/Program.cs +++ b/src/OpenClaw.Cli/Program.cs @@ -35,6 +35,7 @@ public static async Task Main(string[] args) "migrate" => await MigrateAsync(rest), "heartbeat" => await HeartbeatAsync(rest), "admin" => await AdminAsync(rest), + "plugins" => await PluginCommands.RunAsync(rest), "clawhub" => await ClawHubCommand.RunAsync(rest), "version" or "--version" or "-v" => PrintVersion(), _ => UnknownCommand(command) @@ -115,6 +116,13 @@ openclaw admin posture openclaw admin approvals simulate --tool shell --args "{\"command\":\"pwd\"}" openclaw admin incident export + Plugin management: + openclaw plugins install Install from npm/ClawHub + openclaw plugins install ./local-plugin Install from local path + openclaw plugins remove Remove a plugin + openclaw plugins list List installed plugins + openclaw plugins search Search npm for plugins + ClawHub wrapper: # Forward --help to ClawHub itself: openclaw clawhub -- --help diff --git a/src/OpenClaw.Core/Models/GatewayConfig.cs b/src/OpenClaw.Core/Models/GatewayConfig.cs index ec1c284..5ab2b61 100644 --- a/src/OpenClaw.Core/Models/GatewayConfig.cs +++ b/src/OpenClaw.Core/Models/GatewayConfig.cs @@ -29,6 +29,10 @@ public sealed class GatewayConfig public ProfilesConfig Profiles { get; set; } = new(); public LearningConfig Learning { get; set; } = new(); public WebhooksConfig Webhooks { get; set; } = new(); + public RoutingConfig Routing { get; set; } = new(); + public TailscaleConfig Tailscale { get; set; } = new(); + public GmailPubSubConfig GmailPubSub { get; set; } = new(); + public MdnsConfig Mdns { get; set; } = new(); public string UsageFooter { get; set; } = "off"; // "off", "tokens", "full" public int MaxConcurrentSessions { get; set; } = 64; @@ -255,6 +259,9 @@ public sealed class ChannelsConfig public TelegramChannelConfig Telegram { get; set; } = new(); public WhatsAppChannelConfig WhatsApp { get; set; } = new(); public TeamsChannelConfig Teams { get; set; } = new(); + public SlackChannelConfig Slack { get; set; } = new(); + public DiscordChannelConfig Discord { get; set; } = new(); + public SignalChannelConfig Signal { get; set; } = new(); } public sealed class WhatsAppChannelConfig @@ -442,6 +449,60 @@ public sealed class TelegramChannelConfig public string WebhookSecretTokenRef { get; set; } = "env:TELEGRAM_WEBHOOK_SECRET"; } +public sealed class SlackChannelConfig +{ + public bool Enabled { get; set; } = false; + public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed + public string? BotToken { get; set; } + public string BotTokenRef { get; set; } = "env:SLACK_BOT_TOKEN"; + public string? SigningSecret { get; set; } + public string SigningSecretRef { get; set; } = "env:SLACK_SIGNING_SECRET"; + public string WebhookPath { get; set; } = "/slack/events"; + public string SlashCommandPath { get; set; } = "/slack/commands"; + public string[] AllowedWorkspaceIds { get; set; } = []; + public string[] AllowedFromUserIds { get; set; } = []; + public string[] AllowedChannelIds { get; set; } = []; + public int MaxInboundChars { get; set; } = 4096; + public int MaxRequestBytes { get; set; } = 64 * 1024; + public bool ValidateSignature { get; set; } = true; +} + +public sealed class DiscordChannelConfig +{ + public bool Enabled { get; set; } = false; + public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed + public string? BotToken { get; set; } + public string BotTokenRef { get; set; } = "env:DISCORD_BOT_TOKEN"; + public string? ApplicationId { get; set; } + public string ApplicationIdRef { get; set; } = "env:DISCORD_APPLICATION_ID"; + public string? PublicKey { get; set; } + public string PublicKeyRef { get; set; } = "env:DISCORD_PUBLIC_KEY"; + public string WebhookPath { get; set; } = "/discord/interactions"; + public string[] AllowedGuildIds { get; set; } = []; + public string[] AllowedFromUserIds { get; set; } = []; + public string[] AllowedChannelIds { get; set; } = []; + public int MaxInboundChars { get; set; } = 4096; + public int MaxRequestBytes { get; set; } = 64 * 1024; + public bool ValidateSignature { get; set; } = true; + public bool RegisterSlashCommands { get; set; } = true; + public string SlashCommandPrefix { get; set; } = "claw"; +} + +public sealed class SignalChannelConfig +{ + public bool Enabled { get; set; } = false; + public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed + public string Driver { get; set; } = "signald"; // "signald" or "signal_cli" + public string SocketPath { get; set; } = "/var/run/signald/signald.sock"; + public string? SignalCliPath { get; set; } + public string? AccountPhoneNumber { get; set; } + public string AccountPhoneNumberRef { get; set; } = "env:SIGNAL_PHONE_NUMBER"; + public string[] AllowedFromNumbers { get; set; } = []; + public int MaxInboundChars { get; set; } = 4096; + public bool NoContentLogging { get; set; } = false; + public bool TrustAllKeys { get; set; } = true; +} + public sealed class CronConfig { public bool Enabled { get; set; } = false; @@ -481,3 +542,59 @@ public sealed class WebhookEndpointConfig /// Maximum webhook body length in characters before truncation. Limits prompt injection surface. public int MaxBodyLength { get; set; } = 10_240; } + +// ── Multi-Agent Routing ───────────────────────────────────────── + +public sealed class RoutingConfig +{ + public bool Enabled { get; set; } = false; + public Dictionary Routes { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class AgentRouteConfig +{ + public string? ChannelId { get; set; } + public string? SenderId { get; set; } + public string? SystemPrompt { get; set; } + public string? ModelOverride { get; set; } + public string? PresetId { get; set; } + public string[] AllowedTools { get; set; } = []; +} + +// ── Tailscale ─────────────────────────────────────────────────── + +public sealed class TailscaleConfig +{ + public bool Enabled { get; set; } = false; + public string Mode { get; set; } = "off"; // "off", "serve", "funnel" + public int Port { get; set; } = 443; + public string? Hostname { get; set; } +} + +// ── Gmail Pub/Sub ─────────────────────────────────────────────── + +public sealed class GmailPubSubConfig +{ + public bool Enabled { get; set; } = false; + public string? CredentialsPath { get; set; } + public string CredentialsPathRef { get; set; } = "env:GOOGLE_APPLICATION_CREDENTIALS"; + public string? TopicName { get; set; } + public string? SubscriptionName { get; set; } + public string WebhookPath { get; set; } = "/gmail/push"; + public string? SessionId { get; set; } + public string Prompt { get; set; } = "A new email notification was received. Check inbox and triage."; + + /// Shared secret token for authenticating push requests. Set as a query param or header. + public string? WebhookSecret { get; set; } + public string WebhookSecretRef { get; set; } = "env:GMAIL_PUBSUB_SECRET"; +} + +// ── mDNS/Bonjour Discovery ───────────────────────────────────── + +public sealed class MdnsConfig +{ + public bool Enabled { get; set; } = false; + public string ServiceType { get; set; } = "_openclaw._tcp"; + public string? InstanceName { get; set; } + public int Port { get; set; } = 0; // 0 = use gateway port +} diff --git a/src/OpenClaw.Core/Models/Session.cs b/src/OpenClaw.Core/Models/Session.cs index 3829d04..9b8d582 100644 --- a/src/OpenClaw.Core/Models/Session.cs +++ b/src/OpenClaw.Core/Models/Session.cs @@ -24,6 +24,21 @@ public sealed class Session /// Optional model override for this specific session (set via /model command). public string? ModelOverride { get; set; } + /// Optional route-scoped system prompt appended by gateway routing before runtime execution. + public string? SystemPromptOverride { get; set; } + + /// Optional route-scoped tool preset that overrides the default preset resolution. + public string? RoutePresetId { get; set; } + + /// Optional route-scoped tool allowlist applied in addition to preset filtering. + public string[] RouteAllowedTools { get; set; } = []; + + /// Reasoning effort level for extended thinking (null/off, low, medium, high). Set via /think command. + public string? ReasoningEffort { get; set; } + + /// When true, shows tool calls and token counts in responses. Set via /verbose command. + public bool VerboseMode { get; set; } + /// Total input tokens consumed across all turns in this session. public long TotalInputTokens { get; set; } @@ -429,6 +444,14 @@ public sealed record ToolInvocation [JsonSerializable(typeof(ChannelAuthStatusItem))] [JsonSerializable(typeof(WhatsAppSetupRequest))] [JsonSerializable(typeof(WhatsAppSetupResponse))] +[JsonSerializable(typeof(SlackChannelConfig))] +[JsonSerializable(typeof(DiscordChannelConfig))] +[JsonSerializable(typeof(SignalChannelConfig))] +[JsonSerializable(typeof(RoutingConfig))] +[JsonSerializable(typeof(AgentRouteConfig))] +[JsonSerializable(typeof(TailscaleConfig))] +[JsonSerializable(typeof(GmailPubSubConfig))] +[JsonSerializable(typeof(MdnsConfig))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs b/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs index dfc6d6e..838b1d5 100644 --- a/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs +++ b/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs @@ -24,17 +24,27 @@ public sealed class ChatCommandProcessor "/reset", "/model", "/usage", + "/think", + "/compact", + "/verbose", "/help" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); private readonly SessionManager _sessionManager; private readonly ConcurrentDictionary>> _dynamicCommands = new(StringComparer.OrdinalIgnoreCase); + private Func>? _compactCallback; public ChatCommandProcessor(SessionManager sessionManager) { _sessionManager = sessionManager; } + /// + /// Sets the callback for LLM-powered history compaction (injected from gateway setup). + /// + public void SetCompactCallback(Func> callback) + => _compactCallback = callback; + /// /// Registers a dynamic command handler (e.g. from a plugin). /// @@ -95,8 +105,61 @@ public DynamicCommandRegistrationResult RegisterDynamic(string command, Func 0) + session.History.RemoveRange(0, removeCount); + await _sessionManager.PersistAsync(session, ct); + return (true, $"Trimmed: {turnsBefore} turns → {session.History.Count} turns (kept last {keepRecent})."); + + case "/verbose": + if (string.IsNullOrWhiteSpace(args)) + return (true, $"Verbose mode: {(session.VerboseMode ? "on" : "off")}\nUsage: /verbose on|off"); + + if (args.Equals("on", StringComparison.OrdinalIgnoreCase)) + { + session.VerboseMode = true; + await _sessionManager.PersistAsync(session, ct); + return (true, "Verbose mode enabled. Tool calls and token counts will be shown."); + } + if (args.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + session.VerboseMode = false; + await _sessionManager.PersistAsync(session, ct); + return (true, "Verbose mode disabled."); + } + return (true, "Usage: /verbose on|off"); + case "/help": - return (true, "Available commands:\n/status - Show session details\n/new (or /reset) - Clear conversation history\n/model - Override the LLM model for this session\n/model reset - Clear model override\n/usage - Show token counts\n/help - Show this message"); + return (true, "Available commands:\n/status - Show session details\n/new (or /reset) - Clear conversation history\n/model - Override the LLM model for this session\n/model reset - Clear model override\n/usage - Show token counts\n/think - Set reasoning effort (off/low/medium/high)\n/compact - Compact conversation history\n/verbose on|off - Toggle verbose output\n/help - Show this message"); default: if (_dynamicCommands.TryGetValue(command, out var dynamicHandler)) diff --git a/src/OpenClaw.Core/Validation/ConfigValidator.cs b/src/OpenClaw.Core/Validation/ConfigValidator.cs index bf0e822..a1789ce 100644 --- a/src/OpenClaw.Core/Validation/ConfigValidator.cs +++ b/src/OpenClaw.Core/Validation/ConfigValidator.cs @@ -288,6 +288,9 @@ public static IReadOnlyList Validate(Models.GatewayConfig config) ValidateDmPolicy("Channels.Telegram.DmPolicy", config.Channels.Telegram.DmPolicy, errors); ValidateDmPolicy("Channels.WhatsApp.DmPolicy", config.Channels.WhatsApp.DmPolicy, errors); ValidateDmPolicy("Channels.Teams.DmPolicy", config.Channels.Teams.DmPolicy, errors); + ValidateDmPolicy("Channels.Slack.DmPolicy", config.Channels.Slack.DmPolicy, errors); + ValidateDmPolicy("Channels.Discord.DmPolicy", config.Channels.Discord.DmPolicy, errors); + ValidateDmPolicy("Channels.Signal.DmPolicy", config.Channels.Signal.DmPolicy, errors); // Cron if (config.Cron.Enabled) diff --git a/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs index a692e78..73b0951 100644 --- a/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs @@ -53,6 +53,32 @@ public static IServiceCollection AddOpenClawChannelServices(this IServiceCollect sp.GetRequiredService>())); } + if (config.Channels.Slack.Enabled) + { + services.AddSingleton(config.Channels.Slack); + services.AddSingleton(sp => + new SlackWebhookHandler( + config.Channels.Slack, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddSingleton(); + } + + if (config.Channels.Discord.Enabled) + { + services.AddSingleton(config.Channels.Discord); + services.AddSingleton(); + services.AddSingleton(); + } + + if (config.Channels.Signal.Enabled) + { + services.AddSingleton(config.Channels.Signal); + services.AddSingleton(); + } + return services; } } diff --git a/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs index c12b782..c2c7533 100644 --- a/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs @@ -22,6 +22,7 @@ public static IServiceCollection AddOpenClawCoreServices(this IServiceCollection var config = startup.Config; services.AddSingleton(config); + services.AddSingleton(config.Learning); services.AddSingleton(typeof(AllowlistSemantics), AllowlistPolicy.ParseSemantics(config.Channels.AllowlistSemantics)); services.AddSingleton(sp => new RecentSendersStore(config.Memory.StoragePath, sp.GetRequiredService>())); diff --git a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs index 581f66c..29b34cf 100644 --- a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs @@ -119,6 +119,18 @@ public static async Task InitializeOpenClawRuntimeAsync( effectiveApprovalRequiredTools, services.ToolSandbox); + // Wire compact callback so /compact command can trigger LLM-powered compaction + if (agentRuntime is AgentRuntime concreteRuntime) + { + services.CommandProcessor.SetCompactCallback(async (session, ct) => + { + var countBefore = session.History.Count; + await concreteRuntime.CompactHistoryAsync(session, ct); + var countAfter = session.History.Count; + return countAfter; // Return actual remaining turn count + }); + } + var middlewarePipeline = CreateMiddlewarePipeline(config, loggerFactory, services.ContractGovernance, services.SessionManager); var skillWatcher = new SkillWatcherService( config.Skills, @@ -153,6 +165,28 @@ public static async Task InitializeOpenClawRuntimeAsync( pluginComposition.NativeDynamicPluginHost); await profile.OnRuntimeInitializedAsync(app, startup, runtime); + + // Start integration services + if (config.Tailscale.Enabled) + { + var tailscale = new Integrations.TailscaleService( + config.Tailscale, + config.Port, + loggerFactory.CreateLogger()); + _ = tailscale.StartAsync(app.Lifetime.ApplicationStopping); + app.Lifetime.ApplicationStopping.Register(() => tailscale.DisposeAsync().AsTask().GetAwaiter().GetResult()); + } + + if (config.Mdns.Enabled) + { + var mdns = new Integrations.MdnsDiscoveryService( + config.Mdns, + config.Port, + loggerFactory.CreateLogger()); + mdns.Start(app.Lifetime.ApplicationStopping); + app.Lifetime.ApplicationStopping.Register(() => mdns.DisposeAsync().AsTask().GetAwaiter().GetResult()); + } + return runtime; } @@ -225,6 +259,15 @@ private static async Task BuildChannelCompositionAsync( if (config.Channels.Teams.Enabled) channelAdapters["teams"] = app.Services.GetRequiredService(); + if (config.Channels.Slack.Enabled) + channelAdapters["slack"] = app.Services.GetRequiredService(); + + if (config.Channels.Discord.Enabled) + channelAdapters["discord"] = app.Services.GetRequiredService(); + + if (config.Channels.Signal.Enabled) + channelAdapters["signal"] = app.Services.GetRequiredService(); + var whatsAppWorkerHost = await CreateWhatsAppChannelAsync(app, startup, services, loggerFactory, channelAdapters); if (config.Plugins.Native.Email.Enabled) @@ -452,7 +495,29 @@ private static IReadOnlyList CreateBuiltInTools( new TodoTool(services.SessionMetadataStore), new AutomationTool(services.AutomationService, services.Pipeline), new VisionAnalyzeTool(services.GeminiMultimodalService), - new TextToSpeechTool(services.TextToSpeechService) + new TextToSpeechTool(services.TextToSpeechService), + + // Core dev tools + new EditFileTool(config.Tooling), + new ApplyPatchTool(config.Tooling), + + // Session management tools + new SessionsHistoryTool(services.SessionManager, services.MemoryStore), + new SessionsSendTool(services.SessionManager, services.Pipeline), + new SessionsSpawnTool(services.SessionManager, services.Pipeline), + new SessionStatusTool(services.SessionManager), + new AgentsListTool(config.Delegation), + + // System management tools + new CronTool(services.CronJobSource, services.Pipeline), + new GatewayTool(services.RuntimeMetrics, services.SessionManager, config), + + // Communication & data tools + new MessageTool(services.Pipeline), + new XSearchTool(), + new MemoryGetTool(services.MemoryStore), + new ProfileWriteTool(services.UserProfileStore), + new SessionsYieldTool(services.SessionManager, services.Pipeline, services.MemoryStore), }; if (config.Tooling.EnableBrowserTool) diff --git a/src/OpenClaw.Gateway/DiscordWebhookHandler.cs b/src/OpenClaw.Gateway/DiscordWebhookHandler.cs new file mode 100644 index 0000000..bab71ea --- /dev/null +++ b/src/OpenClaw.Gateway/DiscordWebhookHandler.cs @@ -0,0 +1,184 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using OpenClaw.Channels; +using OpenClaw.Core.Models; +using OpenClaw.Core.Security; + +namespace OpenClaw.Gateway; + +/// +/// Handles Discord Interaction endpoint webhooks (slash commands). +/// Regular messages arrive via the Gateway WebSocket in DiscordChannel. +/// +internal sealed class DiscordWebhookHandler +{ + private readonly DiscordChannelConfig _config; + private readonly byte[]? _publicKeyBytes; + private readonly ILogger _logger; + + public DiscordWebhookHandler( + DiscordChannelConfig config, + ILogger logger) + { + _config = config; + _logger = logger; + + var publicKeyHex = SecretResolver.Resolve(config.PublicKeyRef) ?? config.PublicKey; + if (!string.IsNullOrWhiteSpace(publicKeyHex)) + _publicKeyBytes = Convert.FromHexString(publicKeyHex); + + if (config.ValidateSignature && !Ed25519Verify.IsSupported) + logger.LogWarning("Discord signature validation is enabled but Ed25519 is not supported on this platform. " + + "Signature checks will reject all requests. Disable ValidateSignature or add an Ed25519 provider."); + } + + public readonly record struct WebhookResponse(int StatusCode, string? Body = null, string ContentType = "application/json"); + + /// + /// Handles an inbound Discord interaction webhook. + /// + public async ValueTask HandleAsync( + string bodyText, + string? signatureHeader, + string? timestampHeader, + Func enqueue, + CancellationToken ct) + { + // Validate Ed25519 signature + if (_config.ValidateSignature) + { + if (!ValidateSignature(bodyText, signatureHeader, timestampHeader)) + { + _logger.LogWarning("Rejected Discord interaction due to invalid signature."); + return new WebhookResponse(401, "invalid request signature"); + } + } + + using var doc = JsonDocument.Parse(bodyText); + var root = doc.RootElement; + + var type = root.TryGetProperty("type", out var typeProp) ? typeProp.GetInt32() : 0; + + // Type 1: Ping — required for Discord endpoint verification + if (type == 1) + return new WebhookResponse(200, """{"type":1}"""); + + // Type 2: Application Command + if (type == 2) + { + var interaction = JsonSerializer.Deserialize(bodyText, DiscordJsonContext.Default.DiscordInteraction); + if (interaction is null) + return new WebhookResponse(400, """{"error":"invalid interaction"}"""); + + var userId = interaction.Member?.User?.Id ?? interaction.User?.Id; + var username = interaction.Member?.User?.Username ?? interaction.User?.Username; + var guildId = interaction.GuildId; + var channelId = interaction.ChannelId; + + if (string.IsNullOrWhiteSpace(userId)) + return new WebhookResponse(400, """{"error":"missing user"}"""); + + if (_config.AllowedGuildIds.Length > 0 && !string.IsNullOrWhiteSpace(guildId)) + { + if (!Array.Exists(_config.AllowedGuildIds, id => string.Equals(id, guildId, StringComparison.Ordinal))) + return new WebhookResponse(403, """{"error":"guild not allowed"}"""); + } + + if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channelId)) + { + if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channelId, StringComparison.Ordinal))) + return new WebhookResponse(403, """{"error":"channel not allowed"}"""); + } + + if (_config.AllowedFromUserIds.Length > 0) + { + if (!Array.Exists(_config.AllowedFromUserIds, id => string.Equals(id, userId, StringComparison.Ordinal))) + return new WebhookResponse(403, """{"error":"user not allowed"}"""); + } + + // Extract command text from options + var text = ""; + if (interaction.Data?.Options is { Length: > 0 } options) + { + foreach (var opt in options) + { + if (string.Equals(opt.Name, "message", StringComparison.Ordinal) && opt.Value.HasValue) + { + text = opt.Value.Value.GetString() ?? ""; + break; + } + } + } + + if (string.IsNullOrWhiteSpace(text)) + text = $"/{interaction.Data?.Name ?? "claw"}"; + + if (text.Length > _config.MaxInboundChars) + text = text[.._config.MaxInboundChars]; + + var isDm = guildId is null; + string? sessionId = null; + if (!isDm && channelId is not null) + sessionId = $"discord:{channelId}:{userId}"; + + var message = new InboundMessage + { + ChannelId = "discord", + SenderId = userId, + SenderName = username, + SessionId = sessionId, + Text = text, + MessageId = interaction.Id, + IsGroup = !isDm, + GroupId = guildId, + }; + + await enqueue(message, ct); + + // Respond with deferred message (type 5) so the user sees "thinking..." + return new WebhookResponse(200, """{"type":5}"""); + } + + return new WebhookResponse(200, """{"type":1}"""); + } + + /// + /// Validates the Discord Ed25519 signature. + /// The signed message is: timestamp + body. + /// Uses BouncyCastle Ed25519 verification. + /// + private bool ValidateSignature(string body, string? signature, string? timestamp) + { + if (_publicKeyBytes is null || _publicKeyBytes.Length != 32 || + string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(timestamp)) + return false; + + // Reject stale timestamps (5-minute window) to prevent replay attacks + if (long.TryParse(timestamp, out var ts)) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (Math.Abs(now - ts) > 300) + { + _logger.LogWarning("Rejected Discord interaction with stale timestamp ({Timestamp}).", timestamp); + return false; + } + } + + try + { + var signatureBytes = Convert.FromHexString(signature); + if (signatureBytes.Length != 64) + return false; + + var message = Encoding.UTF8.GetBytes(timestamp + body); + return Ed25519Verify.Verify(signatureBytes, message, _publicKeyBytes); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Discord Ed25519 signature verification failed."); + return false; + } + } +} diff --git a/src/OpenClaw.Gateway/Ed25519Verify.cs b/src/OpenClaw.Gateway/Ed25519Verify.cs new file mode 100644 index 0000000..f399ef2 --- /dev/null +++ b/src/OpenClaw.Gateway/Ed25519Verify.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; + +namespace OpenClaw.Gateway; + +/// +/// Ed25519 signature verification for Discord interaction webhooks. +/// Uses BouncyCastle so verification is correct and consistent across runtime targets. +/// +internal static class Ed25519Verify +{ + /// + /// Verifies an Ed25519 signature. + /// Returns false if verification fails. + /// + public static bool Verify(ReadOnlySpan signature, ReadOnlySpan message, ReadOnlySpan publicKey) + { + if (signature.Length != 64 || publicKey.Length != 32) + return false; + + try + { + var verifier = new Ed25519Signer(); + verifier.Init(forSigning: false, new Ed25519PublicKeyParameters(publicKey.ToArray())); + var payload = message.ToArray(); + verifier.BlockUpdate(payload, 0, payload.Length); + return verifier.VerifySignature(signature.ToArray()); + } + catch + { + return false; + } + } + + /// + /// Returns true if the runtime supports Ed25519 verification. + /// + public static bool IsSupported => true; +} diff --git a/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs index 203f001..e56cd98 100644 --- a/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs +++ b/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs @@ -7,6 +7,7 @@ using OpenClaw.Gateway; using OpenClaw.Gateway.Bootstrap; using OpenClaw.Gateway.Composition; +using OpenClaw.Gateway.Integrations; namespace OpenClaw.Gateway.Endpoints; @@ -369,6 +370,229 @@ public static void MapOpenClawWebhookEndpoints( }); } + if (startup.Config.Channels.Discord.Enabled) + { + var discordHandler = app.Services.GetRequiredService(); + + app.MapPost(startup.Config.Channels.Discord.WebhookPath, async (HttpContext ctx) => + { + var maxRequestSize = Math.Max(4 * 1024, startup.Config.Channels.Discord.MaxRequestBytes); + var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, maxRequestSize, ctx.RequestAborted); + if (!bodyOk) + { + ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted); + return; + } + + var signature = ctx.Request.Headers["X-Signature-Ed25519"].ToString(); + var timestamp = ctx.Request.Headers["X-Signature-Timestamp"].ToString(); + + InboundMessage? replayMessage = null; + try + { + var result = await discordHandler.HandleAsync( + bodyText, + signature, + timestamp, + (msg, ct) => + { + replayMessage = msg; + return runtime.Pipeline.InboundWriter.WriteAsync(msg, ct); + }, + ctx.RequestAborted); + + ctx.Response.StatusCode = result.StatusCode; + ctx.Response.ContentType = result.ContentType; + if (result.Body is not null) + await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted); + } + catch (Exception ex) + { + deliveries.RecordDeadLetter(new WebhookDeadLetterRecord + { + Entry = new WebhookDeadLetterEntry + { + Id = $"whdl_{Guid.NewGuid():N}"[..20], + Source = "discord", + DeliveryKey = "", + ChannelId = "discord", + SenderId = replayMessage?.SenderId, + SessionId = replayMessage?.SessionId, + Error = ex.Message, + PayloadPreview = bodyText.Length <= 500 ? bodyText : bodyText[..500] + "…" + }, + ReplayMessage = replayMessage + }); + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + await ctx.Response.WriteAsync("Webhook processing failed.", ctx.RequestAborted); + } + }); + } + + if (startup.Config.Channels.Slack.Enabled) + { + var slackHandler = app.Services.GetRequiredService(); + + app.MapPost(startup.Config.Channels.Slack.WebhookPath, async (HttpContext ctx) => + { + var maxRequestSize = Math.Max(4 * 1024, startup.Config.Channels.Slack.MaxRequestBytes); + var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, maxRequestSize, ctx.RequestAborted); + if (!bodyOk) + { + ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted); + return; + } + + var timestamp = ctx.Request.Headers["X-Slack-Request-Timestamp"].ToString(); + var signature = ctx.Request.Headers["X-Slack-Signature"].ToString(); + + // Check for url_verification before dedup (challenge must always respond) + if (bodyText.Contains("\"url_verification\"", StringComparison.Ordinal)) + { + var result = await slackHandler.HandleEventAsync(bodyText, timestamp, signature, (_, _) => ValueTask.CompletedTask, ctx.RequestAborted); + ctx.Response.StatusCode = result.StatusCode; + ctx.Response.ContentType = result.ContentType; + if (result.Body is not null) + await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted); + return; + } + + // Use event_id from payload for stable dedup (falls back to timestamp+prefix) + string deliveryKey; + try + { + using var dedupDoc = System.Text.Json.JsonDocument.Parse(bodyText); + var dedupRoot = dedupDoc.RootElement; + if (dedupRoot.TryGetProperty("event_id", out var eventIdProp) && + eventIdProp.ValueKind == System.Text.Json.JsonValueKind.String) + deliveryKey = eventIdProp.GetString()!; + else + deliveryKey = timestamp + ":" + (bodyText.Length > 64 ? bodyText[..64] : bodyText); + } + catch + { + deliveryKey = timestamp + ":" + (bodyText.Length > 64 ? bodyText[..64] : bodyText); + } + var hashedKey = WebhookDeliveryStore.HashDeliveryKey(deliveryKey); + if (!deliveries.TryBegin("slack", hashedKey, TimeSpan.FromHours(6))) + { + ctx.Response.StatusCode = StatusCodes.Status200OK; + await ctx.Response.WriteAsync("Duplicate ignored.", ctx.RequestAborted); + return; + } + + InboundMessage? replayMessage = null; + try + { + var result = await slackHandler.HandleEventAsync( + bodyText, + timestamp, + signature, + (msg, ct) => + { + replayMessage = msg; + return runtime.Pipeline.InboundWriter.WriteAsync(msg, ct); + }, + ctx.RequestAborted); + + ctx.Response.StatusCode = result.StatusCode; + ctx.Response.ContentType = result.ContentType; + if (result.Body is not null) + await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted); + } + catch (Exception ex) + { + deliveries.RecordDeadLetter(new WebhookDeadLetterRecord + { + Entry = new WebhookDeadLetterEntry + { + Id = $"whdl_{Guid.NewGuid():N}"[..20], + Source = "slack", + DeliveryKey = hashedKey, + ChannelId = "slack", + SenderId = replayMessage?.SenderId, + SessionId = replayMessage?.SessionId, + Error = ex.Message, + PayloadPreview = bodyText.Length <= 500 ? bodyText : bodyText[..500] + "…" + }, + ReplayMessage = replayMessage + }); + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + await ctx.Response.WriteAsync("Webhook processing failed.", ctx.RequestAborted); + } + }); + + app.MapPost(startup.Config.Channels.Slack.SlashCommandPath, async (HttpContext ctx) => + { + if (!ctx.Request.HasFormContentType) + { + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + await ctx.Response.WriteAsync("Expected form content.", ctx.RequestAborted); + return; + } + + var maxRequestSize = Math.Max(4 * 1024, startup.Config.Channels.Slack.MaxRequestBytes); + var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, maxRequestSize, ctx.RequestAborted); + if (!bodyOk) + { + ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted); + return; + } + + var timestamp = ctx.Request.Headers["X-Slack-Request-Timestamp"].ToString(); + var signature = ctx.Request.Headers["X-Slack-Signature"].ToString(); + + var parsed = QueryHelpers.ParseQuery(bodyText); + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var kvp in parsed) + dict[kvp.Key] = kvp.Value.ToString(); + + InboundMessage? replayMessage = null; + try + { + var result = await slackHandler.HandleSlashCommandAsync( + dict, + timestamp, + signature, + bodyText, + (msg, ct) => + { + replayMessage = msg; + return runtime.Pipeline.InboundWriter.WriteAsync(msg, ct); + }, + ctx.RequestAborted); + + ctx.Response.StatusCode = result.StatusCode; + ctx.Response.ContentType = result.ContentType; + if (result.Body is not null) + await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted); + } + catch (Exception ex) + { + deliveries.RecordDeadLetter(new WebhookDeadLetterRecord + { + Entry = new WebhookDeadLetterEntry + { + Id = $"whdl_{Guid.NewGuid():N}"[..20], + Source = "slack_command", + DeliveryKey = "", + ChannelId = "slack", + SenderId = replayMessage?.SenderId, + SessionId = replayMessage?.SessionId, + Error = ex.Message, + PayloadPreview = bodyText.Length <= 500 ? bodyText : bodyText[..500] + "…" + }, + ReplayMessage = replayMessage + }); + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + await ctx.Response.WriteAsync("Webhook processing failed.", ctx.RequestAborted); + } + }); + } + if (startup.Config.Webhooks.Enabled) { app.MapPost("/webhooks/{name}", async (HttpContext ctx, string name) => @@ -459,6 +683,46 @@ public static void MapOpenClawWebhookEndpoints( } }); } + + if (startup.Config.GmailPubSub.Enabled) + { + var gmailBridge = new GmailPubSubBridge( + startup.Config.GmailPubSub, + runtime.Pipeline, + app.Services.GetRequiredService>()); + + var gmailSecret = SecretResolver.Resolve(startup.Config.GmailPubSub.WebhookSecretRef) + ?? startup.Config.GmailPubSub.WebhookSecret; + + app.MapPost(startup.Config.GmailPubSub.WebhookPath, async (HttpContext ctx) => + { + // Verify shared secret if configured + if (!string.IsNullOrWhiteSpace(gmailSecret)) + { + var providedToken = ctx.Request.Query["token"].ToString(); + if (string.IsNullOrWhiteSpace(providedToken)) + providedToken = ctx.Request.Headers["X-OpenClaw-Secret"].ToString(); + + if (!string.Equals(providedToken, gmailSecret, StringComparison.Ordinal)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + + var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, 64 * 1024, ctx.RequestAborted); + if (!bodyOk) + { + ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted); + return; + } + + var (status, body) = await gmailBridge.HandlePushAsync(bodyText, ctx.RequestAborted); + ctx.Response.StatusCode = status; + await ctx.Response.WriteAsync(body, ctx.RequestAborted); + }); + } } private static string TryResolveWhatsAppDeliveryKey(string bodyText) diff --git a/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs b/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs index bb9b9bd..bc530ce 100644 --- a/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs +++ b/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs @@ -184,6 +184,10 @@ private static void StartInboundWorkers( LearningService? learningService, GatewayAutomationService? automationService) { + var routeResolver = config.Routing.Enabled + ? new OpenClaw.Gateway.Integrations.AgentRouteResolver(config.Routing) + : null; + for (var i = 0; i < workerCount; i++) { _ = Task.Run(async () => @@ -370,6 +374,9 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage if (msg.ChannelId == "telegram") policy = config.Channels.Telegram.DmPolicy; if (msg.ChannelId == "whatsapp") policy = config.Channels.WhatsApp.DmPolicy; if (msg.ChannelId == "teams") policy = config.Channels.Teams.DmPolicy; + if (msg.ChannelId == "slack") policy = config.Channels.Slack.DmPolicy; + if (msg.ChannelId == "discord") policy = config.Channels.Discord.DmPolicy; + if (msg.ChannelId == "signal") policy = config.Channels.Signal.DmPolicy; if (policy is "closed") continue; // Silently drop all inbound messages @@ -390,12 +397,40 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage continue; // Drop the inbound request after sending pairing code } + // ── Multi-Agent Route Resolution ───────────────── + var resolvedRoute = routeResolver?.Resolve(msg.ChannelId, msg.SenderId); + session = msg.SessionId is not null ? await sessionManager.GetOrCreateByIdAsync(msg.SessionId, msg.ChannelId, conversationRecipientId, lifetime.ApplicationStopping) : await sessionManager.GetOrCreateAsync(msg.ChannelId, conversationRecipientId, lifetime.ApplicationStopping); if (session is null) throw new InvalidOperationException("Session manager returned null session."); + // Apply route overrides to session + if (resolvedRoute is not null) + { + session.ModelOverride = string.IsNullOrWhiteSpace(resolvedRoute.ModelOverride) + ? session.ModelOverride + : resolvedRoute.ModelOverride.Trim(); + session.SystemPromptOverride = string.IsNullOrWhiteSpace(resolvedRoute.SystemPrompt) + ? null + : resolvedRoute.SystemPrompt.Trim(); + session.RoutePresetId = string.IsNullOrWhiteSpace(resolvedRoute.PresetId) + ? null + : resolvedRoute.PresetId.Trim(); + session.RouteAllowedTools = resolvedRoute.AllowedTools + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + else + { + session.SystemPromptOverride = null; + session.RoutePresetId = null; + session.RouteAllowedTools = []; + } + initialInputTokens = session.TotalInputTokens; initialOutputTokens = session.TotalOutputTokens; @@ -575,9 +610,16 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage { await wsChannel.SendStreamEventAsync(msg.SenderId, "typing_start", "", msg.MessageId, lifetime.ApplicationStopping); + AgentStreamEvent? doneEvent = null; await foreach (var evt in agentRuntime.RunStreamingAsync( session, messageText, lifetime.ApplicationStopping, approvalCallback: ApprovalCallback)) { + if (string.Equals(evt.EnvelopeType, "assistant_done", StringComparison.Ordinal)) + { + doneEvent = evt; + continue; + } + await wsChannel.SendStreamEventAsync( msg.SenderId, evt.EnvelopeType, evt.Content, msg.MessageId, lifetime.ApplicationStopping); @@ -585,6 +627,35 @@ await wsChannel.SendStreamEventAsync( await sessionManager.PersistAsync(session, lifetime.ApplicationStopping); if (learningService is not null) await learningService.ObserveSessionAsync(session, lifetime.ApplicationStopping); + + // Send verbose footer via stream for streaming sessions + if (session.VerboseMode) + { + var streamInputDelta = session.TotalInputTokens - initialInputTokens; + var streamOutputDelta = session.TotalOutputTokens - initialOutputTokens; + var streamToolCalls = 0; + for (var ti = session.History.Count - 1; ti >= 0; ti--) + { + var turn = session.History[ti]; + if (turn.ToolCalls is { Count: > 0 }) + streamToolCalls += turn.ToolCalls.Count; + if (string.Equals(turn.Role, "user", StringComparison.Ordinal)) + break; + } + var verboseFooter = $"\n\n---\n{streamToolCalls} tool call(s) | {streamInputDelta} in / {streamOutputDelta} out tokens (this turn)"; + await wsChannel.SendStreamEventAsync(msg.SenderId, "text_delta", verboseFooter, msg.MessageId, lifetime.ApplicationStopping); + } + + if (doneEvent is AgentStreamEvent completedEvent) + { + await wsChannel.SendStreamEventAsync( + msg.SenderId, + completedEvent.EnvelopeType, + completedEvent.Content, + msg.MessageId, + lifetime.ApplicationStopping); + } + await wsChannel.SendStreamEventAsync(msg.SenderId, "typing_stop", "", msg.MessageId, lifetime.ApplicationStopping); } else @@ -618,6 +689,23 @@ await wsChannel.SendStreamEventAsync( if (heartbeatService.IsManagedHeartbeatJob(msg.CronJobName)) heartbeatService.RecordResult(session, responseText, suppressHeartbeatDelivery, inputTokenDelta, outputTokenDelta); + // Append verbose mode footer (tool calls and token delta) + if (session.VerboseMode) + { + // Tool calls may be spread across multiple turns added during this run + var turnToolCalls = 0; + for (var ti = session.History.Count - 1; ti >= 0; ti--) + { + var turn = session.History[ti]; + if (turn.ToolCalls is { Count: > 0 }) + turnToolCalls += turn.ToolCalls.Count; + // Stop once we reach a user turn (start of this interaction) + if (string.Equals(turn.Role, "user", StringComparison.Ordinal)) + break; + } + responseText += $"\n\n---\n{turnToolCalls} tool call(s) | {inputTokenDelta} in / {outputTokenDelta} out tokens (this turn)"; + } + // Append Usage Tracking string if configured if (config.UsageFooter is "tokens") responseText += $"\n\n---\n↑ {session.TotalInputTokens} in / {session.TotalOutputTokens} out tokens"; diff --git a/src/OpenClaw.Gateway/Integrations/AgentRouteResolver.cs b/src/OpenClaw.Gateway/Integrations/AgentRouteResolver.cs new file mode 100644 index 0000000..3c432da --- /dev/null +++ b/src/OpenClaw.Gateway/Integrations/AgentRouteResolver.cs @@ -0,0 +1,63 @@ +using OpenClaw.Core.Models; + +namespace OpenClaw.Gateway.Integrations; + +/// +/// Resolves agent routing for inbound messages based on channel/sender matching. +/// +internal sealed class AgentRouteResolver +{ + private readonly RoutingConfig _config; + + public AgentRouteResolver(RoutingConfig config) + { + _config = config; + } + + /// + /// Resolves a route for the given channel and sender. + /// Returns null if no route matches or routing is disabled. + /// + public AgentRouteConfig? Resolve(string channelId, string senderId) + { + if (!_config.Enabled || _config.Routes.Count == 0) + return null; + + // Try exact match: channel:sender + var exactKey = $"{channelId}:{senderId}"; + if (_config.Routes.TryGetValue(exactKey, out var exactRoute)) + return exactRoute; + + // Try channel-only match + foreach (var (_, route) in _config.Routes) + { + if (route.ChannelId is null) + continue; + + if (!string.Equals(route.ChannelId, channelId, StringComparison.OrdinalIgnoreCase)) + continue; + + // If sender filter specified, must match + if (route.SenderId is not null && + !string.Equals(route.SenderId, senderId, StringComparison.OrdinalIgnoreCase)) + continue; + + return route; + } + + // Try wildcard routes (no channel filter) + foreach (var (_, route) in _config.Routes) + { + if (route.ChannelId is not null) + continue; + + if (route.SenderId is not null && + !string.Equals(route.SenderId, senderId, StringComparison.OrdinalIgnoreCase)) + continue; + + return route; + } + + return null; + } +} diff --git a/src/OpenClaw.Gateway/Integrations/GmailPubSubBridge.cs b/src/OpenClaw.Gateway/Integrations/GmailPubSubBridge.cs new file mode 100644 index 0000000..615c244 --- /dev/null +++ b/src/OpenClaw.Gateway/Integrations/GmailPubSubBridge.cs @@ -0,0 +1,89 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; + +namespace OpenClaw.Gateway.Integrations; + +/// +/// Handles Gmail Pub/Sub push notifications and injects messages into the pipeline. +/// Google Pub/Sub sends a POST with a base64-encoded notification when new emails arrive. +/// +internal sealed class GmailPubSubBridge +{ + private readonly GmailPubSubConfig _config; + private readonly MessagePipeline _pipeline; + private readonly ILogger _logger; + + public GmailPubSubBridge(GmailPubSubConfig config, MessagePipeline pipeline, ILogger logger) + { + _config = config; + _pipeline = pipeline; + _logger = logger; + } + + /// + /// Handles the incoming Pub/Sub push notification webhook. + /// + public async ValueTask<(int StatusCode, string Body)> HandlePushAsync(string bodyText, CancellationToken ct) + { + try + { + using var doc = JsonDocument.Parse(bodyText); + var root = doc.RootElement; + + // Extract the Pub/Sub message + if (!root.TryGetProperty("message", out var message)) + return (400, "Missing 'message' field."); + + string? emailAddress = null; + string? historyId = null; + + if (message.TryGetProperty("data", out var dataEl) && dataEl.ValueKind == JsonValueKind.String) + { + var dataStr = dataEl.GetString(); + if (!string.IsNullOrWhiteSpace(dataStr)) + { + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(dataStr)); + using var innerDoc = JsonDocument.Parse(decoded); + var inner = innerDoc.RootElement; + emailAddress = inner.TryGetProperty("emailAddress", out var ea) ? ea.GetString() : null; + historyId = inner.TryGetProperty("historyId", out var hi) ? hi.ToString() : null; + } + catch + { + // Data may not be JSON — just use the raw notification + } + } + } + + var promptText = _config.Prompt; + if (!string.IsNullOrWhiteSpace(emailAddress)) + promptText += $"\nEmail: {emailAddress}"; + if (!string.IsNullOrWhiteSpace(historyId)) + promptText += $"\nHistory ID: {historyId}"; + + var inbound = new InboundMessage + { + ChannelId = "gmail", + SenderId = emailAddress ?? "gmail-pubsub", + SessionId = _config.SessionId ?? "gmail:triage", + Text = promptText, + IsSystem = true, + }; + + await _pipeline.InboundWriter.WriteAsync(inbound, ct); + _logger.LogInformation("Gmail Pub/Sub notification processed for {Email}.", emailAddress ?? "unknown"); + + return (200, "OK"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process Gmail Pub/Sub notification."); + return (500, "Processing failed."); + } + } +} diff --git a/src/OpenClaw.Gateway/Integrations/MdnsDiscoveryService.cs b/src/OpenClaw.Gateway/Integrations/MdnsDiscoveryService.cs new file mode 100644 index 0000000..ea48837 --- /dev/null +++ b/src/OpenClaw.Gateway/Integrations/MdnsDiscoveryService.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Models; + +namespace OpenClaw.Gateway.Integrations; + +/// +/// Advertises the OpenClaw gateway on the local network via mDNS/DNS-SD. +/// Enables automatic discovery by companion apps and nodes. +/// +internal sealed class MdnsDiscoveryService : IAsyncDisposable +{ + private static readonly IPAddress MulticastAddress = IPAddress.Parse("224.0.0.251"); + private const int MdnsPort = 5353; + + private readonly MdnsConfig _config; + private readonly int _gatewayPort; + private readonly ILogger _logger; + private CancellationTokenSource? _cts; + private Task? _listenTask; + private UdpClient? _udp; + + public MdnsDiscoveryService(MdnsConfig config, int gatewayPort, ILogger logger) + { + _config = config; + _gatewayPort = gatewayPort; + _logger = logger; + } + + public void Start(CancellationToken ct) + { + if (!_config.Enabled) + return; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + + try + { + _udp = new UdpClient(); + _udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + _udp.Client.Bind(new IPEndPoint(IPAddress.Any, MdnsPort)); + _udp.JoinMulticastGroup(MulticastAddress); + + _listenTask = ListenLoopAsync(_cts.Token); + + var instanceName = _config.InstanceName ?? Environment.MachineName; + var port = _config.Port > 0 ? _config.Port : _gatewayPort; + _logger.LogInformation("mDNS discovery started: {Instance}.{ServiceType}.local on port {Port}.", + instanceName, _config.ServiceType, port); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to start mDNS discovery. Service advertisement disabled."); + } + } + + private async Task ListenLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + var result = await _udp!.ReceiveAsync(ct); + // Check if the query is for our service type + var query = Encoding.UTF8.GetString(result.Buffer); + if (query.Contains(_config.ServiceType, StringComparison.OrdinalIgnoreCase)) + await SendResponseAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "mDNS receive error (non-fatal)."); + } + } + } + + private async Task SendResponseAsync(CancellationToken ct) + { + try + { + var instanceName = _config.InstanceName ?? Environment.MachineName; + var port = _config.Port > 0 ? _config.Port : _gatewayPort; + + // Build a minimal DNS-SD TXT record response + var txt = $"version=1.0\nport={port}\nauth={(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_AUTH_TOKEN")) ? "required" : "none")}"; + var response = BuildSimpleMdnsResponse(instanceName, _config.ServiceType, port, txt); + + var endpoint = new IPEndPoint(MulticastAddress, MdnsPort); + await _udp!.SendAsync(response, endpoint, ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to send mDNS response."); + } + } + + /// + /// Builds a minimal mDNS response packet. This is a simplified implementation + /// for basic service advertisement. For full DNS-SD compliance, consider a + /// dedicated mDNS library. + /// + private static byte[] BuildSimpleMdnsResponse(string instanceName, string serviceType, int port, string txt) + { + // Simplified: encode as a TXT record with service info + var payload = $"{instanceName}.{serviceType}.local\tport={port}\t{txt}"; + var data = Encoding.UTF8.GetBytes(payload); + + // Wrap in a minimal DNS response packet + var packet = new byte[12 + data.Length]; + // Transaction ID = 0 (mDNS) + // Flags: 0x8400 (response, authoritative) + packet[2] = 0x84; + packet[3] = 0x00; + // Answer count = 1 + packet[7] = 0x01; + // Copy payload + Buffer.BlockCopy(data, 0, packet, 12, data.Length); + + return packet; + } + + public async ValueTask DisposeAsync() + { + _cts?.Cancel(); + if (_listenTask is not null) + { + try { await _listenTask; } + catch (OperationCanceledException) { } + } + _udp?.Dispose(); + _cts?.Dispose(); + } +} diff --git a/src/OpenClaw.Gateway/Integrations/TailscaleService.cs b/src/OpenClaw.Gateway/Integrations/TailscaleService.cs new file mode 100644 index 0000000..27573ce --- /dev/null +++ b/src/OpenClaw.Gateway/Integrations/TailscaleService.cs @@ -0,0 +1,106 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using OpenClaw.Core.Models; + +namespace OpenClaw.Gateway.Integrations; + +/// +/// Manages Tailscale Serve/Funnel for zero-config remote access to the gateway. +/// +internal sealed class TailscaleService : IAsyncDisposable +{ + private readonly TailscaleConfig _config; + private readonly int _gatewayPort; + private readonly ILogger _logger; + private bool _started; + + public TailscaleService(TailscaleConfig config, int gatewayPort, ILogger logger) + { + _config = config; + _gatewayPort = gatewayPort; + _logger = logger; + } + + public async Task StartAsync(CancellationToken ct) + { + if (!_config.Enabled || string.Equals(_config.Mode, "off", StringComparison.OrdinalIgnoreCase)) + return; + + if (!await IsTailscaleAvailableAsync(ct)) + { + _logger.LogWarning("Tailscale is not available on this system. Skipping Serve/Funnel setup."); + return; + } + + var mode = _config.Mode.ToLowerInvariant(); + var target = $"https+insecure://localhost:{_gatewayPort}"; + var port = _config.Port > 0 ? _config.Port : 443; + + try + { + if (mode == "funnel") + { + await RunTailscaleAsync($"funnel --bg --https={port} {target}", ct); + _logger.LogInformation("Tailscale Funnel started on port {Port} → localhost:{GatewayPort}.", port, _gatewayPort); + } + else + { + await RunTailscaleAsync($"serve --bg --https={port} {target}", ct); + _logger.LogInformation("Tailscale Serve started on port {Port} → localhost:{GatewayPort}.", port, _gatewayPort); + } + + _started = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to start Tailscale {Mode}.", mode); + } + } + + public async ValueTask DisposeAsync() + { + if (!_started) return; + + try + { + await RunTailscaleAsync("serve off", CancellationToken.None); + _logger.LogInformation("Tailscale Serve/Funnel stopped."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to stop Tailscale Serve/Funnel."); + } + } + + private static async Task IsTailscaleAvailableAsync(CancellationToken ct) + { + try + { + var result = await RunTailscaleAsync("status --json", ct); + return result.ExitCode == 0; + } + catch + { + return false; + } + } + + private static async Task<(int ExitCode, string Output)> RunTailscaleAsync(string arguments, CancellationToken ct) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "tailscale", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct); + return (process.ExitCode, output); + } +} diff --git a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj index f078f86..471f8b3 100644 --- a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj +++ b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj @@ -40,6 +40,7 @@ $(DefineConstants);OPENCLAW_ENABLE_OPENSANDBOX + diff --git a/src/OpenClaw.Gateway/SlackWebhookHandler.cs b/src/OpenClaw.Gateway/SlackWebhookHandler.cs new file mode 100644 index 0000000..ae33781 --- /dev/null +++ b/src/OpenClaw.Gateway/SlackWebhookHandler.cs @@ -0,0 +1,275 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using OpenClaw.Channels; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Security; + +namespace OpenClaw.Gateway; + +internal sealed class SlackWebhookHandler +{ + private readonly SlackChannelConfig _config; + private readonly string? _signingSecret; + private readonly AllowlistManager _allowlists; + private readonly RecentSendersStore _recentSenders; + private readonly AllowlistSemantics _semantics; + private readonly ILogger _logger; + + public SlackWebhookHandler( + SlackChannelConfig config, + AllowlistManager allowlists, + RecentSendersStore recentSenders, + AllowlistSemantics allowlistSemantics, + ILogger logger) + { + _config = config; + _allowlists = allowlists; + _recentSenders = recentSenders; + _semantics = allowlistSemantics; + _logger = logger; + + _signingSecret = SecretResolver.Resolve(config.SigningSecretRef) ?? config.SigningSecret; + } + + public readonly record struct WebhookResponse(int StatusCode, string? Body = null, string ContentType = "text/plain"); + + /// + /// Handles an inbound Slack Events API webhook. + /// + public async ValueTask HandleEventAsync( + string bodyText, + string? timestampHeader, + string? signatureHeader, + Func enqueue, + CancellationToken ct) + { + if (_config.ValidateSignature) + { + if (!ValidateSlackSignature(bodyText, timestampHeader, signatureHeader)) + { + _logger.LogWarning("Rejected Slack webhook due to invalid signature."); + return new WebhookResponse(401); + } + } + + var wrapper = JsonSerializer.Deserialize(bodyText, SlackJsonContext.Default.SlackEventWrapper); + if (wrapper is null) + return new WebhookResponse(400, "Invalid payload."); + + // URL verification challenge + if (string.Equals(wrapper.Type, "url_verification", StringComparison.Ordinal)) + { + return new WebhookResponse(200, wrapper.Challenge, "text/plain"); + } + + if (!string.Equals(wrapper.Type, "event_callback", StringComparison.Ordinal)) + return new WebhookResponse(200, "OK"); + + var evt = wrapper.Event; + if (evt is null) + return new WebhookResponse(200, "OK"); + + // Filter bot messages to prevent loops + if (!string.IsNullOrEmpty(evt.BotId) || string.Equals(evt.Subtype, "bot_message", StringComparison.Ordinal)) + return new WebhookResponse(200, "OK"); + + // Only handle message and app_mention events + if (!string.Equals(evt.Type, "message", StringComparison.Ordinal) && + !string.Equals(evt.Type, "app_mention", StringComparison.Ordinal)) + return new WebhookResponse(200, "OK"); + + var userId = evt.User; + var text = evt.Text; + var channel = evt.Channel; + + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(text)) + return new WebhookResponse(200, "OK"); + + // Workspace allowlist + if (_config.AllowedWorkspaceIds.Length > 0) + { + if (string.IsNullOrWhiteSpace(wrapper.TeamId)) + return new WebhookResponse(400, "Missing team_id."); + + if (!Array.Exists(_config.AllowedWorkspaceIds, id => string.Equals(id, wrapper.TeamId, StringComparison.Ordinal))) + { + _logger.LogWarning("Rejected Slack message from disallowed workspace {TeamId}.", wrapper.TeamId); + return new WebhookResponse(403); + } + } + + // Channel allowlist + if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channel)) + { + if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channel, StringComparison.Ordinal))) + return new WebhookResponse(200, "OK"); + } + + await _recentSenders.RecordAsync("slack", userId, senderName: null, ct); + + // User allowlist + var effective = _allowlists.GetEffective("slack", new ChannelAllowlistFile + { + AllowedFrom = _config.AllowedFromUserIds + }); + + if (!AllowlistPolicy.IsAllowed(effective.AllowedFrom, userId, _semantics)) + { + _logger.LogWarning("Rejected Slack message from disallowed user {UserId}.", userId); + return new WebhookResponse(403); + } + + if (text.Length > _config.MaxInboundChars) + text = text[.._config.MaxInboundChars]; + + // Determine session ID based on thread mapping + var isDm = string.Equals(evt.ChannelType, "im", StringComparison.Ordinal); + string? sessionId; + if (evt.ThreadTs is not null && channel is not null) + sessionId = $"slack:thread:{channel}:{evt.ThreadTs}"; + else if (isDm) + sessionId = null; // default: slack:{userId} + else if (channel is not null) + sessionId = $"slack:{channel}:{userId}"; + else + sessionId = null; + + var message = new InboundMessage + { + ChannelId = "slack", + SenderId = userId, + SessionId = sessionId, + Text = text, + MessageId = evt.Ts, + ReplyToMessageId = evt.ThreadTs, + IsGroup = !isDm, + GroupId = isDm ? null : channel, + }; + + await enqueue(message, ct); + return new WebhookResponse(200, "OK"); + } + + /// + /// Handles an inbound Slack slash command (form-encoded POST). + /// + public async ValueTask HandleSlashCommandAsync( + Dictionary form, + string? timestampHeader, + string? signatureHeader, + string rawBody, + Func enqueue, + CancellationToken ct) + { + if (_config.ValidateSignature) + { + if (!ValidateSlackSignature(rawBody, timestampHeader, signatureHeader)) + { + _logger.LogWarning("Rejected Slack slash command due to invalid signature."); + return new WebhookResponse(401); + } + } + + var userId = form.GetValueOrDefault("user_id"); + var commandText = form.GetValueOrDefault("text") ?? ""; + var command = form.GetValueOrDefault("command") ?? ""; + var channel = form.GetValueOrDefault("channel_id"); + var teamId = form.GetValueOrDefault("team_id"); + var channelName = form.GetValueOrDefault("channel_name"); + + if (string.IsNullOrWhiteSpace(userId)) + return new WebhookResponse(400, "Missing user_id."); + + if (_config.AllowedWorkspaceIds.Length > 0) + { + if (string.IsNullOrWhiteSpace(teamId)) + return new WebhookResponse(400, "Missing team_id."); + if (!Array.Exists(_config.AllowedWorkspaceIds, id => string.Equals(id, teamId, StringComparison.Ordinal))) + return new WebhookResponse(403); + } + + if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channel)) + { + if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channel, StringComparison.Ordinal))) + return new WebhookResponse(403); + } + + await _recentSenders.RecordAsync("slack", userId, senderName: form.GetValueOrDefault("user_name"), ct); + + var effective = _allowlists.GetEffective("slack", new ChannelAllowlistFile + { + AllowedFrom = _config.AllowedFromUserIds + }); + + if (!AllowlistPolicy.IsAllowed(effective.AllowedFrom, userId, _semantics)) + return new WebhookResponse(403); + + var text = string.IsNullOrWhiteSpace(commandText) ? command : $"{command} {commandText}"; + + if (text.Length > _config.MaxInboundChars) + text = text[.._config.MaxInboundChars]; + + var isDm = + string.Equals(channelName, "directmessage", StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(channel) && channel.StartsWith('D')); + string? sessionId; + if (isDm) + sessionId = null; + else if (!string.IsNullOrWhiteSpace(channel)) + sessionId = $"slack:{channel}:{userId}"; + else + sessionId = null; + + var message = new InboundMessage + { + ChannelId = "slack", + SenderId = userId, + SessionId = sessionId, + Text = text, + GroupId = isDm ? null : channel, + IsGroup = !isDm && !string.IsNullOrWhiteSpace(channel), + }; + + await enqueue(message, ct); + return new WebhookResponse(200, "Processing..."); + } + + /// + /// Validates the Slack request signature using HMAC-SHA256. + /// Slack signs requests with: v0=HMAC-SHA256(signing_secret, "v0:{timestamp}:{body}") + /// + private bool ValidateSlackSignature(string body, string? timestamp, string? providedSignature) + { + if (string.IsNullOrWhiteSpace(_signingSecret) || + string.IsNullOrWhiteSpace(timestamp) || + string.IsNullOrWhiteSpace(providedSignature)) + return false; + + // Prevent replay attacks: reject requests with invalid or stale timestamps + if (!long.TryParse(timestamp, out var ts)) + { + _logger.LogWarning("Rejected Slack webhook with invalid timestamp format ({Timestamp}).", timestamp); + return false; + } + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (Math.Abs(now - ts) > 300) + { + _logger.LogWarning("Rejected Slack webhook with stale timestamp ({Timestamp}).", timestamp); + return false; + } + + var baseString = $"v0:{timestamp}:{body}"; + var secretBytes = Encoding.UTF8.GetBytes(_signingSecret); + var baseBytes = Encoding.UTF8.GetBytes(baseString); + var hash = HMACSHA256.HashData(secretBytes, baseBytes); + var expected = $"v0={Convert.ToHexStringLower(hash)}"; + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(providedSignature)); + } +} diff --git a/src/OpenClaw.Gateway/ToolPresetResolver.cs b/src/OpenClaw.Gateway/ToolPresetResolver.cs index 2348e40..84c33a3 100644 --- a/src/OpenClaw.Gateway/ToolPresetResolver.cs +++ b/src/OpenClaw.Gateway/ToolPresetResolver.cs @@ -30,6 +30,32 @@ internal sealed class ToolPresetResolver : IToolPresetResolver "notion_write" ]; + private static readonly string[] CodingPresetAllow = + [ + "shell", "read_file", "write_file", "edit_file", "apply_patch", "process", "git", + "code_exec", "browser", "memory", "memory_search", "memory_get", "project_memory", + "sessions", "session_search", "session_status", "delegate_agent", + "web_search", "web_fetch", "pdf_read", "image_gen", "vision_analyze" + ]; + + private static readonly string[] MessagingPresetAllow = + [ + "message", "sessions", "sessions_send", "sessions_history", "sessions_spawn", + "session_status", "session_search", "memory", "memory_search", "memory_get", + "profile_read", "todo" + ]; + + private static readonly Dictionary BuiltInToolsets = new(StringComparer.OrdinalIgnoreCase) + { + ["group:runtime"] = new() { AllowTools = ["shell", "process", "code_exec"] }, + ["group:fs"] = new() { AllowTools = ["read_file", "write_file", "edit_file", "apply_patch"] }, + ["group:sessions"] = new() { AllowTools = ["sessions", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "session_search", "agents_list"] }, + ["group:memory"] = new() { AllowTools = ["memory", "memory_search", "memory_get", "project_memory"] }, + ["group:web"] = new() { AllowTools = ["web_search", "web_fetch", "x_search", "browser"] }, + ["group:automation"] = new() { AllowTools = ["cron", "automation", "gateway", "todo"] }, + ["group:messaging"] = new() { AllowTools = ["message"] }, + }; + private readonly GatewayConfig _config; private readonly SessionMetadataStore _metadataStore; @@ -46,9 +72,11 @@ public ResolvedToolPreset Resolve(Session session, IEnumerable available .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - var metadata = _metadataStore.Get(session.Id); - var requestedPresetId = metadata.ActivePresetId; var surface = InferSurface(session); + var metadata = _metadataStore.Get(session.Id); + var requestedPresetId = !string.IsNullOrWhiteSpace(session.RoutePresetId) + ? session.RoutePresetId + : metadata.ActivePresetId; var presetId = string.IsNullOrWhiteSpace(requestedPresetId) ? ResolvePresetIdForSurface(surface) : requestedPresetId!.Trim(); @@ -64,7 +92,7 @@ public IReadOnlyList ListPresets(IEnumerable availab var names = availableToolNames.ToArray(); var configured = _config.Tooling.Presets.Keys .Select(presetId => ResolveConfiguredPreset(presetId, surface: "", _config.Tooling.Presets[presetId], names)); - var builtInIds = new[] { "cli", "web", "telegram", "automation", "readonly" } + var builtInIds = new[] { "cli", "full", "coding", "messaging", "minimal", "web", "telegram", "automation", "readonly" } .Where(id => !_config.Tooling.Presets.ContainsKey(id)) .Select(id => ResolveBuiltInPreset(id, surface: "", names)); return configured.Concat(builtInIds) @@ -121,7 +149,11 @@ private ResolvedToolPreset ResolveConfiguredPreset( foreach (var toolsetId in preset.Toolsets) { if (!_config.Tooling.Toolsets.TryGetValue(toolsetId, out var toolset)) - continue; + { + // Fall back to built-in toolsets (e.g. "group:runtime") + if (!BuiltInToolsets.TryGetValue(toolsetId, out toolset)) + continue; + } ApplyToolset(allowed, availableToolNames, toolset); } @@ -179,6 +211,18 @@ private ResolvedToolPreset ResolveBuiltInPreset(string presetId, string surface, switch (presetId.ToLowerInvariant()) { + case "full": + // All tools allowed — no denies + break; + case "coding": + allowed.IntersectWith(CodingPresetAllow); + break; + case "messaging": + allowed.IntersectWith(MessagingPresetAllow); + break; + case "minimal": + allowed.IntersectWith(["session_status"]); + break; case "web": foreach (var tool in DefaultWebDeny) allowed.Remove(tool); diff --git a/src/OpenClaw.Gateway/Tools/AgentsListTool.cs b/src/OpenClaw.Gateway/Tools/AgentsListTool.cs new file mode 100644 index 0000000..2ff87f2 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/AgentsListTool.cs @@ -0,0 +1,52 @@ +using System.Text; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; + +namespace OpenClaw.Gateway.Tools; + +/// +/// List available sub-agent profiles that can be delegated to. +/// +internal sealed class AgentsListTool : ITool +{ + private readonly DelegationConfig _delegation; + + public AgentsListTool(DelegationConfig delegation) + { + _delegation = delegation; + } + + public string Name => "agents_list"; + public string Description => "List available sub-agent profiles. Shows configured delegation profiles that can be used with delegate_agent."; + public string ParameterSchema => """{"type":"object","properties":{}}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + if (!_delegation.Enabled || _delegation.Profiles.Count == 0) + return ValueTask.FromResult("No agent profiles configured. Enable Delegation and add profiles to gateway config."); + + var sb = new StringBuilder(); + sb.AppendLine($"Available agents ({_delegation.Profiles.Count}):"); + sb.AppendLine($" Max delegation depth: {_delegation.MaxDepth}"); + sb.AppendLine(); + + foreach (var (name, profile) in _delegation.Profiles) + { + sb.AppendLine($" [{name}]"); + if (!string.IsNullOrWhiteSpace(profile.SystemPrompt)) + { + var preview = profile.SystemPrompt.Length > 80 + ? profile.SystemPrompt[..80] + "…" + : profile.SystemPrompt; + sb.AppendLine($" Prompt: {preview}"); + } + if (profile.AllowedTools.Length > 0) + sb.AppendLine($" Tools: {string.Join(", ", profile.AllowedTools)}"); + if (profile.MaxIterations > 0) + sb.AppendLine($" Max iterations: {profile.MaxIterations}"); + sb.AppendLine(); + } + + return ValueTask.FromResult(sb.ToString().TrimEnd()); + } +} diff --git a/src/OpenClaw.Gateway/Tools/CronTool.cs b/src/OpenClaw.Gateway/Tools/CronTool.cs new file mode 100644 index 0000000..48aacfd --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/CronTool.cs @@ -0,0 +1,119 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Manage scheduled cron jobs. List, inspect, and trigger jobs. +/// +internal sealed class CronTool : IToolWithContext +{ + private readonly ICronJobSource _cronSource; + private readonly MessagePipeline _pipeline; + + public CronTool(ICronJobSource cronSource, MessagePipeline pipeline) + { + _cronSource = cronSource; + _pipeline = pipeline; + } + + public string Name => "cron"; + public string Description => "Manage scheduled cron jobs. List configured jobs, get details, or trigger immediate execution."; + public string ParameterSchema => """{"type":"object","properties":{"action":{"type":"string","enum":["list","get","run"],"description":"Action to perform"},"name":{"type":"string","description":"Job name (required for get/run)"}},"required":["action"]}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: cron requires execution context."); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var action = GetString(root, "action") ?? "list"; + + return action switch + { + "list" => ListJobs(), + "get" => GetJob(root), + "run" => await RunJobAsync(root, ct), + _ => $"Error: Unknown action '{action}'. Use list, get, or run." + }; + } + + private string ListJobs() + { + var jobs = _cronSource.GetJobs(); + if (jobs.Count == 0) + return "No cron jobs configured."; + + var sb = new StringBuilder(); + sb.AppendLine($"Cron jobs ({jobs.Count}):"); + foreach (var job in jobs) + { + sb.AppendLine($" [{job.Name}] {job.CronExpression}"); + var promptPreview = job.Prompt.Length > 60 ? job.Prompt[..60] + "…" : job.Prompt; + sb.AppendLine($" Prompt: {promptPreview}"); + if (job.RunOnStartup) + sb.AppendLine(" RunOnStartup: true"); + } + return sb.ToString().TrimEnd(); + } + + private string GetJob(JsonElement root) + { + var name = GetString(root, "name"); + if (string.IsNullOrWhiteSpace(name)) + return "Error: 'name' is required for get action."; + + var jobs = _cronSource.GetJobs(); + var job = jobs.FirstOrDefault(j => string.Equals(j.Name, name, StringComparison.OrdinalIgnoreCase)); + if (job is null) + return $"Job '{name}' not found."; + + var sb = new StringBuilder(); + sb.AppendLine($"Job: {job.Name}"); + sb.AppendLine($" Schedule: {job.CronExpression}"); + sb.AppendLine($" Prompt: {job.Prompt}"); + sb.AppendLine($" RunOnStartup: {job.RunOnStartup}"); + if (job.SessionId is not null) sb.AppendLine($" SessionId: {job.SessionId}"); + if (job.ChannelId is not null) sb.AppendLine($" ChannelId: {job.ChannelId}"); + if (job.RecipientId is not null) sb.AppendLine($" RecipientId: {job.RecipientId}"); + if (job.Timezone is not null) sb.AppendLine($" Timezone: {job.Timezone}"); + return sb.ToString().TrimEnd(); + } + + private async Task RunJobAsync(JsonElement root, CancellationToken ct) + { + var name = GetString(root, "name"); + if (string.IsNullOrWhiteSpace(name)) + return "Error: 'name' is required for run action."; + + var jobs = _cronSource.GetJobs(); + var job = jobs.FirstOrDefault(j => string.Equals(j.Name, name, StringComparison.OrdinalIgnoreCase)); + if (job is null) + return $"Job '{name}' not found."; + + var message = new InboundMessage + { + ChannelId = job.ChannelId ?? "cron", + SenderId = "cron", + SessionId = job.SessionId ?? $"cron:{job.Name}", + CronJobName = job.Name, + Text = job.Prompt, + Subject = job.Subject, + IsSystem = true, + }; + + await _pipeline.InboundWriter.WriteAsync(message, ct); + return $"Job '{name}' triggered for immediate execution."; + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Gateway/Tools/GatewayTool.cs b/src/OpenClaw.Gateway/Tools/GatewayTool.cs new file mode 100644 index 0000000..de22e51 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/GatewayTool.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Observability; +using OpenClaw.Core.Sessions; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Gateway management and status operations. +/// +internal sealed class GatewayTool : ITool +{ + private readonly RuntimeMetrics _metrics; + private readonly SessionManager _sessions; + private readonly GatewayConfig _config; + + public GatewayTool(RuntimeMetrics metrics, SessionManager sessions, GatewayConfig config) + { + _metrics = metrics; + _sessions = sessions; + _config = config; + } + + public string Name => "gateway"; + public string Description => "Get gateway status, metrics, and configuration summary."; + public string ParameterSchema => """{"type":"object","properties":{"action":{"type":"string","enum":["status","config"],"description":"Action: status (runtime metrics) or config (configuration summary)"}}}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var action = root.TryGetProperty("action", out var a) && a.ValueKind == JsonValueKind.String + ? a.GetString() : "status"; + + return ValueTask.FromResult(action switch + { + "config" => GetConfig(), + _ => GetStatus() + }); + } + + private string GetStatus() + { + var snapshot = _metrics.Snapshot(); + var sb = new StringBuilder(); + sb.AppendLine("Gateway Status:"); + sb.AppendLine($" Active sessions: {_sessions.ActiveCount}"); + sb.AppendLine($" Total requests: {snapshot.TotalRequests}"); + sb.AppendLine($" Total LLM calls: {snapshot.TotalLlmCalls}"); + sb.AppendLine($" Total tokens (in/out): {snapshot.TotalInputTokens}/{snapshot.TotalOutputTokens}"); + sb.AppendLine($" Tool calls: {snapshot.TotalToolCalls}"); + sb.AppendLine($" Tool failures: {snapshot.TotalToolFailures}"); + sb.AppendLine($" LLM errors: {snapshot.TotalLlmErrors}"); + sb.AppendLine($" LLM retries: {snapshot.TotalLlmRetries}"); + return sb.ToString().TrimEnd(); + } + + private string GetConfig() + { + var sb = new StringBuilder(); + sb.AppendLine("Gateway Configuration:"); + sb.AppendLine($" Bind: {_config.BindAddress}:{_config.Port}"); + sb.AppendLine($" LLM Provider: {_config.Llm.Provider}"); + sb.AppendLine($" Model: {_config.Llm.Model}"); + sb.AppendLine($" Memory: {_config.Memory.Provider}"); + sb.AppendLine($" Max sessions: {_config.MaxConcurrentSessions}"); + sb.AppendLine($" Session timeout: {_config.SessionTimeoutMinutes}m"); + sb.AppendLine($" Autonomy: {_config.Tooling.AutonomyMode}"); + sb.AppendLine($" Tool approval: {_config.Tooling.RequireToolApproval}"); + sb.AppendLine($" Browser: {_config.Tooling.EnableBrowserTool}"); + sb.AppendLine($" Shell: {_config.Tooling.AllowShell}"); + sb.AppendLine($" Cron: {_config.Cron.Enabled} ({_config.Cron.Jobs.Count} jobs)"); + return sb.ToString().TrimEnd(); + } +} diff --git a/src/OpenClaw.Gateway/Tools/ProfileWriteTool.cs b/src/OpenClaw.Gateway/Tools/ProfileWriteTool.cs new file mode 100644 index 0000000..d53fbea --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/ProfileWriteTool.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Write or update user profile data. Complements profile_read. +/// +internal sealed class ProfileWriteTool : IToolWithContext +{ + private readonly IUserProfileStore _store; + + public ProfileWriteTool(IUserProfileStore store) => _store = store; + + public string Name => "profile_write"; + public string Description => "Create or update a user profile. Set summary, tone, preferences, and other profile fields."; + public string ParameterSchema => """{"type":"object","properties":{"actor_id":{"type":"string","description":"Actor ID (defaults to current session's channel:sender)"},"summary":{"type":"string","description":"Brief user summary"},"tone":{"type":"string","description":"Preferred communication tone"},"preferences":{"type":"array","items":{"type":"string"},"description":"User preferences list"},"active_projects":{"type":"array","items":{"type":"string"},"description":"Active project names"}},"required":[]}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: profile_write requires execution context."); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var actorId = GetString(root, "actor_id") ?? $"{context.Session.ChannelId}:{context.Session.SenderId}"; + + // Load existing profile to merge + var existing = await _store.GetProfileAsync(actorId, ct); + + var summary = GetString(root, "summary") ?? existing?.Summary ?? ""; + var tone = GetString(root, "tone") ?? existing?.Tone ?? ""; + var preferences = GetStringArray(root, "preferences") ?? existing?.Preferences ?? []; + var activeProjects = GetStringArray(root, "active_projects") ?? existing?.ActiveProjects ?? []; + + var profile = new UserProfile + { + ActorId = actorId, + ChannelId = existing?.ChannelId ?? context.Session.ChannelId, + SenderId = existing?.SenderId ?? context.Session.SenderId, + Summary = summary, + Tone = tone, + Preferences = preferences, + ActiveProjects = activeProjects, + Facts = existing?.Facts ?? [], + RecentIntents = existing?.RecentIntents ?? [], + UpdatedAtUtc = DateTimeOffset.UtcNow, + }; + + await _store.SaveProfileAsync(profile, ct); + return $"Profile updated for '{actorId}'."; + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; + + private static IReadOnlyList? GetStringArray(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var el) || el.ValueKind != JsonValueKind.Array) + return null; + + var list = new List(); + foreach (var item in el.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + list.Add(item.GetString()!); + } + return list; + } +} diff --git a/src/OpenClaw.Gateway/Tools/SessionStatusTool.cs b/src/OpenClaw.Gateway/Tools/SessionStatusTool.cs new file mode 100644 index 0000000..3a82a16 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/SessionStatusTool.cs @@ -0,0 +1,62 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Sessions; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Get compact status of a session including state, token usage, and duration. +/// +internal sealed class SessionStatusTool : IToolWithContext +{ + private readonly SessionManager _sessions; + + public SessionStatusTool(SessionManager sessions) + { + _sessions = sessions; + } + + public string Name => "session_status"; + public string Description => "Get compact status of a session. Shows state, token usage, turn count, and active duration."; + public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Session ID (defaults to current session if omitted)"}}}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: session_status requires execution context."); + + public ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var sessionId = GetString(root, "session_id") ?? context.Session.Id; + var session = _sessions.TryGetActiveById(sessionId); + + if (session is null) + return ValueTask.FromResult($"Session '{sessionId}' not found or not active."); + + var duration = DateTimeOffset.UtcNow - session.CreatedAt; + var sb = new StringBuilder(); + sb.AppendLine($"Session: {session.Id}"); + sb.AppendLine($" State: {session.State}"); + sb.AppendLine($" Channel: {session.ChannelId}"); + sb.AppendLine($" Sender: {session.SenderId}"); + sb.AppendLine($" Turns: {session.History.Count}"); + sb.AppendLine($" Tokens (in/out): {session.TotalInputTokens}/{session.TotalOutputTokens}"); + sb.AppendLine($" Created: {session.CreatedAt:u}"); + sb.AppendLine($" Last Active: {session.LastActiveAt:u}"); + sb.AppendLine($" Duration: {(int)duration.TotalHours}h {duration.Minutes}m"); + + if (session.ModelOverride is not null) + sb.AppendLine($" Model Override: {session.ModelOverride}"); + + return ValueTask.FromResult(sb.ToString().TrimEnd()); + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Gateway/Tools/SessionsHistoryTool.cs b/src/OpenClaw.Gateway/Tools/SessionsHistoryTool.cs new file mode 100644 index 0000000..c320685 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/SessionsHistoryTool.cs @@ -0,0 +1,76 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Sessions; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Fetch conversation history/transcript for a session. +/// +internal sealed class SessionsHistoryTool : IToolWithContext +{ + private readonly SessionManager _sessions; + private readonly IMemoryStore _store; + + public SessionsHistoryTool(SessionManager sessions, IMemoryStore store) + { + _sessions = sessions; + _store = store; + } + + public string Name => "sessions_history"; + public string Description => "Fetch the conversation history for a session. Returns recent turns with role and content."; + public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Session ID to fetch history for"},"limit":{"type":"integer","description":"Max turns to return (default 20, max 100)"}},"required":["session_id"]}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: sessions_history requires execution context."); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var sessionId = GetString(root, "session_id"); + if (string.IsNullOrWhiteSpace(sessionId)) + return "Error: 'session_id' is required."; + + var limit = 20; + if (root.TryGetProperty("limit", out var l) && l.ValueKind == JsonValueKind.Number) + limit = Math.Clamp(l.GetInt32(), 1, 100); + + // Try active session first, then load from store + var session = _sessions.TryGetActiveById(sessionId); + if (session is null) + session = await _store.GetSessionAsync(sessionId, ct); + + if (session is null) + return $"Session '{sessionId}' not found."; + + var history = session.History; + if (history.Count == 0) + return "Session has no conversation history."; + + var start = Math.Max(0, history.Count - limit); + var sb = new StringBuilder(); + sb.AppendLine($"Session: {sessionId} ({history.Count} total turns, showing last {history.Count - start})"); + sb.AppendLine(); + + for (var i = start; i < history.Count; i++) + { + var turn = history[i]; + sb.AppendLine($"[{turn.Role}] ({turn.Timestamp:HH:mm:ss})"); + sb.AppendLine(turn.Content); + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Gateway/Tools/SessionsSendTool.cs b/src/OpenClaw.Gateway/Tools/SessionsSendTool.cs new file mode 100644 index 0000000..2d0a8a4 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/SessionsSendTool.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Sessions; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Send a message to another session. Enables cross-session communication. +/// +internal sealed class SessionsSendTool : IToolWithContext +{ + private readonly SessionManager _sessions; + private readonly MessagePipeline _pipeline; + + public SessionsSendTool(SessionManager sessions, MessagePipeline pipeline) + { + _sessions = sessions; + _pipeline = pipeline; + } + + public string Name => "sessions_send"; + public string Description => "Send a message to another active session. The message will be processed as if it came from the system."; + public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Target session ID to send message to"},"message":{"type":"string","description":"Message text to send"}},"required":["session_id","message"]}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: sessions_send requires execution context."); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var sessionId = GetString(root, "session_id"); + if (string.IsNullOrWhiteSpace(sessionId)) + return "Error: 'session_id' is required."; + + var message = GetString(root, "message"); + if (string.IsNullOrWhiteSpace(message)) + return "Error: 'message' is required."; + + var target = _sessions.TryGetActiveById(sessionId); + if (target is null) + return $"Error: Session '{sessionId}' not found or not active."; + + var inbound = new InboundMessage + { + ChannelId = target.ChannelId, + SenderId = "system", + SessionId = sessionId, + Text = message, + IsSystem = true, + }; + + await _pipeline.InboundWriter.WriteAsync(inbound, ct); + return $"Message sent to session '{sessionId}'."; + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Gateway/Tools/SessionsSpawnTool.cs b/src/OpenClaw.Gateway/Tools/SessionsSpawnTool.cs new file mode 100644 index 0000000..0d848c1 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/SessionsSpawnTool.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Sessions; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Spawn a new sub-agent session with an initial prompt. +/// +internal sealed class SessionsSpawnTool : IToolWithContext +{ + private readonly SessionManager _sessions; + private readonly MessagePipeline _pipeline; + + public SessionsSpawnTool(SessionManager sessions, MessagePipeline pipeline) + { + _sessions = sessions; + _pipeline = pipeline; + } + + public string Name => "sessions_spawn"; + public string Description => "Spawn a new agent session with an initial prompt. Returns the new session ID."; + public string ParameterSchema => """{"type":"object","properties":{"prompt":{"type":"string","description":"Initial prompt for the new session"},"session_id":{"type":"string","description":"Optional explicit session ID (auto-generated if omitted)"},"channel_id":{"type":"string","description":"Channel to associate (default: 'agent')"}},"required":["prompt"]}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: sessions_spawn requires execution context."); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var prompt = GetString(root, "prompt"); + if (string.IsNullOrWhiteSpace(prompt)) + return "Error: 'prompt' is required."; + + var channelId = GetString(root, "channel_id") ?? "agent"; + var sessionId = GetString(root, "session_id") ?? $"spawn_{Guid.NewGuid():N}"[..20]; + + var session = await _sessions.GetOrCreateByIdAsync(sessionId, channelId, "system", ct); + + var inbound = new InboundMessage + { + ChannelId = channelId, + SenderId = "system", + SessionId = session.Id, + Text = prompt, + IsSystem = true, + }; + + await _pipeline.InboundWriter.WriteAsync(inbound, ct); + return $"Spawned session '{session.Id}' on channel '{channelId}'."; + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.Gateway/Tools/SessionsYieldTool.cs b/src/OpenClaw.Gateway/Tools/SessionsYieldTool.cs new file mode 100644 index 0000000..334f3d2 --- /dev/null +++ b/src/OpenClaw.Gateway/Tools/SessionsYieldTool.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Sessions; + +namespace OpenClaw.Gateway.Tools; + +/// +/// Yield execution to another session. Sends a message and waits for the target session's response. +/// +internal sealed class SessionsYieldTool : IToolWithContext +{ + private readonly SessionManager _sessions; + private readonly MessagePipeline _pipeline; + private readonly IMemoryStore _store; + + public SessionsYieldTool(SessionManager sessions, MessagePipeline pipeline, IMemoryStore store) + { + _sessions = sessions; + _pipeline = pipeline; + _store = store; + } + + public string Name => "sessions_yield"; + public string Description => "Yield execution to another session. Sends a message and waits for the target to respond, then returns the response."; + public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Target session ID to yield to"},"message":{"type":"string","description":"Message to send to the target session"},"timeout_seconds":{"type":"integer","description":"Max seconds to wait for response (default 60, max 300)"}},"required":["session_id","message"]}"""; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("Error: sessions_yield requires execution context."); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + { + using var args = JsonDocument.Parse( + string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + var root = args.RootElement; + + var sessionId = GetString(root, "session_id"); + if (string.IsNullOrWhiteSpace(sessionId)) + return "Error: 'session_id' is required."; + + // Prevent self-yield deadlock + if (string.Equals(sessionId, context.Session.Id, StringComparison.Ordinal)) + return "Error: Cannot yield to the current session (would deadlock)."; + + var message = GetString(root, "message"); + if (string.IsNullOrWhiteSpace(message)) + return "Error: 'message' is required."; + + var timeoutSeconds = 60; + if (root.TryGetProperty("timeout_seconds", out var ts) && ts.ValueKind == JsonValueKind.Number) + timeoutSeconds = Math.Clamp(ts.GetInt32(), 5, 300); + + // Verify target session exists + var target = _sessions.TryGetActiveById(sessionId); + if (target is null) + return $"Error: Session '{sessionId}' not found or not active."; + + // Record the target's current turn count to detect new responses + var turnCountBefore = target.History.Count; + + // Send message to target session + var inbound = new InboundMessage + { + ChannelId = target.ChannelId, + SenderId = "system", + SessionId = sessionId, + Text = message, + IsSystem = true, + }; + + await _pipeline.InboundWriter.WriteAsync(inbound, ct); + + // Poll for a new assistant turn on the target session + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + var pollDelay = 500; + var storeFallbackDone = false; + while (!timeoutCts.Token.IsCancellationRequested) + { + await Task.Delay(pollDelay, timeoutCts.Token); + pollDelay = Math.Min(pollDelay + 250, 2000); // backoff to 2s max + + // Check active sessions first (O(n) but avoids disk I/O) + var updated = _sessions.TryGetActiveById(sessionId); + + // Only try store once if session was evicted from active cache + if (updated is null && !storeFallbackDone) + { + updated = await _store.GetSessionAsync(sessionId, timeoutCts.Token); + storeFallbackDone = true; + } + + if (updated is not null && updated.History.Count > turnCountBefore) + { + for (var i = updated.History.Count - 1; i >= turnCountBefore; i--) + { + if (string.Equals(updated.History[i].Role, "assistant", StringComparison.Ordinal)) + return $"[Session {sessionId} responded]:\n{updated.History[i].Content}"; + } + } + } + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + // Timeout — not cancellation of parent + } + + return $"Timeout: Session '{sessionId}' did not respond within {timeoutSeconds} seconds."; + } + + private static string? GetString(JsonElement root, string property) + => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; +} diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs index fa694b1..3da5bb2 100644 --- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs @@ -157,7 +157,7 @@ public async Task RunAsync( return "You've reached the token limit for this session. Please start a new conversation."; } - ChatClientAgent agent = CreateAgent(); + ChatClientAgent agent = CreateAgent(session); AgentSession mafSession = await _sessionStateStore.LoadAsync(agent, session, ct); var toolInvocations = new List(); @@ -177,7 +177,7 @@ public async Task RunAsync( { Session = session, TurnContext = turnCtx, - SystemPromptLength = _systemPromptLength, + SystemPromptLength = GetSystemPromptLength(session), SkillPromptLength = _skillPromptLength, SessionTokenBudget = _sessionTokenBudget, ToolInvocations = toolInvocations, @@ -258,7 +258,7 @@ public async IAsyncEnumerable RunStreamingAsync( yield break; } - ChatClientAgent agent = CreateAgent(); + ChatClientAgent agent = CreateAgent(session); AgentSession mafSession = await _sessionStateStore.LoadAsync(agent, session, ct); var eventChannel = Channel.CreateBounded(new BoundedChannelOptions(256) { @@ -293,15 +293,9 @@ public async IAsyncEnumerable RunStreamingAsync( await producer; } - private ChatClientAgent CreateAgent() + private ChatClientAgent CreateAgent(Session session) { - string systemPrompt; - lock (_skillGate) - { - systemPrompt = _systemPrompt; - } - - return _agentFactory.Create(_chatClient, systemPrompt, _mafTools); + return _agentFactory.Create(_chatClient, GetSystemPrompt(session), _mafTools); } private async Task ProduceStreamingRunAsync( @@ -326,7 +320,7 @@ ValueTask WriteStreamEventAsync(AgentStreamEvent evt, CancellationToken token) { Session = session, TurnContext = turnCtx, - SystemPromptLength = _systemPromptLength, + SystemPromptLength = GetSystemPromptLength(session), SkillPromptLength = _skillPromptLength, SessionTokenBudget = _sessionTokenBudget, ToolInvocations = toolInvocations, @@ -400,7 +394,8 @@ await writer.WriteAsync( } private ChatOptions CreateChatOptions(Session session, System.Text.Json.JsonElement? responseSchema) - => new() + { + var options = new ChatOptions { ModelId = session.ModelOverride ?? _config.Model, MaxOutputTokens = _config.MaxTokens, @@ -410,6 +405,32 @@ private ChatOptions CreateChatOptions(Session session, System.Text.Json.JsonElem : null }; + if (!string.IsNullOrWhiteSpace(session.ReasoningEffort)) + { + options.AdditionalProperties ??= new AdditionalPropertiesDictionary(); + options.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort; + } + + return options; + } + + private string GetSystemPrompt(Session session) + { + string systemPrompt; + lock (_skillGate) + { + systemPrompt = _systemPrompt; + } + + if (string.IsNullOrWhiteSpace(session.SystemPromptOverride)) + return systemPrompt; + + return systemPrompt + "\n\n[Route Instructions]\n" + session.SystemPromptOverride.Trim(); + } + + private int GetSystemPromptLength(Session session) + => GetSystemPrompt(session).Length; + private async ValueTask TryInjectRecallAsync(List messages, string userMessage, CancellationToken ct) { if (_recall is null || !_recall.Enabled) diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs index 4faa234..34e753c 100644 --- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs @@ -149,7 +149,12 @@ internal static string ComputeHistoryHash(Session session) { var historyJson = JsonSerializer.Serialize(session.History, CoreJsonContext.Default.ListChatTurn); var modelOverride = session.ModelOverride ?? string.Empty; - var payload = $"{modelOverride}\n{historyJson}"; + var systemPromptOverride = session.SystemPromptOverride ?? string.Empty; + var routePresetId = session.RoutePresetId ?? string.Empty; + var routeAllowedTools = session.RouteAllowedTools.Length == 0 + ? string.Empty + : string.Join(",", session.RouteAllowedTools.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)); + var payload = $"{modelOverride}\n{systemPromptOverride}\n{routePresetId}\n{routeAllowedTools}\n{historyJson}"; return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))); } } diff --git a/src/OpenClaw.Tests/ChannelAdapterSecurityTests.cs b/src/OpenClaw.Tests/ChannelAdapterSecurityTests.cs new file mode 100644 index 0000000..4ec3239 --- /dev/null +++ b/src/OpenClaw.Tests/ChannelAdapterSecurityTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using OpenClaw.Channels; +using OpenClaw.Core.Models; +using OpenClaw.Core.Pipeline; +using OpenClaw.Core.Security; +using OpenClaw.Gateway; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Xunit; + +namespace OpenClaw.Tests; + +public sealed class ChannelAdapterSecurityTests +{ + [Fact] + public void Ed25519Verify_AcceptsValidSignature() + { + var privateKey = Enumerable.Range(0, 32).Select(static i => (byte)(i + 1)).ToArray(); + var publicKey = new Ed25519PrivateKeyParameters(privateKey, 0).GeneratePublicKey().GetEncoded(); + var message = Encoding.UTF8.GetBytes("1234567890{\"type\":1}"); + + var signer = new Ed25519Signer(); + signer.Init(forSigning: true, new Ed25519PrivateKeyParameters(privateKey, 0)); + signer.BlockUpdate(message, 0, message.Length); + var signature = signer.GenerateSignature(); + + Assert.True(Ed25519Verify.Verify(signature, message, publicKey)); + } + + [Fact] + public async Task DiscordWebhookHandler_RejectsDisallowedGuild() + { + var handler = new DiscordWebhookHandler( + new DiscordChannelConfig + { + ValidateSignature = false, + AllowedGuildIds = ["allowed-guild"] + }, + NullLogger.Instance); + + var payload = """ + { + "id":"1", + "type":2, + "guild_id":"blocked-guild", + "channel_id":"channel-1", + "member":{"user":{"id":"user-1","username":"tester"}}, + "data":{"name":"claw","options":[{"name":"message","value":"hello"}]} + } + """; + var enqueued = false; + + var result = await handler.HandleAsync( + payload, + signatureHeader: null, + timestampHeader: null, + (msg, ct) => + { + enqueued = true; + return ValueTask.CompletedTask; + }, + CancellationToken.None); + + Assert.Equal(403, result.StatusCode); + Assert.False(enqueued); + } + + [Fact] + public async Task SlackWebhookHandler_SlashCommand_RejectsDisallowedWorkspace() + { + var handler = new SlackWebhookHandler( + new SlackChannelConfig + { + ValidateSignature = false, + AllowedWorkspaceIds = ["allowed-workspace"] + }, + new AllowlistManager( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), + NullLogger.Instance), + new RecentSendersStore( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), + NullLogger.Instance), + AllowlistSemantics.Legacy, + NullLogger.Instance); + + var result = await handler.HandleSlashCommandAsync( + new Dictionary(StringComparer.Ordinal) + { + ["user_id"] = "user-1", + ["team_id"] = "blocked-workspace", + ["channel_id"] = "C123", + ["command"] = "/claw", + ["text"] = "hello" + }, + timestampHeader: null, + signatureHeader: null, + rawBody: "user_id=user-1", + (msg, ct) => ValueTask.CompletedTask, + CancellationToken.None); + + Assert.Equal(403, result.StatusCode); + } + + [Fact] + public async Task DiscordChannel_SendAsync_RecreatesRequestAfterRateLimit() + { + var responses = new Queue( + [ + new HttpResponseMessage((HttpStatusCode)429) + { + Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.Zero) } + }, + new HttpResponseMessage(HttpStatusCode.OK) + ]); + var requestCount = 0; + using var http = new HttpClient(new CallbackHandler(request => + { + Interlocked.Increment(ref requestCount); + return responses.Dequeue(); + })); + + var channel = new DiscordChannel( + new DiscordChannelConfig + { + BotToken = "token", + RegisterSlashCommands = false + }, + NullLogger.Instance, + http); + + await channel.SendAsync( + new OutboundMessage + { + ChannelId = "discord", + RecipientId = "123", + Text = "hello" + }, + CancellationToken.None); + + Assert.Equal(2, requestCount); + } + + private sealed class CallbackHandler(Func callback) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(callback(request)); + } +} diff --git a/src/OpenClaw.Tests/ChatCommandProcessorTests.cs b/src/OpenClaw.Tests/ChatCommandProcessorTests.cs index ab51bf5..f8110fa 100644 --- a/src/OpenClaw.Tests/ChatCommandProcessorTests.cs +++ b/src/OpenClaw.Tests/ChatCommandProcessorTests.cs @@ -32,4 +32,31 @@ public void RegisterDynamic_DuplicateCommand_FirstRegistrationWins() Assert.Equal(DynamicCommandRegistrationResult.Registered, first); Assert.Equal(DynamicCommandRegistrationResult.Duplicate, duplicate); } + + [Fact] + public async Task Compact_Command_ReportsRemainingTurns() + { + var store = new FileMemoryStore(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-command-tests", Guid.NewGuid().ToString("N")), 4); + var processor = new ChatCommandProcessor(new SessionManager(store, new GatewayConfig(), NullLogger.Instance)); + processor.SetCompactCallback(static (_, _) => Task.FromResult(6)); + + var session = new Session + { + Id = "sess-compact", + ChannelId = "websocket", + SenderId = "user1" + }; + session.History.AddRange( + [ + new ChatTurn { Role = "user", Content = "u1" }, + new ChatTurn { Role = "assistant", Content = "a1" }, + new ChatTurn { Role = "user", Content = "u2" }, + new ChatTurn { Role = "assistant", Content = "a2" } + ]); + + var (handled, response) = await processor.TryProcessCommandAsync(session, "/compact", CancellationToken.None); + + Assert.True(handled); + Assert.Equal("Compacted: 4 turns → 6 turns remaining.", response); + } } diff --git a/src/OpenClaw.Tests/CoreServicesExtensionsTests.cs b/src/OpenClaw.Tests/CoreServicesExtensionsTests.cs new file mode 100644 index 0000000..9ef5573 --- /dev/null +++ b/src/OpenClaw.Tests/CoreServicesExtensionsTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenClaw.Core.Models; +using OpenClaw.Gateway; +using OpenClaw.Gateway.Bootstrap; +using OpenClaw.Gateway.Composition; +using Xunit; + +namespace OpenClaw.Tests; + +public sealed class CoreServicesExtensionsTests +{ + [Fact] + public void AddOpenClawCoreServices_RegistersLearningConfigForLearningService() + { + var tempPath = Path.Combine(Path.GetTempPath(), "openclaw-core-services-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempPath); + + var config = new GatewayConfig + { + Memory = new MemoryConfig + { + StoragePath = tempPath + } + }; + var startup = new GatewayStartupContext + { + Config = config, + RuntimeState = new GatewayRuntimeState + { + RequestedMode = "jit", + EffectiveMode = GatewayRuntimeMode.Jit, + DynamicCodeSupported = true + }, + IsNonLoopbackBind = false + }; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddOpenClawCoreServices(startup); + + using var provider = services.BuildServiceProvider(); + + Assert.Same(config.Learning, provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + } +} diff --git a/src/OpenClaw.Tests/FeatureParityTests.cs b/src/OpenClaw.Tests/FeatureParityTests.cs index ef7495a..dbf919f 100644 --- a/src/OpenClaw.Tests/FeatureParityTests.cs +++ b/src/OpenClaw.Tests/FeatureParityTests.cs @@ -154,6 +154,30 @@ public async Task RunAsync_EstimatedTokenAdmissionControl_RejectsBeforeCallingLl await chatClient.DidNotReceiveWithAnyArgs().GetResponseAsync(default!, default!, default); } + [Fact] + public async Task RunAsync_RouteSystemPromptOverride_IsIncludedInSystemMessage() + { + var chatClient = Substitute.For(); + IList? captured = null; + chatClient.GetResponseAsync( + Arg.Do>(messages => captured = messages), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(new ChatResponse(new[] { new ChatMessage(ChatRole.Assistant, "ok") }))); + var memory = Substitute.For(); + + var agent = new AgentRuntime(chatClient, [], memory, DefaultConfig, maxHistoryTurns: 10); + var session = CreateSession(); + session.SystemPromptOverride = "Answer like the incident response agent."; + + _ = await agent.RunAsync(session, "hello", CancellationToken.None); + + Assert.NotNull(captured); + var systemMessage = Assert.Single(captured!, static message => message.Role == ChatRole.System); + Assert.Contains("[Route Instructions]", systemMessage.Text, StringComparison.Ordinal); + Assert.Contains("incident response agent", systemMessage.Text, StringComparison.Ordinal); + } + [Fact] public void AgentStreamEvent_EnvelopeType_Maps_Correctly() { diff --git a/src/OpenClaw.Tests/GatewayWorkersTests.cs b/src/OpenClaw.Tests/GatewayWorkersTests.cs index 46d8b1f..d997c31 100644 --- a/src/OpenClaw.Tests/GatewayWorkersTests.cs +++ b/src/OpenClaw.Tests/GatewayWorkersTests.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Net; +using System.Text.Json; using System.Threading.Channels; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; @@ -291,6 +293,244 @@ await pipeline.InboundWriter.WriteAsync(new InboundMessage Assert.Contains(operations.RuntimeEvents.Query(new RuntimeEventQuery { Limit = 10 }), item => item.Action == "grant_consumed"); } + [Fact] + public async Task Start_RouteOverrides_AreAppliedToSessionBeforeRuntimeExecution() + { + var storagePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-worker-tests", Guid.NewGuid().ToString("N")); + var store = new FileMemoryStore(storagePath, 4); + var config = new GatewayConfig + { + Memory = new MemoryConfig + { + StoragePath = storagePath + }, + Tooling = new ToolingConfig + { + EnableBrowserTool = false + }, + Routing = new RoutingConfig + { + Enabled = true, + Routes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["telegram:analyst"] = new() + { + ModelOverride = "route-model", + SystemPrompt = "Focus on production incidents.", + PresetId = "readonly", + AllowedTools = ["session_search", "profile_read"] + } + } + }, + Channels = new ChannelsConfig + { + Telegram = new TelegramChannelConfig + { + DmPolicy = "open" + } + } + }; + + var sessionManager = new SessionManager(store, config, NullLogger.Instance); + var heartbeatService = new HeartbeatService(config, store, sessionManager, NullLogger.Instance); + var pipeline = new MessagePipeline(); + var middleware = new MiddlewarePipeline([]); + var wsChannel = new WebSocketChannel(config.WebSocket); + await using var adapter = new RecordingChannelAdapter("telegram"); + var agentRuntime = Substitute.For(); + Session? capturedSession = null; + agentRuntime.RunAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + capturedSession = callInfo.Arg(); + return Task.FromResult("ok"); + }); + var toolApprovalService = new ToolApprovalService(); + var approvalAuditStore = new ApprovalAuditStore(storagePath, NullLogger.Instance); + var pairingManager = new OpenClaw.Core.Security.PairingManager(storagePath, NullLogger.Instance); + var commandProcessor = new ChatCommandProcessor(sessionManager); + var runtimeMetrics = new OpenClaw.Core.Observability.RuntimeMetrics(); + var providerRegistry = new LlmProviderRegistry(); + var providerPolicies = new ProviderPolicyService(storagePath, NullLogger.Instance); + var runtimeEvents = new RuntimeEventStore(storagePath, NullLogger.Instance); + var operations = new RuntimeOperationsState + { + ProviderPolicies = providerPolicies, + ProviderRegistry = providerRegistry, + LlmExecution = new GatewayLlmExecutionService( + config, + providerRegistry, + providerPolicies, + runtimeEvents, + runtimeMetrics, + new OpenClaw.Core.Observability.ProviderUsageTracker(), + NullLogger.Instance), + PluginHealth = new PluginHealthService(storagePath, NullLogger.Instance), + ApprovalGrants = new ToolApprovalGrantStore(storagePath, NullLogger.Instance), + RuntimeEvents = runtimeEvents, + OperatorAudit = new OperatorAuditStore(storagePath, NullLogger.Instance), + WebhookDeliveries = new WebhookDeliveryStore(storagePath, NullLogger.Instance), + ActorRateLimits = new ActorRateLimitService(storagePath, NullLogger.Instance), + SessionMetadata = new SessionMetadataStore(storagePath, NullLogger.Instance) + }; + + using var lifetime = new TestApplicationLifetime(); + GatewayWorkers.Start( + lifetime, + NullLogger.Instance, + workerCount: 1, + isNonLoopbackBind: false, + sessionManager, + new ConcurrentDictionary(), + new ConcurrentDictionary(), + pipeline, + middleware, + wsChannel, + agentRuntime, + new Dictionary(StringComparer.Ordinal) + { + ["telegram"] = adapter + }, + config, + cronScheduler: null, + heartbeatService, + toolApprovalService, + approvalAuditStore, + pairingManager, + commandProcessor, + operations); + + await pipeline.InboundWriter.WriteAsync(new InboundMessage + { + ChannelId = "telegram", + SenderId = "analyst", + Text = "status", + MessageId = "msg-route" + }); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var outbound = await adapter.ReadAsync(timeout.Token); + + Assert.Equal("ok", outbound.Text); + Assert.NotNull(capturedSession); + Assert.Equal("route-model", capturedSession!.ModelOverride); + Assert.Equal("Focus on production incidents.", capturedSession.SystemPromptOverride); + Assert.Equal("readonly", capturedSession.RoutePresetId); + Assert.Equal(["session_search", "profile_read"], capturedSession.RouteAllowedTools); + } + + [Fact] + public async Task Start_StreamingVerboseFooter_IsSentBeforeAssistantDone() + { + var storagePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-worker-tests", Guid.NewGuid().ToString("N")); + var store = new FileMemoryStore(storagePath, 4); + var config = new GatewayConfig + { + Memory = new MemoryConfig + { + StoragePath = storagePath + }, + Tooling = new ToolingConfig + { + EnableBrowserTool = false + } + }; + + var sessionManager = new SessionManager(store, config, NullLogger.Instance); + var session = await sessionManager.GetOrCreateByIdAsync("sess-stream", "websocket", "ws-user", CancellationToken.None) + ?? throw new InvalidOperationException("Failed to create session."); + session.VerboseMode = true; + await sessionManager.PersistAsync(session, CancellationToken.None); + + var heartbeatService = new HeartbeatService(config, store, sessionManager, NullLogger.Instance); + var pipeline = new MessagePipeline(); + var middleware = new MiddlewarePipeline([]); + var wsChannel = new WebSocketChannel(config.WebSocket); + var socket = new TestWebSocket(); + Assert.True(wsChannel.TryAddConnectionForTest("ws-user", socket, IPAddress.Loopback, useJsonEnvelope: true)); + var agentRuntime = Substitute.For(); + agentRuntime.RunStreamingAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(StreamVerboseTestEvents()); + var toolApprovalService = new ToolApprovalService(); + var approvalAuditStore = new ApprovalAuditStore(storagePath, NullLogger.Instance); + var pairingManager = new OpenClaw.Core.Security.PairingManager(storagePath, NullLogger.Instance); + var commandProcessor = new ChatCommandProcessor(sessionManager); + var runtimeMetrics = new OpenClaw.Core.Observability.RuntimeMetrics(); + var providerRegistry = new LlmProviderRegistry(); + var providerPolicies = new ProviderPolicyService(storagePath, NullLogger.Instance); + var runtimeEvents = new RuntimeEventStore(storagePath, NullLogger.Instance); + var operations = new RuntimeOperationsState + { + ProviderPolicies = providerPolicies, + ProviderRegistry = providerRegistry, + LlmExecution = new GatewayLlmExecutionService( + config, + providerRegistry, + providerPolicies, + runtimeEvents, + runtimeMetrics, + new OpenClaw.Core.Observability.ProviderUsageTracker(), + NullLogger.Instance), + PluginHealth = new PluginHealthService(storagePath, NullLogger.Instance), + ApprovalGrants = new ToolApprovalGrantStore(storagePath, NullLogger.Instance), + RuntimeEvents = runtimeEvents, + OperatorAudit = new OperatorAuditStore(storagePath, NullLogger.Instance), + WebhookDeliveries = new WebhookDeliveryStore(storagePath, NullLogger.Instance), + ActorRateLimits = new ActorRateLimitService(storagePath, NullLogger.Instance), + SessionMetadata = new SessionMetadataStore(storagePath, NullLogger.Instance) + }; + + using var lifetime = new TestApplicationLifetime(); + GatewayWorkers.Start( + lifetime, + NullLogger.Instance, + workerCount: 1, + isNonLoopbackBind: false, + sessionManager, + new ConcurrentDictionary(), + new ConcurrentDictionary(), + pipeline, + middleware, + wsChannel, + agentRuntime, + new Dictionary(StringComparer.Ordinal), + config, + cronScheduler: null, + heartbeatService, + toolApprovalService, + approvalAuditStore, + pairingManager, + commandProcessor, + operations); + + await pipeline.InboundWriter.WriteAsync(new InboundMessage + { + ChannelId = "websocket", + SenderId = "ws-user", + SessionId = "sess-stream", + Text = "hello", + MessageId = "msg-stream" + }); + + await WaitForAsync( + () => socket.Sent.Count >= 5, + TimeSpan.FromSeconds(2), + "Timed out waiting for websocket stream events."); + + var envelopes = socket.Sent + .Select(static payload => JsonDocument.Parse(payload).RootElement.GetProperty("type").GetString() ?? "") + .ToArray(); + var footerIndex = Array.FindIndex(envelopes, static type => string.Equals(type, "text_delta", StringComparison.Ordinal)); + var doneIndex = Array.FindIndex(envelopes, static type => string.Equals(type, "assistant_done", StringComparison.Ordinal)); + + Assert.Equal("typing_start", envelopes[0]); + Assert.Equal("assistant_chunk", envelopes[1]); + Assert.True(footerIndex >= 0, "Expected verbose footer text_delta event."); + Assert.True(doneIndex >= 0, "Expected assistant_done event."); + Assert.True(footerIndex < doneIndex, "Verbose footer should be emitted before assistant_done."); + Assert.Equal("typing_stop", envelopes[^1]); + } + [Fact] public async Task Start_ApprovalTimeout_RecordsTimedOutAuditAndRuntimeEvent() { @@ -722,7 +962,10 @@ await pipeline.InboundWriter.WriteAsync(new InboundMessage Text = job.Prompt }); - var status = await WaitForHeartbeatStatusAsync(heartbeatService, TimeSpan.FromSeconds(2), static item => item.Outcome == "alert"); + var status = await WaitForHeartbeatStatusAsync(heartbeatService, TimeSpan.FromSeconds(5), static item => item.Outcome == "alert"); + + // Allow outbound worker time to attempt delivery (which will throw) + await Task.Delay(500); Assert.False(status.DeliverySuppressed); Assert.Null(status.LastDeliveredAtUtc); @@ -1230,6 +1473,13 @@ private static async Task WaitForAsync(Func predicate, TimeSpan timeout, s throw new TimeoutException(message); } + private static async IAsyncEnumerable StreamVerboseTestEvents() + { + yield return AgentStreamEvent.TextDelta("hello"); + await Task.Yield(); + yield return AgentStreamEvent.Complete(); + } + private sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable { private readonly CancellationTokenSource _stopping = new(); diff --git a/src/OpenClaw.Tests/SocketBridgeTransportTests.cs b/src/OpenClaw.Tests/SocketBridgeTransportTests.cs index 8af1c85..936b9e7 100644 --- a/src/OpenClaw.Tests/SocketBridgeTransportTests.cs +++ b/src/OpenClaw.Tests/SocketBridgeTransportTests.cs @@ -78,15 +78,29 @@ public async Task DisposeAsync_DoesNotDeleteConfiguredSocketParentDirectory() private static async Task ConnectAsync(string socketPath) { - var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)); - var stream = new NetworkStream(socket, ownsSocket: true); - var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true) + const int maxAttempts = 20; + for (var attempt = 0; attempt < maxAttempts; attempt++) { - AutoFlush = true - }; + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)); + var stream = new NetworkStream(socket, ownsSocket: true); + var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true) + { + AutoFlush = true + }; + + return new TestSocketClient(stream, writer); + } + catch (SocketException) when (attempt < maxAttempts - 1) + { + socket.Dispose(); + await Task.Delay(10); + } + } - return new TestSocketClient(stream, writer); + throw new InvalidOperationException($"Failed to connect to test socket at {socketPath}."); } private sealed class TestSocketClient(Stream stream, StreamWriter writer) : IAsyncDisposable diff --git a/src/OpenClaw.Tests/ToolPresetResolverTests.cs b/src/OpenClaw.Tests/ToolPresetResolverTests.cs index d5d33d5..e7931e2 100644 --- a/src/OpenClaw.Tests/ToolPresetResolverTests.cs +++ b/src/OpenClaw.Tests/ToolPresetResolverTests.cs @@ -73,6 +73,43 @@ public void Resolve_UsesSurfaceBindingWhenNoSessionOverride() Assert.Contains("session_search", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase); } + [Fact] + public void Resolve_RoutePresetId_TakesPrecedenceOverSessionMetadata() + { + var storagePath = CreateStoragePath(); + var metadataStore = new SessionMetadataStore(storagePath, NullLogger.Instance); + metadataStore.Set("sess_route", new SessionMetadataUpdateRequest + { + ActivePresetId = "ops" + }); + + var config = new GatewayConfig(); + config.Tooling.Presets["ops"] = new ToolPresetConfig + { + DenyTools = ["shell"] + }; + config.Tooling.Presets["readonly"] = new ToolPresetConfig + { + DenyTools = ["shell", "write_file"] + }; + + var resolver = new ToolPresetResolver(config, metadataStore); + var resolved = resolver.Resolve( + new Session + { + Id = "sess_route", + ChannelId = "websocket", + SenderId = "user1", + RoutePresetId = "readonly" + }, + ["shell", "write_file", "session_search"]); + + Assert.Equal("readonly", resolved.PresetId); + Assert.DoesNotContain("shell", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("write_file", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase); + Assert.Contains("session_search", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase); + } + private static string CreateStoragePath() { var path = Path.Combine(Path.GetTempPath(), "openclaw-tests", Guid.NewGuid().ToString("N"));