Files
optimclaw/src/channels/wasm/wrapper.rs
T
ced83d5b4d feat: Sandbox jobs (#4)
* Orchestrating jobs and running them in sandboxes

* Fix heartbeat: dynamic max_tokens, empty content guard, notification fallback

- Query /v1/models API for context_length and set max_tokens to half
  (floor 4096) instead of hardcoded 1024; reasoning models like GLM-4.7
  need much larger budgets
- Guard against empty LLM content (reasoning models can burn all tokens
  on chain-of-thought and return content: null)
- Simplify notification routing: try configured channel first, fall back
  to broadcast_all so heartbeat alerts always reach someone
- Add ModelMetadata struct and model_metadata() to LlmProvider trait
- Refactor NearAiChatProvider::list_models into shared fetch_models()
- Add standalone test_heartbeat example for isolated debugging

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Add job detail view with drill-down from jobs list

Click a job row to see full details across four sub-tabs:
Overview (metadata grid, description, state transitions timeline),
Actions (expandable tool call cards with input/output JSON),
Thinking (conversation messages styled by role), and
Files (embedded workspace tree browser).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Strip model-internal XML tags from LLM responses, fix Telegram parse_mode 400

Some models (GLM-4.7, etc.) emit <tool_call>tool_list</tool_call> in the
content field instead of using the OpenAI tool_calls array. This XML leaks
through to channels as text, and Telegram's Markdown parser chokes on the
underscores, returning 400 "can't parse entities".

Two fixes:
- Generalize clean_response() to strip <tool_call>, <function_call>,
  <tool_calls>, and pipe-delimited variants (<|tool_call|>) alongside
  the existing <thinking> tag stripping
- Add Telegram send_message helper with parse_mode fallback: try Markdown
  first, retry as plain text on "can't parse entities" 400 errors

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Add SystemCommand submission type for thread-state-independent commands

System commands (/help, /model, /version, /tools, /ping, /debug) now
bypass thread-state checks and safety validation via a dedicated
Submission::SystemCommand variant. Previously these flowed through
process_user_input() which blocked them during Processing/AwaitingApproval
/Completed states.

- Add /model [name] for runtime model switching with provider validation
- Add active_model_name()/set_model() to LlmProvider trait with RwLock
  hot-swap in both NEAR AI providers
- Rewrite /help with aligned columns grouped by category
- Expand REPL tab-completion from 10 to 23 slash commands
- Remove REPL-local /help interception (now handled by agent)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Add per-tool execution timeouts, auto-create sandbox project dirs, serve built files

The sandbox e2e pipeline (agent -> container -> built website -> browsable URL)
was broken by three gaps: hardcoded 60s timeouts killed sandbox jobs that need
minutes, no auto-created project directory meant container output vanished, and
no HTTP route to browse the built files.

- Add `execution_timeout()` to the `Tool` trait (default 60s), replace all four
  hardcoded `Duration::from_secs(60)` call sites (agent_loop, worker, scheduler,
  worker/runtime) with the per-tool value
- Override to 660s in `RunInSandboxTool` (10 min polling + 60s buffer)
- Auto-create `~/.ironclaw/projects/{uuid}/` when no `project_dir` is specified,
  so every sandbox job gets a persistent bind mount
- Include `project_dir` and `browse_url` in sandbox tool output JSON
- Add `/projects/{id}` and `/projects/{id}/{path}` static file serving routes
  to the web gateway with path traversal protection and MIME type detection
- Add `mime_guess` dependency for content-type detection

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Apply cargo fmt to wizard.rs after merge

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Persist sandbox jobs in DB, fix web UI, unify job model

Sandbox container jobs were invisible to the web UI because they lived
only in ContainerJobManager's in-memory HashMap while the API queried
ContextManager. This persists them to the agent_jobs table and fixes
all six front-end bugs (empty job list, broken back button, empty
actions/thinking tabs, wrong files tab, stuck status, no persistence).

Key changes:
- V4 migration adds project_dir and user_id columns to agent_jobs
- Embedded migrations via refinery (no external CLI needed)
- SandboxJobRecord CRUD in Store with fire-and-forget DB writes
- Unified job_id: sandbox tool generates UUID, passes to ContainerJobManager
- Web API queries DB for sandbox jobs, merges with ContextManager direct jobs
- New endpoints: restart, project file list/read with path traversal protection
- Front-end: rebuild DOM on back navigation, sandbox-aware tabs, job cards in
  chat stream, source badges, restart button for failed/interrupted jobs
- Gateway defaults to enabled, prints Web UI URL on startup
- Stale jobs marked "interrupted" on restart for visibility and restartability

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Secure in-chat auth: tokens never touch the LLM or chat history

Remove the token parameter from tool_auth so the LLM cannot pass raw
API keys. Add dedicated REST (POST /api/chat/auth-token) and WebSocket
(auth_token) endpoints that route tokens directly to ext_mgr.auth(),
completely bypassing the message pipeline, turns, history, and compaction.

Web UI shows an auth card (password input + OAuth button) when the agent
enters auth mode, submitted via the dedicated endpoint. CLI auth mode
interception is unchanged (already secure).

New StatusUpdate::AuthRequired/AuthCompleted variants propagate through
all channels (SSE, WebSocket, REPL, WASM).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Add Claude Code mode for sandbox jobs

Run Claude Code CLI inside Docker containers as an alternative to the
standard worker mode. The bridge spawns `claude -p` with stream-json
output, posts events to the orchestrator, and supports follow-up
prompts via `--resume`.

Key additions:
- `claude-bridge` CLI subcommand and ClaudeBridgeRuntime
- JobMode enum (Worker vs ClaudeCode) with per-mode container config
- Orchestrator endpoints for Claude events and prompt polling
- SSE event variants for real-time Claude Code streaming to frontend
- Claude Code sub-tab in web UI with terminal-style output and input bar
- Database migration for job_mode column and claude_code_events table
- ClaudeCodeConfig with env var support (CLAUDE_CODE_ENABLED, etc.)
- Mode parameter on run_in_sandbox tool schema

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Skip create_job tool when sandbox is enabled to prevent duplicate jobs

When sandbox mode is on, the LLM would call create_job (creating a
pending "direct" entry) then run_in_sandbox (creating a second "sandbox"
entry), producing two jobs in the list for a single user request.

Now register_job_tools() skips create_job when sandbox is enabled since
run_in_sandbox already creates tracked jobs. Also improved the
run_in_sandbox description to guide the LLM to use it directly and to
mention wait=false for async execution.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Web gateway UI quality-of-life improvements

Phase 1: Send button disabled state to prevent double-sends, copy button
on code blocks, confirm() guards on destructive actions, SSE-driven job
list auto-refresh, log filters re-applied on tab switch, jobEvents memory
leak fix (cap at 500, cleanup after 60s).

Phase 2: Toast notification system replacing chat-based system messages,
memory search highlighting with centered snippets, keyboard shortcuts
(Ctrl+1-5 tabs, Ctrl+K focus, Ctrl+N new thread, Escape close/blur),
activity tab toolbar with event type filter and auto-scroll toggle.

Phase 3: Thread sidebar with load/switch/create, thread_id passed with
messages, collapsible to hamburger. Memory inline editing with textarea,
Save/Cancel, POST to /api/memory/write.

Phase 4: Gateway status popover on hover (polls every 30s), extension
install form (name/URL/kind), markdown rendering in memory viewer for
.md files, mobile responsive layout at 768px breakpoint.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Add routines system, remove non-sandbox job mode from web UI

Routines: scheduled & reactive job system with cron and event triggers,
lightweight (single LLM call) and full-job execution modes, guardrails
(cooldown, max concurrent, dedup), and LLM-facing tools for CRUD.

Web UI: remove ContextManager-backed "direct" job mode entirely. Jobs
are now exclusively sandbox-backed (DB + container). Simplify job detail
response, drop dead types (ActionInfo, MessageInfo, MessageToolCallInfo),
fix Browse Files CSS loading (trailing-slash redirect), fix Activity tab
event rendering.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Re-enable chat input on agent completion, auto-auth on tool_activate, recover tool calls from content XML

Three fixes:

1. Chat input stays disabled after agent finishes: the "Done" status
   SSE event now calls enableChatInput() as a safety net when the
   response event is empty or lost. Same for auth_completed and
   cancelAuth().

2. tool_activate never triggers auth: when activation fails due to
   missing authentication, it now auto-initiates the auth flow
   (same pattern as the web API handler). detect_auth_awaiting()
   also matches tool_activate results now.

3. Models like GLM-4.7 emit tool calls as XML tags in content
   (<tool_call>tool_list</tool_call>) instead of using the structured
   tool_calls array. recover_tool_calls_from_content() extracts and
   validates these before falling back to plain text.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Add routines web UI tab, update docs for sandbox-jobs branch

Add full routines management to the web gateway (list, detail, trigger,
toggle, delete) with 7 new API endpoints, response types, and frontend
(HTML, JS, CSS). Update FEATURE_PARITY.md (~23 rows), CLAUDE.md (new
subsystems, config, TODOs), and README.md (architecture diagram,
features, components, fix onboard command).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Bind Telegram bot to owner account during setup

Without owner binding, anyone who discovers the bot can send it messages.
The setup wizard now prompts the user to message their bot, captures their
Telegram user ID via getUpdates, and persists it as telegram_owner_id in
settings. On startup, the owner_id is injected into the WASM channel config
so the existing owner restriction logic drops messages from non-owners.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Move settings from disk to PostgreSQL database

Settings previously lived in three JSON files on disk (settings.json,
mcp-servers.json, session.json). This made them inaccessible from the
web UI and caused redundant disk reads (Settings::load() called 8+
times during startup).

Now all settings live in a `settings` table (user_id + key -> JSONB)
with only 4 bootstrap fields remaining on disk (database_url, pool
size, secrets key source, onboard_completed) since they're needed
before the DB connection exists.

- Add V8 migration for settings table
- Add BootstrapConfig (thin disk file) and Settings DB round-trip
- Add Store CRUD methods for settings (get/set/delete/list/bulk)
- Refactor Config to load from DB (env > DB > default cascade)
- Add SessionManager DB persistence for session tokens
- Add DB-backed MCP server config load/save functions
- Add 6 settings web API endpoints (list/get/set/delete/export/import)
- Add one-time disk-to-DB migration on first boot
- Make CLI config commands async with DB access (disk fallback)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Seed workspace on boot, fix gateway duplicate logs and URL auto-auth

- Add Workspace::seed_if_empty() to create core identity files (README,
  MEMORY, IDENTITY, SOUL, AGENTS, USER, HEARTBEAT) when missing, called
  on every boot without overwriting existing user edits
- Remove duplicate gateway log lines from web/mod.rs (main.rs has the
  useful clickable ?token= URL)
- Auto-authenticate from ?token= URL parameter in the web UI and strip
  the token from the address bar after successful auth

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Harden sandbox security (path traversal + orchestrator auth)

Two vulnerabilities fixed:

1. project_dir path traversal: The create_job tool let the LLM specify
   arbitrary host paths for Docker bind mounts. Removed project_dir from
   the tool schema entirely, and added canonicalization + prefix validation
   at both resolve_project_dir() and the job_manager bind mount point.

2. Orchestrator API auth bypass: worker_auth_middleware was defined but
   never applied. Each handler manually called validate_token(), so any
   new endpoint that forgot would be publicly accessible. Applied the
   middleware as route_layer on all /worker/ routes, removed manual auth
   from all 7 handlers. Bind to 127.0.0.1 on macOS/Windows (Linux keeps
   0.0.0.0 since containers reach host via docker bridge, not loopback).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Rework gateway chat with pinned assistant, pagination, and NEAR AI response chaining

Implements the 4-phase plan for overhauling the web gateway chat:

- Phase 1: Pinned "Assistant" thread at top of sidebar, regular threads below
- Phase 2: Cursor-based history pagination with infinite scroll
- Phase 3: NEAR AI previous_response_id chaining (delta-only messages),
  with fallback to full history on chain errors, and DB persistence of
  chain state across restarts
- Phase 4: SSE thread isolation (events filtered by thread_id)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Add per-request HTTP timeout to WASM host, redact credentials in errors

Three fixes for WASM channel reliability:

1. Per-request timeout: Add optional timeout-ms parameter to http-request
   in both channel and tool WIT interfaces. Telegram long-poll now specifies
   35s (outliving the 30s server-side hold), while regular API calls use
   the 30s default. Fixes the triple-30s timeout race that caused polling
   failures.

2. Credential redaction: reqwest::Error includes the full URL (with injected
   bot tokens) in its Display output. Scrub credential values from error
   messages before logging or returning to WASM.

3. Webhook route registration: Remove tunnel URL gate so webhook routes are
   always available when webhook channels exist, not only when TUNNEL_URL
   is configured.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: Fix clippy warnings in WASM tools and channels

- slack channel: allow dead_code on signing_secret_name (forward compat field)
- gmail tool: use div_ceil() instead of manual (n+2)/3
- google-calendar tool: extract CreateEventParams/UpdateEventParams structs
  to fix too-many-arguments warnings

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix approval flow

* fix: Rebuild bundled telegram.wasm with updated WIT interface

The bundled WASM binary must match the host's WIT definition.
Previous binary was compiled against the old 4-arg http-request;
this rebuild includes the new timeout-ms parameter.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: Load WASM channels from disk instead of bundling in binary

Remove include_bytes! embedding of telegram.wasm. Channels are now
loaded from their build output directories (channels-src/<name>/target/)
during onboarding, then from ~/.ironclaw/channels/ at runtime.

- bundled.rs: locate_channel_artifacts() finds WASM + capabilities from
  build output; IRONCLAW_CHANNELS_SRC env var overrides the default path
- available_channel_names(): only lists channels with build artifacts
- bundled_channel_names(): lists all known channels (manifest)
- Setup wizard uses available_channel_names() to offer installable channels
- Add *.wasm to .gitignore, remove tracked telegram.wasm

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Persist gateway auth token, fix thread hydration race, polish auth screen

Three web gateway UX fixes:

1. Token persistence: Store auth token in sessionStorage so refreshing
   the page doesn't force re-authentication. Hide the auth screen
   immediately when a saved token exists to prevent flash.

2. Thread hydration: Remove the !msgs.is_empty() bail-out in
   maybe_hydrate_thread so that even brand-new (empty) assistant threads
   get hydrated with their correct DB UUID. Previously resolve_thread
   would mint a fresh UUID, causing messages to land in the wrong
   conversation and duplicate threads to appear.

3. Auth screen: Redesign as a centered card with brand, tagline, labeled
   input, and hint text.

Also adds 34 new tests covering session/thread lifecycle, thread
resolution isolation (user, channel, external ID), hydration edge cases,
serialization round-trips, approval flows, and stale mapping recovery.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Use bindgen! for WASM tool wrapper, add dev tool loading

Three changes:

1. Rewrite src/tools/wasm/wrapper.rs to use wasmtime::component::bindgen!
   instead of manual linker.root().func_wrap(). This fixes the
   "component imports instance 'near:agent/host', but a matching
   implementation was not found in the linker" error. All 6 host functions
   (log, now-millis, workspace-read, http-request, secret-exists,
   tool-invoke) are now properly registered under the near:agent/host
   namespace. Also adds WASI support, credential injection, and leak
   detection for HTTP requests made by WASM tools.

2. Add dev tool loading to src/tools/wasm/loader.rs. During startup, the
   loader now also scans tools-src/*/target/wasm32-wasip2/release/ for
   build artifacts that are newer than installed copies. This means during
   development you just rebuild the WASM and restart the host; no manual
   copy step needed. Set IRONCLAW_TOOLS_SRC to override the source dir.

3. Wire up load_dev_tools() in main.rs alongside the existing
   load_from_dir() call.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: Wire main startup and CLI to use DB-backed settings

main.rs now reloads Config from the database after connecting,
attaches the store to the session manager for dual-write tokens,
and loads MCP servers from DB instead of disk. ExtensionManager
and MCP CLI commands use DB when available with disk fallback.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-11 08:31:25 +00:00

2482 lines
88 KiB
Rust

//! WASM channel wrapper implementing the Channel trait.
//!
//! Wraps a prepared WASM channel module and provides the Channel interface.
//! Each callback (on_start, on_http_request, on_poll, on_respond) creates
//! a fresh WASM instance for isolation.
//!
//! # Architecture
//!
//! ```text
//! ┌──────────────────────────────────────────────────────────────┐
//! │ WasmChannel │
//! │ │
//! │ ┌─────────────┐ call_on_* ┌──────────────────────┐ │
//! │ │ Channel │ ────────────> │ execute_callback │ │
//! │ │ Trait │ │ (fresh instance) │ │
//! │ └─────────────┘ └──────────┬───────────┘ │
//! │ │ │
//! │ ▼ │
//! │ ┌──────────────────────────────────────────────────────┐ │
//! │ │ ChannelStoreData │ │
//! │ │ ┌─────────────┐ ┌──────────────────────────────┐ │ │
//! │ │ │ limiter │ │ ChannelHostState │ │ │
//! │ │ └─────────────┘ │ - emitted_messages │ │ │
//! │ │ │ - pending_writes │ │ │
//! │ │ │ - base HostState (logging) │ │ │
//! │ │ └──────────────────────────────┘ │ │
//! │ └──────────────────────────────────────────────────────┘ │
//! └──────────────────────────────────────────────────────────────┘
//! ```
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc, oneshot};
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
use wasmtime::Store;
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::error::WasmChannelError;
use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
use crate::channels::wasm::router::RegisteredEndpoint;
use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
use crate::channels::wasm::schema::ChannelConfig;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
use crate::safety::LeakDetector;
use crate::tools::wasm::LogLevel;
use crate::tools::wasm::WasmResourceLimiter;
// Generate component model bindings from the WIT file
wasmtime::component::bindgen!({
path: "wit/channel.wit",
world: "sandboxed-channel",
async: false,
with: {
// Use our own store data type
},
});
/// Store data for WASM channel execution.
///
/// Contains the resource limiter, channel-specific host state, and WASI context.
struct ChannelStoreData {
limiter: WasmResourceLimiter,
host_state: ChannelHostState,
wasi: WasiCtx,
table: ResourceTable,
/// Injected credentials for URL substitution (e.g., bot tokens).
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
credentials: HashMap<String, String>,
}
impl ChannelStoreData {
fn new(
memory_limit: u64,
channel_name: &str,
capabilities: ChannelCapabilities,
credentials: HashMap<String, String>,
) -> Self {
// Create a minimal WASI context (no filesystem, no env vars for security)
let wasi = WasiCtxBuilder::new().build();
Self {
limiter: WasmResourceLimiter::new(memory_limit),
host_state: ChannelHostState::new(channel_name, capabilities),
wasi,
table: ResourceTable::new(),
credentials,
}
}
/// Inject credentials into a string by replacing placeholders.
///
/// Replaces patterns like `{TELEGRAM_BOT_TOKEN}` or `{WHATSAPP_ACCESS_TOKEN}`
/// with actual values from the injected credentials map. This allows WASM
/// channels to reference credentials without ever seeing the actual values.
///
/// Works on URLs, headers, or any string with credential placeholders.
fn inject_credentials(&self, input: &str, context: &str) -> String {
let mut result = input.to_string();
tracing::debug!(
input_preview = %input.chars().take(100).collect::<String>(),
context = %context,
credential_count = self.credentials.len(),
credential_names = ?self.credentials.keys().collect::<Vec<_>>(),
"Injecting credentials"
);
// Replace all known placeholders from the credentials map
for (name, value) in &self.credentials {
let placeholder = format!("{{{}}}", name);
if result.contains(&placeholder) {
tracing::debug!(
placeholder = %placeholder,
context = %context,
"Found and replacing credential placeholder"
);
result = result.replace(&placeholder, value);
}
}
// Check if any placeholders remain (indicates missing credential)
if result.contains('{') && result.contains('}') {
// Only warn if it looks like an unresolved placeholder (not JSON braces)
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
if let Some(re) = brace_pattern {
if re.is_match(&result) {
tracing::warn!(
context = %context,
"String may contain unresolved credential placeholders"
);
}
}
}
result
}
/// Replace injected credential values with `[REDACTED]` in text.
///
/// Prevents credentials from leaking through error messages, logs, or
/// return values to WASM. reqwest::Error includes the full URL in its
/// Display output, so any error from an injected-URL request will
/// contain the raw credential unless we scrub it.
fn redact_credentials(&self, text: &str) -> String {
let mut result = text.to_string();
for (name, value) in &self.credentials {
if !value.is_empty() {
result = result.replace(value, &format!("[REDACTED:{}]", name));
}
}
result
}
}
// Implement WasiView to provide WASI context and resource table
impl WasiView for ChannelStoreData {
fn ctx(&mut self) -> &mut WasiCtx {
&mut self.wasi
}
fn table(&mut self) -> &mut ResourceTable {
&mut self.table
}
}
// Implement the generated Host trait for channel-host interface
impl near::agent::channel_host::Host for ChannelStoreData {
fn log(&mut self, level: near::agent::channel_host::LogLevel, message: String) {
let log_level = match level {
near::agent::channel_host::LogLevel::Trace => LogLevel::Trace,
near::agent::channel_host::LogLevel::Debug => LogLevel::Debug,
near::agent::channel_host::LogLevel::Info => LogLevel::Info,
near::agent::channel_host::LogLevel::Warn => LogLevel::Warn,
near::agent::channel_host::LogLevel::Error => LogLevel::Error,
};
let _ = self.host_state.log(log_level, message);
}
fn now_millis(&mut self) -> u64 {
self.host_state.now_millis()
}
fn workspace_read(&mut self, path: String) -> Option<String> {
self.host_state.workspace_read(&path).ok().flatten()
}
fn workspace_write(&mut self, path: String, content: String) -> Result<(), String> {
self.host_state
.workspace_write(&path, content)
.map_err(|e| e.to_string())
}
fn http_request(
&mut self,
method: String,
url: String,
headers_json: String,
body: Option<Vec<u8>>,
timeout_ms: Option<u32>,
) -> Result<near::agent::channel_host::HttpResponse, String> {
tracing::info!(
method = %method,
original_url = %url,
body_len = body.as_ref().map(|b| b.len()).unwrap_or(0),
"WASM http_request called"
);
// Inject credentials into URL (e.g., replace {TELEGRAM_BOT_TOKEN} with actual token)
let injected_url = self.inject_credentials(&url, "url");
// Log whether injection happened (without revealing the token)
let url_changed = injected_url != url;
tracing::info!(url_changed = url_changed, "URL after credential injection");
// Check if HTTP is allowed for this URL
self.host_state
.check_http_allowed(&injected_url, &method)
.map_err(|e| {
tracing::error!(error = %e, "HTTP not allowed");
format!("HTTP not allowed: {}", e)
})?;
// Record the request for rate limiting
self.host_state.record_http_request().map_err(|e| {
tracing::error!(error = %e, "Rate limit exceeded");
format!("Rate limit exceeded: {}", e)
})?;
// Parse headers and inject credentials into header values
// This allows patterns like "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
let raw_headers: std::collections::HashMap<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
let headers: std::collections::HashMap<String, String> = raw_headers
.into_iter()
.map(|(k, v)| {
(
k.clone(),
self.inject_credentials(&v, &format!("header:{}", k)),
)
})
.collect();
let headers_changed = headers
.values()
.any(|v| v.contains("Bearer ") && !v.contains('{'));
tracing::debug!(
header_count = headers.len(),
headers_changed = headers_changed,
"Parsed and injected request headers"
);
let url = injected_url;
let leak_detector = LeakDetector::new();
let header_vec: Vec<(String, String)> = headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
leak_detector
.scan_http_request(&url, &header_vec, body.as_deref())
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
// Make the HTTP request using blocking I/O
// We're already in a spawn_blocking context, so we can use block_on
let result = tokio::runtime::Handle::current().block_on(async {
let client = reqwest::Client::new();
let mut request = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
"HEAD" => client.head(&url),
_ => return Err(format!("Unsupported HTTP method: {}", method)),
};
// Add headers
for (key, value) in headers {
request = request.header(&key, &value);
}
// Add body if present
if let Some(body_bytes) = body {
request = request.body(body_bytes);
}
// Send request with caller-specified timeout (default 30s).
// Cap at callback_timeout to prevent outliving the host wrapper.
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64);
let response = request.timeout(timeout).send().await.map_err(|e| {
// Walk the full error chain so we get the actual root cause
// (DNS, TLS, connection refused, etc.) instead of just
// "error sending request for url (...)".
let mut chain = format!("HTTP request failed: {}", e);
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
chain.push_str(&format!(" -> {}", cause));
source = cause.source();
}
chain
})?;
let status = response.status().as_u16();
let response_headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_string(), v.to_string()))
})
.collect();
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
let body = response
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?
.to_vec();
tracing::info!(
status = status,
body_len = body.len(),
"HTTP response received"
);
// Log response body for debugging (truncated at char boundary)
if let Ok(body_str) = std::str::from_utf8(&body) {
let truncated = if body_str.chars().count() > 500 {
format!("{}...", body_str.chars().take(500).collect::<String>())
} else {
body_str.to_string()
};
tracing::debug!(body = %truncated, "Response body");
}
// Leak detection on response body (best-effort)
if let Ok(body_str) = std::str::from_utf8(&body) {
leak_detector
.scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
}
Ok(near::agent::channel_host::HttpResponse {
status,
headers_json,
body,
})
});
// Scrub credential values from error messages before logging or returning
// to WASM. reqwest::Error includes the full URL (with injected credentials)
// in its Display output.
let result = result.map_err(|e| self.redact_credentials(&e));
match &result {
Ok(resp) => {
tracing::info!(status = resp.status, "http_request completed successfully");
}
Err(e) => {
tracing::error!(error = %e, "http_request failed");
}
}
result
}
fn secret_exists(&mut self, name: String) -> bool {
self.host_state.secret_exists(&name)
}
fn emit_message(&mut self, msg: near::agent::channel_host::EmittedMessage) {
tracing::info!(
user_id = %msg.user_id,
user_name = ?msg.user_name,
content_len = msg.content.len(),
"WASM emit_message called"
);
let mut emitted = EmittedMessage::new(msg.user_id.clone(), msg.content.clone());
if let Some(name) = msg.user_name {
emitted = emitted.with_user_name(name);
}
if let Some(tid) = msg.thread_id {
emitted = emitted.with_thread_id(tid);
}
emitted = emitted.with_metadata(msg.metadata_json);
match self.host_state.emit_message(emitted) {
Ok(()) => {
tracing::info!("Message emitted to host state successfully");
}
Err(e) => {
tracing::error!(error = %e, "Failed to emit message to host state");
}
}
}
}
/// A WASM-based channel implementing the Channel trait.
#[allow(dead_code)]
pub struct WasmChannel {
/// Channel name.
name: String,
/// Runtime for WASM execution.
runtime: Arc<WasmChannelRuntime>,
/// Prepared module (compiled WASM).
prepared: Arc<PreparedChannelModule>,
/// Channel capabilities.
capabilities: ChannelCapabilities,
/// Channel configuration JSON (passed to on_start).
/// Wrapped in RwLock to allow updating before start.
config_json: RwLock<String>,
/// Channel configuration returned by on_start.
channel_config: RwLock<Option<ChannelConfig>>,
/// Message sender (for emitting messages to the stream).
/// Wrapped in Arc for sharing with the polling task.
message_tx: Arc<RwLock<Option<mpsc::Sender<IncomingMessage>>>>,
/// Pending responses (for synchronous response handling).
pending_responses: RwLock<HashMap<Uuid, oneshot::Sender<String>>>,
/// Rate limiter for message emission.
/// Wrapped in Arc for sharing with the polling task.
rate_limiter: Arc<RwLock<ChannelEmitRateLimiter>>,
/// Shutdown signal sender.
shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
/// Polling shutdown signal sender (keeps polling alive while held).
poll_shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
/// Registered HTTP endpoints.
endpoints: RwLock<Vec<RegisteredEndpoint>>,
/// Injected credentials for HTTP requests (e.g., bot tokens).
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
/// Wrapped in Arc for sharing with the polling task.
credentials: Arc<RwLock<HashMap<String, String>>>,
/// Background task that repeats typing indicators every 4 seconds.
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
}
impl WasmChannel {
/// Create a new WASM channel.
pub fn new(
runtime: Arc<WasmChannelRuntime>,
prepared: Arc<PreparedChannelModule>,
capabilities: ChannelCapabilities,
config_json: String,
) -> Self {
let name = prepared.name.clone();
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
Self {
name,
runtime,
prepared,
capabilities,
config_json: RwLock::new(config_json),
channel_config: RwLock::new(None),
message_tx: Arc::new(RwLock::new(None)),
pending_responses: RwLock::new(HashMap::new()),
rate_limiter: Arc::new(RwLock::new(rate_limiter)),
shutdown_tx: RwLock::new(None),
poll_shutdown_tx: RwLock::new(None),
endpoints: RwLock::new(Vec::new()),
credentials: Arc::new(RwLock::new(HashMap::new())),
typing_task: RwLock::new(None),
}
}
/// Update the channel config before starting.
///
/// Merges the provided values into the existing config JSON.
/// Call this before `start()` to inject runtime values like tunnel_url.
pub async fn update_config(&self, updates: HashMap<String, serde_json::Value>) {
let mut config_guard = self.config_json.write().await;
// Parse existing config
let mut config: HashMap<String, serde_json::Value> =
serde_json::from_str(&config_guard).unwrap_or_default();
// Merge updates
for (key, value) in updates {
config.insert(key, value);
}
// Serialize back
*config_guard = serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string());
tracing::debug!(
channel = %self.name,
config = %*config_guard,
"Updated channel config"
);
}
/// Set a credential for URL injection.
pub async fn set_credential(&self, name: &str, value: String) {
self.credentials
.write()
.await
.insert(name.to_string(), value);
}
/// Get a snapshot of credentials for use in callbacks.
pub async fn get_credentials(&self) -> HashMap<String, String> {
self.credentials.read().await.clone()
}
/// Get the channel name.
pub fn channel_name(&self) -> &str {
&self.name
}
/// Get the channel capabilities.
pub fn capabilities(&self) -> &ChannelCapabilities {
&self.capabilities
}
/// Get the registered endpoints.
pub async fn endpoints(&self) -> Vec<RegisteredEndpoint> {
self.endpoints.read().await.clone()
}
/// Add channel host functions to the linker using generated bindings.
///
/// Uses the wasmtime::component::bindgen! generated `add_to_linker` function
/// to properly register all host functions with correct component model signatures.
fn add_host_functions(linker: &mut Linker<ChannelStoreData>) -> Result<(), WasmChannelError> {
// Add WASI support (required by the component adapter)
wasmtime_wasi::add_to_linker_sync(linker).map_err(|e| {
WasmChannelError::Config(format!("Failed to add WASI functions: {}", e))
})?;
// Use the generated add_to_linker function from bindgen for our custom interface
near::agent::channel_host::add_to_linker(linker, |state| state).map_err(|e| {
WasmChannelError::Config(format!("Failed to add host functions: {}", e))
})?;
Ok(())
}
/// Create a fresh store configured for WASM execution.
fn create_store(
runtime: &WasmChannelRuntime,
prepared: &PreparedChannelModule,
capabilities: &ChannelCapabilities,
credentials: HashMap<String, String>,
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
let engine = runtime.engine();
let limits = &prepared.limits;
// Create fresh store with channel state (NEAR pattern: fresh instance per call)
let store_data = ChannelStoreData::new(
limits.memory_bytes,
&prepared.name,
capabilities.clone(),
credentials,
);
let mut store = Store::new(engine, store_data);
// Configure fuel if enabled
if runtime.config().fuel_config.enabled {
store
.set_fuel(limits.fuel)
.map_err(|e| WasmChannelError::Config(format!("Failed to set fuel: {}", e)))?;
}
// Configure epoch deadline for timeout backup
store.epoch_deadline_trap();
store.set_epoch_deadline(1);
// Set up resource limiter
store.limiter(|data| &mut data.limiter);
Ok(store)
}
/// Instantiate the WASM component using generated bindings.
fn instantiate_component(
runtime: &WasmChannelRuntime,
prepared: &PreparedChannelModule,
store: &mut Store<ChannelStoreData>,
) -> Result<SandboxedChannel, WasmChannelError> {
let engine = runtime.engine();
// Compile the component (uses cached bytes)
let component = Component::new(engine, prepared.component_bytes())
.map_err(|e| WasmChannelError::Compilation(e.to_string()))?;
// Create linker and add host functions
let mut linker = Linker::new(engine);
Self::add_host_functions(&mut linker)?;
// Instantiate using the generated bindings
let instance = SandboxedChannel::instantiate(store, &component, &linker)
.map_err(|e| WasmChannelError::Instantiation(e.to_string()))?;
Ok(instance)
}
/// Map WASM execution errors to our error types.
fn map_wasm_error(e: anyhow::Error, name: &str, fuel_limit: u64) -> WasmChannelError {
let error_str = e.to_string();
if error_str.contains("out of fuel") {
WasmChannelError::FuelExhausted {
name: name.to_string(),
limit: fuel_limit,
}
} else if error_str.contains("unreachable") {
WasmChannelError::Trapped {
name: name.to_string(),
reason: "unreachable code executed".to_string(),
}
} else {
WasmChannelError::Trapped {
name: name.to_string(),
reason: error_str,
}
}
}
/// Extract host state after callback execution.
fn extract_host_state(
store: &mut Store<ChannelStoreData>,
channel_name: &str,
capabilities: &ChannelCapabilities,
) -> ChannelHostState {
std::mem::replace(
&mut store.data_mut().host_state,
ChannelHostState::new(channel_name, capabilities.clone()),
)
}
/// Execute the on_start callback.
///
/// Returns the channel configuration for HTTP endpoint registration.
async fn call_on_start(&self) -> Result<ChannelConfig, WasmChannelError> {
// If no WASM bytes, return default config (for testing)
if self.prepared.component_bytes.is_empty() {
tracing::info!(
channel = %self.name,
"WASM channel on_start called (no WASM module, returning defaults)"
);
return Ok(ChannelConfig {
display_name: self.prepared.description.clone(),
http_endpoints: Vec::new(),
poll: None,
});
}
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let config_json = self.config_json.read().await.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_start using the generated typed interface
let channel_iface = instance.near_agent_channel();
let wasm_result = channel_iface
.call_on_start(&mut store, &config_json)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
// Convert the result
let config = match wasm_result {
Ok(wit_config) => convert_channel_config(wit_config),
Err(err_msg) => {
return Err(WasmChannelError::CallbackFailed {
name: prepared.name.clone(),
reason: err_msg,
});
}
};
let host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
Ok((config, host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
})?
})
.await;
match result {
Ok(Ok((config, _host_state))) => {
tracing::info!(
channel = %self.name,
display_name = %config.display_name,
endpoints = config.http_endpoints.len(),
"WASM channel on_start completed"
);
Ok(config)
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: self.name.clone(),
callback: "on_start".to_string(),
}),
}
}
/// Execute the on_http_request callback.
///
/// Called when an HTTP request arrives at a registered endpoint.
pub async fn call_on_http_request(
&self,
method: &str,
path: &str,
headers: &HashMap<String, String>,
query: &HashMap<String, String>,
body: &[u8],
secret_validated: bool,
) -> Result<HttpResponse, WasmChannelError> {
tracing::info!(
channel = %self.name,
method = method,
path = path,
body_len = body.len(),
secret_validated = secret_validated,
"call_on_http_request invoked (webhook received)"
);
// Log the body for debugging (truncated at char boundary)
if let Ok(body_str) = std::str::from_utf8(body) {
let truncated = if body_str.chars().count() > 1000 {
format!("{}...", body_str.chars().take(1000).collect::<String>())
} else {
body_str.to_string()
};
tracing::debug!(body = %truncated, "Webhook request body");
}
// Log credentials state (without values)
let creds = self.get_credentials().await;
tracing::info!(
credential_count = creds.len(),
credential_names = ?creds.keys().collect::<Vec<_>>(),
"Credentials available for on_http_request"
);
// If no WASM bytes, return 200 OK (for testing)
if self.prepared.component_bytes.is_empty() {
tracing::debug!(
channel = %self.name,
method = method,
path = path,
"WASM channel on_http_request called (no WASM module)"
);
return Ok(HttpResponse::ok());
}
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await;
// Prepare request data
let method = method.to_string();
let path = path.to_string();
let headers_json = serde_json::to_string(&headers).unwrap_or_default();
let query_json = serde_json::to_string(&query).unwrap_or_default();
let body = body.to_vec();
let channel_name = self.name.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Build the WIT request type
let wit_request = wit_channel::IncomingHttpRequest {
method,
path,
headers_json,
query_json,
body,
secret_validated,
};
// Call on_http_request using the generated typed interface
let channel_iface = instance.near_agent_channel();
let wit_response = channel_iface
.call_on_http_request(&mut store, &wit_request)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let response = convert_http_response(wit_response);
let host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
Ok((response, host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
})?
})
.await;
let channel_name = self.name.clone();
match result {
Ok(Ok((response, mut host_state))) => {
// Process emitted messages
let emitted = host_state.take_emitted_messages();
self.process_emitted_messages(emitted).await?;
tracing::debug!(
channel = %channel_name,
status = response.status,
"WASM channel on_http_request completed"
);
Ok(response)
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: channel_name,
callback: "on_http_request".to_string(),
}),
}
}
/// Execute the on_poll callback.
///
/// Called periodically if polling is configured.
pub async fn call_on_poll(&self) -> Result<(), WasmChannelError> {
// If no WASM bytes, do nothing (for testing)
if self.prepared.component_bytes.is_empty() {
tracing::debug!(
channel = %self.name,
"WASM channel on_poll called (no WASM module)"
);
return Ok(());
}
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface
let channel_iface = instance.near_agent_channel();
channel_iface
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
Ok(((), host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
})?
})
.await;
let channel_name = self.name.clone();
match result {
Ok(Ok(((), mut host_state))) => {
// Process emitted messages
let emitted = host_state.take_emitted_messages();
self.process_emitted_messages(emitted).await?;
tracing::debug!(
channel = %channel_name,
"WASM channel on_poll completed"
);
Ok(())
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: channel_name,
callback: "on_poll".to_string(),
}),
}
}
/// Execute the on_respond callback.
///
/// Called when the agent has a response to send back.
pub async fn call_on_respond(
&self,
message_id: Uuid,
content: &str,
thread_id: Option<&str>,
metadata_json: &str,
) -> Result<(), WasmChannelError> {
tracing::info!(
channel = %self.name,
message_id = %message_id,
content_len = content.len(),
thread_id = ?thread_id,
"call_on_respond invoked"
);
// Log credentials state (without values)
let creds = self.get_credentials().await;
tracing::info!(
credential_count = creds.len(),
credential_names = ?creds.keys().collect::<Vec<_>>(),
"Credentials available for on_respond"
);
// If no WASM bytes, do nothing (for testing)
if self.prepared.component_bytes.is_empty() {
tracing::debug!(
channel = %self.name,
message_id = %message_id,
"WASM channel on_respond called (no WASM module)"
);
return Ok(());
}
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
// Prepare response data
let message_id_str = message_id.to_string();
let content = content.to_string();
let thread_id = thread_id.map(|s| s.to_string());
let metadata_json = metadata_json.to_string();
// Execute in blocking task with timeout
tracing::info!(channel = %channel_name, "Starting on_respond WASM execution");
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
tracing::info!("Creating WASM store for on_respond");
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
tracing::info!("Instantiating WASM component for on_respond");
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Build the WIT response type
let wit_response = wit_channel::AgentResponse {
message_id: message_id_str,
content: content.clone(),
thread_id,
metadata_json,
};
// Truncate at char boundary for logging (avoid panic on multi-byte UTF-8)
let content_preview: String = content.chars().take(50).collect();
tracing::info!(
content_preview = %content_preview,
"Calling WASM on_respond"
);
// Call on_respond using the generated typed interface
let channel_iface = instance.near_agent_channel();
let wasm_result = channel_iface
.call_on_respond(&mut store, &wit_response)
.map_err(|e| {
tracing::error!(error = %e, "WASM on_respond call failed");
Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel)
})?;
tracing::info!(wasm_result = ?wasm_result, "WASM on_respond returned");
// Check for WASM-level errors
if let Err(ref err_msg) = wasm_result {
tracing::error!(error = %err_msg, "WASM on_respond returned error");
return Err(WasmChannelError::CallbackFailed {
name: prepared.name.clone(),
reason: err_msg.clone(),
});
}
let host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
tracing::info!("on_respond WASM execution completed successfully");
Ok(((), host_state))
})
.await
.map_err(|e| {
tracing::error!(error = %e, "spawn_blocking panicked");
WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
}
})?
})
.await;
let channel_name = self.name.clone();
match result {
Ok(Ok(((), _host_state))) => {
tracing::debug!(
channel = %channel_name,
message_id = %message_id,
"WASM channel on_respond completed"
);
Ok(())
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: channel_name,
callback: "on_respond".to_string(),
}),
}
}
/// Execute the on_status callback.
///
/// Called to notify the WASM channel of agent status changes (e.g., typing).
pub async fn call_on_status(
&self,
status: &StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), WasmChannelError> {
// If no WASM bytes, do nothing (for testing)
if self.prepared.component_bytes.is_empty() {
return Ok(());
}
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let wit_update = status_to_wit(status, metadata);
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel();
channel_iface
.call_on_status(&mut store, &wit_update)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
Ok(())
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name.clone(),
reason: e.to_string(),
})?
})
.await;
match result {
Ok(Ok(())) => {
tracing::debug!(
channel = %self.name,
"WASM channel on_status completed"
);
Ok(())
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: self.name.clone(),
callback: "on_status".to_string(),
}),
}
}
/// Execute a single on_status callback with a fresh WASM instance.
///
/// Static method for use by the background typing repeat task (which
/// doesn't have access to `&self`).
async fn execute_status(
channel_name: &str,
runtime: &Arc<WasmChannelRuntime>,
prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>,
timeout: Duration,
wit_update: wit_channel::StatusUpdate,
) -> Result<(), WasmChannelError> {
if prepared.component_bytes.is_empty() {
return Ok(());
}
let runtime = Arc::clone(runtime);
let prepared = Arc::clone(prepared);
let capabilities = capabilities.clone();
let credentials_snapshot = credentials.read().await.clone();
let channel_name_owned = channel_name.to_string();
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel();
channel_iface
.call_on_status(&mut store, &wit_update)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
Ok(())
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name_owned.clone(),
reason: e.to_string(),
})?
})
.await;
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: channel_name.to_string(),
callback: "on_status".to_string(),
}),
}
}
/// Cancel the background typing indicator task if running.
async fn cancel_typing_task(&self) {
if let Some(handle) = self.typing_task.write().await.take() {
handle.abort();
}
}
/// Handle a status update, managing the typing repeat timer.
///
/// On Thinking: fires on_status once, then spawns a background task
/// that repeats the call every 4 seconds (Telegram's typing indicator
/// expires after ~5s).
///
/// On Done/Interrupted/Status: cancels the repeat task, fires on_status once.
/// On StreamChunk: no-op (too noisy).
async fn handle_status_update(
&self,
status: StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
match &status {
StatusUpdate::Thinking(_) => {
// Cancel any existing typing task
self.cancel_typing_task().await;
// Fire once immediately
if let Err(e) = self.call_on_status(&status, metadata).await {
tracing::debug!(
channel = %self.name,
error = %e,
"on_status(Thinking) failed (best-effort)"
);
}
// Spawn background repeater
let channel_name = self.name.clone();
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let credentials = self.credentials.clone();
let callback_timeout = self.runtime.config().callback_timeout;
let wit_update = status_to_wit(&status, metadata);
let handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(4));
// Skip the first tick (we already fired above)
interval.tick().await;
loop {
interval.tick().await;
let wit_update_clone = clone_wit_status_update(&wit_update);
if let Err(e) = Self::execute_status(
&channel_name,
&runtime,
&prepared,
&capabilities,
&credentials,
callback_timeout,
wit_update_clone,
)
.await
{
tracing::debug!(
channel = %channel_name,
error = %e,
"Typing repeat on_status failed (best-effort)"
);
}
}
});
*self.typing_task.write().await = Some(handle);
}
StatusUpdate::StreamChunk(_) => {
// No-op, too noisy
}
_ => {
// Done, Interrupted, Status, ToolStarted, ToolCompleted: cancel and fire once
self.cancel_typing_task().await;
if let Err(e) = self.call_on_status(&status, metadata).await {
tracing::debug!(
channel = %self.name,
error = %e,
"on_status failed (best-effort)"
);
}
}
}
Ok(())
}
/// Process emitted messages from a callback.
async fn process_emitted_messages(
&self,
messages: Vec<EmittedMessage>,
) -> Result<(), WasmChannelError> {
tracing::info!(
channel = %self.name,
message_count = messages.len(),
"Processing emitted messages from WASM callback"
);
if messages.is_empty() {
tracing::debug!(channel = %self.name, "No messages emitted");
return Ok(());
}
let tx_guard = self.message_tx.read().await;
let Some(tx) = tx_guard.as_ref() else {
tracing::error!(
channel = %self.name,
count = messages.len(),
"Messages emitted but no sender available - channel may not be started!"
);
return Ok(());
};
let mut rate_limiter = self.rate_limiter.write().await;
for emitted in messages {
// Check rate limit
if !rate_limiter.check_and_record() {
tracing::warn!(
channel = %self.name,
"Message emission rate limited"
);
return Err(WasmChannelError::EmitRateLimited {
name: self.name.clone(),
});
}
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
}
if let Some(thread_id) = emitted.thread_id {
msg = msg.with_thread(thread_id);
}
// Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
msg = msg.with_metadata(metadata);
}
// Send to stream
tracing::info!(
channel = %self.name,
user_id = %emitted.user_id,
content_len = emitted.content.len(),
"Sending emitted message to agent"
);
if tx.send(msg).await.is_err() {
tracing::error!(
channel = %self.name,
"Failed to send emitted message, channel closed"
);
break;
}
tracing::info!(
channel = %self.name,
"Message successfully sent to agent queue"
);
}
Ok(())
}
/// Start the polling loop if configured.
///
/// Since we can't hold `Arc<Self>` from `&self`, we pass all the components
/// needed for polling to a spawned task. Each poll tick creates a fresh WASM
/// instance (matching our "fresh instance per callback" pattern).
fn start_polling(&self, interval: Duration, shutdown_rx: oneshot::Receiver<()>) {
let channel_name = self.name.clone();
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let message_tx = self.message_tx.clone();
let rate_limiter = self.rate_limiter.clone();
let credentials = self.credentials.clone();
let callback_timeout = self.runtime.config().callback_timeout;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
let mut shutdown = std::pin::pin!(shutdown_rx);
loop {
tokio::select! {
_ = interval_timer.tick() => {
tracing::debug!(
channel = %channel_name,
"Polling tick - calling on_poll"
);
// Execute on_poll with fresh WASM instance
let result = Self::execute_poll(
&channel_name,
&runtime,
&prepared,
&capabilities,
&credentials,
callback_timeout,
).await;
match result {
Ok(emitted_messages) => {
// Process any emitted messages
if !emitted_messages.is_empty() {
if let Err(e) = Self::dispatch_emitted_messages(
&channel_name,
emitted_messages,
&message_tx,
&rate_limiter,
).await {
tracing::warn!(
channel = %channel_name,
error = %e,
"Failed to dispatch emitted messages from poll"
);
}
}
}
Err(e) => {
tracing::warn!(
channel = %channel_name,
error = %e,
"Polling callback failed"
);
}
}
}
_ = &mut shutdown => {
tracing::info!(
channel = %channel_name,
"Polling stopped"
);
break;
}
}
}
});
}
/// Execute a single poll callback with a fresh WASM instance.
///
/// Returns any emitted messages from the callback.
async fn execute_poll(
channel_name: &str,
runtime: &Arc<WasmChannelRuntime>,
prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>,
timeout: Duration,
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
// Skip if no WASM bytes (testing mode)
if prepared.component_bytes.is_empty() {
tracing::debug!(
channel = %channel_name,
"WASM channel on_poll called (no WASM module)"
);
return Ok(Vec::new());
}
let runtime = Arc::clone(runtime);
let prepared = Arc::clone(prepared);
let capabilities = capabilities.clone();
let credentials_snapshot = credentials.read().await.clone();
let channel_name_owned = channel_name.to_string();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface
let channel_iface = instance.near_agent_channel();
channel_iface
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
Ok(host_state)
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
name: channel_name_owned.clone(),
reason: e.to_string(),
})?
})
.await;
match result {
Ok(Ok(mut host_state)) => {
let emitted = host_state.take_emitted_messages();
tracing::debug!(
channel = %channel_name,
emitted_count = emitted.len(),
"WASM channel on_poll completed"
);
Ok(emitted)
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: channel_name.to_string(),
callback: "on_poll".to_string(),
}),
}
}
/// Dispatch emitted messages to the message channel.
///
/// This is a static helper used by the polling loop since it doesn't have
/// access to `&self`.
async fn dispatch_emitted_messages(
channel_name: &str,
messages: Vec<EmittedMessage>,
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
) -> Result<(), WasmChannelError> {
tracing::info!(
channel = %channel_name,
message_count = messages.len(),
"Processing emitted messages from polling callback"
);
let tx_guard = message_tx.read().await;
let Some(tx) = tx_guard.as_ref() else {
tracing::error!(
channel = %channel_name,
count = messages.len(),
"Messages emitted but no sender available - channel may not be started!"
);
return Ok(());
};
let mut limiter = rate_limiter.write().await;
for emitted in messages {
// Check rate limit
if !limiter.check_and_record() {
tracing::warn!(
channel = %channel_name,
"Message emission rate limited"
);
return Err(WasmChannelError::EmitRateLimited {
name: channel_name.to_string(),
});
}
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
}
if let Some(thread_id) = emitted.thread_id {
msg = msg.with_thread(thread_id);
}
// Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
msg = msg.with_metadata(metadata);
}
// Send to stream
tracing::info!(
channel = %channel_name,
user_id = %emitted.user_id,
content_len = emitted.content.len(),
"Sending polled message to agent"
);
if tx.send(msg).await.is_err() {
tracing::error!(
channel = %channel_name,
"Failed to send polled message, channel closed"
);
break;
}
tracing::info!(
channel = %channel_name,
"Message successfully sent to agent queue"
);
}
Ok(())
}
}
#[async_trait]
impl Channel for WasmChannel {
fn name(&self) -> &str {
&self.name
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
// Create message channel
let (tx, rx) = mpsc::channel(256);
*self.message_tx.write().await = Some(tx);
// Create shutdown channel
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
*self.shutdown_tx.write().await = Some(shutdown_tx);
// Call on_start to get configuration
let config = self
.call_on_start()
.await
.map_err(|e| ChannelError::StartupFailed {
name: self.name.clone(),
reason: e.to_string(),
})?;
// Store the config
*self.channel_config.write().await = Some(config.clone());
// Register HTTP endpoints
let mut endpoints = Vec::new();
for endpoint in &config.http_endpoints {
// Validate path is allowed
if !self.capabilities.is_path_allowed(&endpoint.path) {
tracing::warn!(
channel = %self.name,
path = %endpoint.path,
"HTTP endpoint path not allowed by capabilities"
);
continue;
}
endpoints.push(RegisteredEndpoint {
channel_name: self.name.clone(),
path: endpoint.path.clone(),
methods: endpoint.methods.clone(),
require_secret: endpoint.require_secret,
});
}
*self.endpoints.write().await = endpoints;
// Start polling if configured
if let Some(poll_config) = &config.poll {
if poll_config.enabled {
let interval = self
.capabilities
.validate_poll_interval(poll_config.interval_ms)
.map_err(|e| ChannelError::StartupFailed {
name: self.name.clone(),
reason: e,
})?;
// Create shutdown channel for polling and store the sender to keep it alive
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
}
}
tracing::info!(
channel = %self.name,
display_name = %config.display_name,
endpoints = config.http_endpoints.len(),
"WASM channel started"
);
Ok(Box::pin(ReceiverStream::new(rx)))
}
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
// Stop the typing indicator, we're about to send the actual response
self.cancel_typing_task().await;
// Check if there's a pending synchronous response waiter
if let Some(tx) = self.pending_responses.write().await.remove(&msg.id) {
let _ = tx.send(response.content.clone());
}
// Call WASM on_respond
// IMPORTANT: Use the ORIGINAL message's metadata, not the response's metadata.
// The original metadata contains channel-specific routing info (e.g., Telegram chat_id)
// that the WASM channel needs to send the reply to the correct destination.
let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default();
self.call_on_respond(
msg.id,
&response.content,
response.thread_id.as_deref(),
&metadata_json,
)
.await
.map_err(|e| ChannelError::SendFailed {
name: self.name.clone(),
reason: e.to_string(),
})?;
Ok(())
}
async fn send_status(
&self,
status: StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
// Delegate to the typing indicator implementation
self.handle_status_update(status, metadata).await
}
async fn health_check(&self) -> Result<(), ChannelError> {
// Check if we have an active message sender
if self.message_tx.read().await.is_some() {
Ok(())
} else {
Err(ChannelError::HealthCheckFailed {
name: self.name.clone(),
})
}
}
async fn shutdown(&self) -> Result<(), ChannelError> {
// Cancel typing indicator
self.cancel_typing_task().await;
// Send shutdown signal
if let Some(tx) = self.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
// Stop polling by dropping the sender (receiver will complete)
let _ = self.poll_shutdown_tx.write().await.take();
// Clear the message sender
*self.message_tx.write().await = None;
tracing::info!(
channel = %self.name,
"WASM channel shut down"
);
Ok(())
}
}
impl std::fmt::Debug for WasmChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmChannel")
.field("name", &self.name)
.field("prepared", &self.prepared.name)
.finish()
}
}
// ============================================================================
// Shared Channel Wrapper
// ============================================================================
/// A wrapper around `Arc<WasmChannel>` that implements `Channel`.
///
/// This allows sharing the same WasmChannel instance between:
/// - The WasmChannelRouter (for webhook handling)
/// - The ChannelManager (for message streaming and responses)
pub struct SharedWasmChannel {
inner: Arc<WasmChannel>,
}
impl SharedWasmChannel {
/// Create a new shared wrapper.
pub fn new(channel: Arc<WasmChannel>) -> Self {
Self { inner: channel }
}
/// Get the inner Arc.
pub fn inner(&self) -> &Arc<WasmChannel> {
&self.inner
}
}
impl std::fmt::Debug for SharedWasmChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SharedWasmChannel")
.field("inner", &self.inner)
.finish()
}
}
#[async_trait]
impl Channel for SharedWasmChannel {
fn name(&self) -> &str {
self.inner.name()
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
self.inner.start().await
}
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.inner.respond(msg, response).await
}
async fn send_status(
&self,
status: StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
self.inner.send_status(status, metadata).await
}
async fn health_check(&self) -> Result<(), ChannelError> {
self.inner.health_check().await
}
async fn shutdown(&self) -> Result<(), ChannelError> {
self.inner.shutdown().await
}
}
// ============================================================================
// WIT Type Conversion Helpers
// ============================================================================
// Type aliases for the generated WIT types (exported interface)
use exports::near::agent::channel as wit_channel;
/// Convert WIT-generated ChannelConfig to our internal type.
fn convert_channel_config(wit: wit_channel::ChannelConfig) -> ChannelConfig {
ChannelConfig {
display_name: wit.display_name,
http_endpoints: wit
.http_endpoints
.into_iter()
.map(
|ep| crate::channels::wasm::schema::HttpEndpointConfigSchema {
path: ep.path,
methods: ep.methods,
require_secret: ep.require_secret,
},
)
.collect(),
poll: wit
.poll
.map(|p| crate::channels::wasm::schema::PollConfigSchema {
interval_ms: p.interval_ms,
enabled: p.enabled,
}),
}
}
/// Convert WIT-generated OutgoingHttpResponse to our HttpResponse type.
fn convert_http_response(wit: wit_channel::OutgoingHttpResponse) -> HttpResponse {
let headers = serde_json::from_str(&wit.headers_json).unwrap_or_default();
HttpResponse {
status: wit.status,
headers,
body: wit.body,
}
}
/// Convert a StatusUpdate + metadata into the WIT StatusUpdate type.
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate {
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
match status {
StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: msg.clone(),
metadata_json,
},
StatusUpdate::ToolStarted { name } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ToolStarted,
message: name.clone(),
metadata_json,
},
StatusUpdate::ToolCompleted { name, success } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ToolCompleted,
message: format!("{}: {}", name, if *success { "ok" } else { "failed" }),
metadata_json,
},
StatusUpdate::ToolResult { name, preview } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ToolCompleted,
message: format!("{}: {}", name, preview),
metadata_json,
},
StatusUpdate::StreamChunk(chunk) => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: chunk.clone(),
metadata_json,
},
StatusUpdate::Status(msg) => {
// Map well-known status strings to WIT types
let status_type = match msg.as_str() {
"Done" => wit_channel::StatusType::Done,
"Interrupted" => wit_channel::StatusType::Interrupted,
_ => wit_channel::StatusType::Thinking,
};
wit_channel::StatusUpdate {
status: status_type,
message: msg.clone(),
metadata_json,
}
}
StatusUpdate::ApprovalNeeded {
tool_name,
description,
..
} => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!("Approval needed: {} - {}", tool_name, description),
metadata_json,
},
StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!("Job started: {} ({})", title, job_id),
metadata_json,
},
StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!("Auth required: {}", extension_name),
metadata_json,
},
StatusUpdate::AuthCompleted {
extension_name,
success,
..
} => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!(
"Auth {}: {}",
if *success { "completed" } else { "failed" },
extension_name
),
metadata_json,
},
}
}
/// Clone a WIT StatusUpdate (the generated type doesn't derive Clone).
fn clone_wit_status_update(update: &wit_channel::StatusUpdate) -> wit_channel::StatusUpdate {
wit_channel::StatusUpdate {
status: match update.status {
wit_channel::StatusType::Thinking => wit_channel::StatusType::Thinking,
wit_channel::StatusType::Done => wit_channel::StatusType::Done,
wit_channel::StatusType::Interrupted => wit_channel::StatusType::Interrupted,
wit_channel::StatusType::ToolStarted => wit_channel::StatusType::ToolStarted,
wit_channel::StatusType::ToolCompleted => wit_channel::StatusType::ToolCompleted,
},
message: update.message.clone(),
metadata_json: update.metadata_json.clone(),
}
}
/// HTTP response from a WASM channel callback.
#[derive(Debug, Clone)]
pub struct HttpResponse {
/// HTTP status code.
pub status: u16,
/// Response headers.
pub headers: HashMap<String, String>,
/// Response body.
pub body: Vec<u8>,
}
impl HttpResponse {
/// Create an OK response.
pub fn ok() -> Self {
Self {
status: 200,
headers: HashMap::new(),
body: Vec::new(),
}
}
/// Create a JSON response.
pub fn json(value: serde_json::Value) -> Self {
let body = serde_json::to_vec(&value).unwrap_or_default();
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
Self {
status: 200,
headers,
body,
}
}
/// Create an error response.
pub fn error(status: u16, message: &str) -> Self {
Self {
status,
headers: HashMap::new(),
body: message.as_bytes().to_vec(),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::channels::Channel;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
};
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let prepared = Arc::new(PreparedChannelModule {
name: "test".to_string(),
description: "Test channel".to_string(),
component_bytes: Vec::new(),
limits: ResourceLimits::default(),
});
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
}
#[test]
fn test_channel_name() {
let channel = create_test_channel();
assert_eq!(channel.name(), "test");
}
#[test]
fn test_http_response_ok() {
let response = HttpResponse::ok();
assert_eq!(response.status, 200);
assert!(response.body.is_empty());
}
#[test]
fn test_http_response_json() {
let response = HttpResponse::json(serde_json::json!({"key": "value"}));
assert_eq!(response.status, 200);
assert_eq!(
response.headers.get("Content-Type"),
Some(&"application/json".to_string())
);
}
#[test]
fn test_http_response_error() {
let response = HttpResponse::error(400, "Bad request");
assert_eq!(response.status, 400);
assert_eq!(response.body, b"Bad request");
}
#[tokio::test]
async fn test_channel_start_and_shutdown() {
let channel = create_test_channel();
// Start should succeed
let stream = channel.start().await;
assert!(stream.is_ok());
// Health check should pass
assert!(channel.health_check().await.is_ok());
// Shutdown should succeed
assert!(channel.shutdown().await.is_ok());
// Health check should fail after shutdown
assert!(channel.health_check().await.is_err());
}
#[tokio::test]
async fn test_execute_poll_no_wasm_returns_empty() {
// When there's no WASM module (empty component_bytes), execute_poll
// should return an empty vector of messages
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let prepared = Arc::new(PreparedChannelModule {
name: "poll-test".to_string(),
description: "Test channel".to_string(),
component_bytes: Vec::new(), // No WASM bytes
limits: ResourceLimits::default(),
});
let capabilities = ChannelCapabilities::for_channel("poll-test").with_polling(1000);
let credentials = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
let timeout = std::time::Duration::from_secs(5);
let result = WasmChannel::execute_poll(
"poll-test",
&runtime,
&prepared,
&capabilities,
&credentials,
timeout,
)
.await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_dispatch_emitted_messages_sends_to_channel() {
use crate::channels::wasm::host::EmittedMessage;
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
),
));
let messages = vec![
EmittedMessage::new("user1", "Hello from polling!"),
EmittedMessage::new("user2", "Another message"),
];
let result = WasmChannel::dispatch_emitted_messages(
"test-channel",
messages,
&message_tx,
&rate_limiter,
)
.await;
assert!(result.is_ok());
// Verify messages were sent
let msg1 = rx.try_recv().expect("Should receive first message");
assert_eq!(msg1.user_id, "user1");
assert_eq!(msg1.content, "Hello from polling!");
let msg2 = rx.try_recv().expect("Should receive second message");
assert_eq!(msg2.user_id, "user2");
assert_eq!(msg2.content, "Another message");
// No more messages
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn test_dispatch_emitted_messages_no_sender_returns_ok() {
use crate::channels::wasm::host::EmittedMessage;
// No sender available (channel not started)
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
),
));
let messages = vec![EmittedMessage::new("user1", "Hello!")];
// Should return Ok even without a sender (logs warning but doesn't fail)
let result = WasmChannel::dispatch_emitted_messages(
"test-channel",
messages,
&message_tx,
&rate_limiter,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_channel_with_polling_stores_shutdown_sender() {
// Create a channel with polling capabilities
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let prepared = Arc::new(PreparedChannelModule {
name: "poll-channel".to_string(),
description: "Polling test channel".to_string(),
component_bytes: Vec::new(),
limits: ResourceLimits::default(),
});
// Enable polling with a 1 second minimum interval
let capabilities = ChannelCapabilities::for_channel("poll-channel")
.with_path("/webhook/poll")
.with_polling(1000);
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
// Start the channel
let _stream = channel.start().await.expect("Channel should start");
// Verify poll_shutdown_tx is set (polling was started)
// Note: For testing channels without WASM, on_start returns no poll config,
// so polling won't actually be started. This verifies the basic lifecycle.
assert!(channel.health_check().await.is_ok());
// Shutdown should clean up properly
channel.shutdown().await.expect("Shutdown should succeed");
assert!(channel.health_check().await.is_err());
}
#[tokio::test]
async fn test_call_on_poll_no_wasm_succeeds() {
// Verify call_on_poll returns Ok when there's no WASM module
let channel = create_test_channel();
// Start the channel first to set up message_tx
let _stream = channel.start().await.expect("Channel should start");
// call_on_poll should succeed (no-op for no WASM)
let result = channel.call_on_poll().await;
assert!(result.is_ok());
channel.shutdown().await.expect("Shutdown should succeed");
}
#[tokio::test]
async fn test_typing_task_starts_on_thinking() {
let channel = create_test_channel();
let _stream = channel.start().await.expect("Channel should start");
let metadata = serde_json::json!({"chat_id": 123});
// Sending Thinking should succeed (no-op for no WASM)
let result = channel
.send_status(
crate::channels::StatusUpdate::Thinking("Processing...".into()),
&metadata,
)
.await;
assert!(result.is_ok());
// A typing task should have been spawned
assert!(channel.typing_task.read().await.is_some());
// Shutdown should cancel the typing task
channel.shutdown().await.expect("Shutdown should succeed");
assert!(channel.typing_task.read().await.is_none());
}
#[tokio::test]
async fn test_typing_task_cancelled_on_done() {
let channel = create_test_channel();
let _stream = channel.start().await.expect("Channel should start");
let metadata = serde_json::json!({"chat_id": 123});
// Start typing
let _ = channel
.send_status(
crate::channels::StatusUpdate::Thinking("Processing...".into()),
&metadata,
)
.await;
assert!(channel.typing_task.read().await.is_some());
// Send Done status
let _ = channel
.send_status(
crate::channels::StatusUpdate::Status("Done".into()),
&metadata,
)
.await;
// Typing task should be cancelled
assert!(channel.typing_task.read().await.is_none());
channel.shutdown().await.expect("Shutdown should succeed");
}
#[tokio::test]
async fn test_typing_task_replaced_on_new_thinking() {
let channel = create_test_channel();
let _stream = channel.start().await.expect("Channel should start");
let metadata = serde_json::json!({"chat_id": 123});
// Start typing
let _ = channel
.send_status(
crate::channels::StatusUpdate::Thinking("First...".into()),
&metadata,
)
.await;
// Get handle of first task
let first_handle = {
let guard = channel.typing_task.read().await;
guard.as_ref().map(|h| h.id())
};
assert!(first_handle.is_some());
// Start typing again (should replace the previous task)
let _ = channel
.send_status(
crate::channels::StatusUpdate::Thinking("Second...".into()),
&metadata,
)
.await;
// Should still have a typing task, but it's a new one
let second_handle = {
let guard = channel.typing_task.read().await;
guard.as_ref().map(|h| h.id())
};
assert!(second_handle.is_some());
// The task IDs should differ (old one was aborted, new one spawned)
assert_ne!(first_handle, second_handle);
channel.shutdown().await.expect("Shutdown should succeed");
}
#[tokio::test]
async fn test_respond_cancels_typing_task() {
use crate::channels::IncomingMessage;
let channel = create_test_channel();
let _stream = channel.start().await.expect("Channel should start");
let metadata = serde_json::json!({"chat_id": 123});
// Start typing
let _ = channel
.send_status(
crate::channels::StatusUpdate::Thinking("Processing...".into()),
&metadata,
)
.await;
assert!(channel.typing_task.read().await.is_some());
// Respond should cancel the typing task
let msg = IncomingMessage::new("test", "user1", "hello").with_metadata(metadata);
let _ = channel
.respond(&msg, crate::channels::OutgoingResponse::text("response"))
.await;
// Typing task should be gone
assert!(channel.typing_task.read().await.is_none());
channel.shutdown().await.expect("Shutdown should succeed");
}
#[tokio::test]
async fn test_stream_chunk_is_noop() {
let channel = create_test_channel();
let _stream = channel.start().await.expect("Channel should start");
let metadata = serde_json::json!({"chat_id": 123});
// StreamChunk should not start a typing task
let result = channel
.send_status(
crate::channels::StatusUpdate::StreamChunk("chunk".into()),
&metadata,
)
.await;
assert!(result.is_ok());
assert!(channel.typing_task.read().await.is_none());
channel.shutdown().await.expect("Shutdown should succeed");
}
#[test]
fn test_status_to_wit_thinking() {
use super::status_to_wit;
let metadata = serde_json::json!({"chat_id": 42});
let wit = status_to_wit(
&crate::channels::StatusUpdate::Thinking("Processing...".into()),
&metadata,
);
assert!(matches!(
wit.status,
super::wit_channel::StatusType::Thinking
));
assert_eq!(wit.message, "Processing...");
assert!(wit.metadata_json.contains("42"));
}
#[test]
fn test_status_to_wit_done() {
use super::status_to_wit;
let metadata = serde_json::json!(null);
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Done".into()),
&metadata,
);
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
}
#[test]
fn test_status_to_wit_interrupted() {
use super::status_to_wit;
let metadata = serde_json::json!(null);
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Interrupted".into()),
&metadata,
);
assert!(matches!(
wit.status,
super::wit_channel::StatusType::Interrupted
));
}
#[test]
fn test_clone_wit_status_update() {
use super::{clone_wit_status_update, wit_channel};
let original = wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: "hello".to_string(),
metadata_json: "{\"a\":1}".to_string(),
};
let cloned = clone_wit_status_update(&original);
assert!(matches!(cloned.status, wit_channel::StatusType::Thinking));
assert_eq!(cloned.message, "hello");
assert_eq!(cloned.metadata_json, "{\"a\":1}");
}
#[test]
fn test_redact_credentials_replaces_values() {
use super::ChannelStoreData;
let mut creds = std::collections::HashMap::new();
creds.insert(
"TELEGRAM_BOT_TOKEN".to_string(),
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
);
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
let store =
ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds);
let error = "HTTP request failed: error sending request for url \
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
let redacted = store.redact_credentials(error);
assert!(
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
"credential value should be redacted"
);
assert!(
redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"),
"redacted text should contain placeholder name"
);
assert!(
!redacted.contains("s3cret"),
"other credentials should also be redacted"
);
}
#[test]
fn test_redact_credentials_no_op_without_credentials() {
use super::ChannelStoreData;
let store = ChannelStoreData::new(
1024 * 1024,
"test",
ChannelCapabilities::default(),
std::collections::HashMap::new(),
);
let input = "some error message";
assert_eq!(store.redact_credentials(input), input);
}
#[test]
fn test_redact_credentials_skips_empty_values() {
use super::ChannelStoreData;
let mut creds = std::collections::HashMap::new();
creds.insert("EMPTY_TOKEN".to_string(), String::new());
let store =
ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds);
let input = "should not match anything";
assert_eq!(store.redact_credentials(input), input);
}
/// Verify that the block_on-inside-spawn_blocking pattern used by the WASM
/// channel HTTP host function doesn't deadlock or panic.
#[tokio::test]
async fn test_block_on_inside_spawn_blocking_does_not_deadlock() {
let result = tokio::task::spawn_blocking(|| {
tokio::runtime::Handle::current().block_on(async { 42 })
})
.await
.expect("spawn_blocking panicked");
assert_eq!(result, 42);
}
}