feat: WASM channel attachments with LLM pipeline integration (#596)

* feat: add inbound attachment support to WASM channel system

Add attachment record to WIT interface and implement inbound media
parsing across all four channel implementations (Telegram, Slack,
WhatsApp, Discord). Attachments flow from WASM channels through
EmittedMessage to IncomingMessage with validation (size limits,
MIME allowlist, count caps) at the host boundary.

- Add `attachment` record to `emitted-message` in wit/channel.wit
- Add `IncomingAttachment` struct to channel.rs and re-export
- Add host-side validation (20MB total, 10 max, MIME allowlist)
- Telegram: parse photo, document, audio, video, voice, sticker
- Slack: parse file attachments with url_private
- WhatsApp: parse image, audio, video, document with captions
- Discord: backward-compatible empty attachments
- Update FEATURE_PARITY.md section 7
- Add fixture-based tests per channel and host integration tests

[skip-regression-check]

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

* feat: integrate outbound attachment support and reconcile WIT types (#409)

Reconcile PR #409's outbound attachment work with our inbound attachment
support into a unified design:

WIT type split:
- `inbound-attachment` in channel-host: metadata-only (id, mime_type,
  filename, size_bytes, source_url, storage_key, extracted_text)
- `attachment` in channel: raw bytes (filename, mime_type, data) on
  agent-response for outbound sending

Outbound features (from PR #409):
- `on-broadcast` WIT export for proactive messages without prior inbound
- Telegram: multipart sendPhoto/sendDocument with auto photo→document
  fallback for files >10MB
- wrapper.rs: `call_on_broadcast`, `read_attachments` from disk,
  attachment params threaded through `call_on_respond`
- HTTP tool: `save_to` param for binary downloads to /tmp/ (50MB limit,
  path traversal protection, SSRF-safe redirect following)
- Message tool: allow /tmp/ paths for attachments alongside base_dir
- Credential env var fallback in inject_channel_credentials

Channel updates:
- All 4 channels implement on_broadcast (Telegram full, others stub)
- Telegram: polling_enabled config, adjusted poll timeout
- Inbound attachment types renamed to InboundAttachment in all channels

Tests: 1965 passing (9 new), 0 clippy warnings

[skip-regression-check]

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

* feat: add audio transcription pipeline and extensible WIT attachment design

Add host-side transcription middleware (OpenAI Whisper) that detects audio
attachments with inline data on incoming messages and transcribes them
automatically. Refactor WIT inbound-attachment to use extras-json and a
store-attachment-data host function instead of typed fields, so future
attachment properties (dimensions, codec, etc.) don't require WIT changes
that invalidate all channel plugins.

- Add src/transcription/ module: TranscriptionProvider trait,
  TranscriptionMiddleware, AudioFormat enum, OpenAI Whisper provider
- Add src/config/transcription.rs: TRANSCRIPTION_ENABLED/MODEL/BASE_URL
- Wire middleware into agent message loop via AgentDeps
- WIT: replace data + duration-secs with extras-json + store-attachment-data
- Host: parse extras-json for well-known keys, merge stored binary data
- Telegram: download voice files via store-attachment-data, add duration
  to extras-json, add /file/bot to HTTP allowlist, voice-only placeholder
- Add reqwest multipart feature for Whisper API uploads
- 5 regression tests for transcription middleware

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

* feat: wire attachment processing into LLM pipeline with multimodal image support

Attachments on incoming messages are now augmented into user text via XML tags
before entering the turn system, and images with data are passed as multimodal
content parts (base64 data URIs) to LLM providers. This enables audio transcripts,
document text, and image content to reach the LLM without changes to ChatMessage
serialization or provider interfaces.

- Add src/agent/attachments.rs with augment_with_attachments() and 9 unit tests
- Add ContentPart/ImageUrl types to llm::provider with OpenAI-compatible serde
- Carry image_content_parts transiently on Turn (skipped in serialization)
- Update nearai_chat and rig_adapter to serialize multimodal content
- Add 3 e2e tests verifying attachments flow through the full agent loop

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

* fix: CI failures — formatting, version bumps, and Telegram voice test

- Fix cargo fmt formatting in attachments.rs, nearai_chat.rs, rig_adapter.rs,
  e2e_attachments.rs
- Bump channel registry versions 0.1.0 → 0.2.0 (discord, slack, telegram,
  whatsapp) to satisfy version-bump CI check
- Fix Telegram test_extract_attachments_voice: add missing required `duration`
  field to voice fixture JSON

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

* fix: bump WIT channel version to 0.3.0, fix Telegram voice test, add pre-commit hook

- Bump wit/channel.wit package version 0.2.0 → 0.3.0 (interface changed with
  store-attachment-data)
- Update WIT_CHANNEL_VERSION constant and registry wit_version fields to match
- Fix Telegram test_extract_attachments_voice: gate voice download behind
  #[cfg(target_arch = "wasm32")] so host functions aren't called in native tests,
  update assertions for generated filename and extras_json duration
- Add @0.3.0 linker stubs in wit_compat.rs
- Add .githooks/pre-commit hook that runs scripts/check-version-bumps.sh when
  WIT or extension sources are staged
- Symlink commit-msg regression hook into .githooks/

[skip-regression-check]

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

* refactor: extract voice download from extract_attachments into handle_message

Move download_voice_file + store_attachment_data calls out of
extract_attachments into a separate download_and_store_voice function
called from handle_message. This keeps extract_attachments as a pure
data-mapping function with no host calls, making it fully testable
in native unit tests without #[cfg(target_arch)] gates.

[skip-regression-check]

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

* fix: address PR review comments — security, correctness, and code quality

Security fixes:
- Add path validation to read_attachments (restrict to /tmp/) preventing
  arbitrary file reads from compromised tools
- Escape XML special characters in attachment filenames, MIME types, and
  extracted text to prevent prompt injection via tag spoofing
- Percent-encode file_id in Telegram getFile URL to prevent query injection
- Clone SecretString directly instead of expose_secret().to_string()

Correctness fixes:
- Fix store_attachment_data overwrite accounting: subtract old entry size
  before adding new to prevent inflated totals and false rejections
- Use max(reported, stored_size) for attachment size accounting to prevent
  WASM channels from under-reporting size_bytes to bypass limits
- Add application/octet-stream to MIME allowlist (channels default unknown
  types to this)

Code quality:
- Extract send_response helper in Telegram, deduplicating on_respond and
  on_broadcast
- Rename misleading Discord test to test_parse_slash_command_interaction
- Fix .githooks/commit-msg to use relative symlink (portable across machines)

[skip-regression-check]

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

* feat: add tool_upgrade command + fix TOCTOU in save_to path validation

Add `tool_upgrade` — a new extension management tool that automatically
detects and reinstalls WASM extensions with outdated WIT versions.
Preserves authentication secrets during upgrade. Supports upgrading a
single extension by name or all installed WASM tools/channels at once.

Fix TOCTOU in `validate_save_to_path`: validate the path *before*
creating parent directories, so traversal paths like `/tmp/../../etc/`
cannot cause filesystem mutations outside /tmp before being rejected.

[skip-regression-check]

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

* fix: unify WIT package version to 0.3.0 across tool.wit and all capabilities

tool.wit and channel.wit share the `near:agent` package namespace, so they
must declare the same version. Bumps tool.wit from 0.2.0 to 0.3.0 and
updates all capabilities files and registry entries to match.

Fixes `cargo component build` failure: "package identifier near:[email protected]
does not match previous package name of near:[email protected]"

[skip-regression-check]

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

* fix: move WIT file comments after package declaration

WIT treats `//` comments before `package` as doc comments. When both
tool.wit and channel.wit had header comments, the parser rejected them
as "doc comments on multiple 'package' items". Move comments after the
package declaration in both files.

Also bumps tool registry versions to 0.2.0 to match the WIT 0.3.0 bump.

[skip-regression-check]

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

* feat: display extension versions in gateway Extensions tab

Add version field to InstalledExtension and RegistryEntry types, pipe
through the web API (ExtensionInfo, RegistryEntryInfo), and render as
a badge in the gateway UI for both installed and available extensions.

For installed WASM extensions, version is read from the capabilities
file with a fallback to the registry entry when the local file has no
version (old installations). Bump all extension Cargo.toml and registry
JSON versions from 0.1.0 to 0.2.0 to keep them in sync.

[skip-regression-check]

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

* feat: add document text extraction middleware for PDF, Office, and text files

Extract text from document attachments (PDF, DOCX, PPTX, XLSX, RTF, plain text,
code files) so the LLM can reason about uploaded documents. Uses pdf-extract for
PDFs, zip+XML parsing for Office XML formats, and UTF-8 decode for text files.
Wired into the agent loop after transcription middleware.

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

* fix: download document files in Telegram channel for text extraction

The DocumentExtractionMiddleware needs file bytes in the attachment `data`
field, but only voice files were being downloaded. Document attachments
(PDFs, DOCX, etc.) had empty `data` and a source_url with a credential
placeholder that only works inside the WASM host's http_request.

Add `download_and_store_documents()` that downloads non-voice, non-image,
non-audio attachments via the existing two-step getFile→download flow and
stores bytes via `store_attachment_data` for host-side extraction.

Also rename `download_voice_file` → `download_telegram_file` since it's
generic for any file_id.

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

* fix: allow Office MIME types and increase file download limit for Telegram

Two issues preventing document extraction from Telegram:

1. PPTX/DOCX/XLSX MIME types (application/vnd.*) were dropped by the
   WASM host attachment allowlist — add application/vnd., application/msword,
   and application/rtf prefixes.

2. Telegram file downloads over 10 MB failed with "Response body too large" —
   set max_response_bytes to 20 MB in Telegram capabilities.

[skip-regression-check]

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

* fix: report document extraction errors back to user instead of silently skipping

- Bump max_response_bytes to 50 MB for Telegram file downloads
- When document extraction fails (too large, download error, parse error),
  set extracted_text to a user-friendly error message instead of leaving it
  None. This ensures the LLM tells the user what went wrong.
- On Telegram download failure, set extracted_text with the error so the
  user sees feedback even when the file never reaches the extraction middleware.

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

* feat: store extracted document text in workspace memory for search/recall

After document extraction succeeds, write the extracted text to workspace
memory at `documents/{date}/{filename}`. This enables:
- Full-text and semantic search over past uploaded documents
- Cross-conversation recall ("what did that PDF say?")
- Automatic chunking and embedding via the workspace pipeline

Documents are stored with metadata header (uploader, channel, date, MIME type).
Error messages (extraction failures) are not stored — only successful extractions.

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

* fix: CI failures — formatting, unused assignment warning

- Run cargo fmt on document_extraction and agent_loop modules
- Suppress unused_assignments warning on trace_llm_ref (used only
  behind #[cfg(feature = "libsql")])

[skip-regression-check]

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

* fix: address PR review comments — security, correctness, and code quality

Security fixes:
- Remove SSRF-prone download() from DocumentExtractionMiddleware (#13)
- Sanitize filenames in workspace path to prevent directory traversal (#11)
- Pre-check file size before reading in WASM wrapper to prevent OOM (#2)
- Percent-encode file_id in Telegram source URLs (#7)

Correctness fixes:
- Clear image_content_parts on turn end to prevent memory leak (#1)
- Find first *successful* transcription instead of first overall (#3)
- Enforce data.len() size limit in document extraction (#10)
- Use UTF-8 safe truncation with char_indices() (#12)

Robustness & code quality:
- Add 120s timeout to OpenAI Whisper HTTP client (#5)
- Trim trailing slash from Whisper base_url (#6)
- Allow ~/.ironclaw/ paths in WASM wrapper (#8)
- Return error from on_broadcast in Slack/Discord/WhatsApp (#9)
- Fix doc comment in HTTP tool (#4)

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

* fix: formatting — cargo fmt

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

* fix: address latest PR review — doc comments, error messages, version bumps

- Fix DocumentExtractionMiddleware doc comment (no longer downloads from source_url)
- Fix error message: "no inline data" instead of "no download URL"
- Log error + fallback instead of silent unwrap_or_default on Whisper HTTP client
- Bump all capabilities.json versions from 0.1.0 to 0.2.0 to match Cargo.toml

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

* fix: remove unsupported profile: minimal from CI workflows [skip-regression-check]

dtolnay/rust-toolchain@stable does not accept the 'profile' input
(it was a parameter for the deprecated actions-rs/toolchain action).

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

* fix: merge with latest main — resolve compilation errors and PR review nits

- Add version: None to RegistryEntry/InstalledExtension test constructors
- Fix MessageContent type mismatches in nearai_chat tests (String → MessageContent::Text)
- Fix .contains() calls on MessageContent — use .as_text().unwrap()
- Remove redundant trace_llm_ref = None assignment in test_rig
- Check data size before clone in document extraction to avoid unnecessary allocation

[skip-regression-check]

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-07 18:01:40 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 30790439ee
commit d144484b06
106 changed files with 5638 additions and 419 deletions
+59
View File
@@ -10,6 +10,56 @@ use uuid::Uuid;
use crate::error::ChannelError;
/// Kind of attachment carried on an incoming message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachmentKind {
/// Audio content (voice notes, audio files).
Audio,
/// Image content (photos, screenshots).
Image,
/// Document content (PDFs, files).
Document,
}
impl AttachmentKind {
/// Infer attachment kind from MIME type.
pub fn from_mime_type(mime: &str) -> Self {
let base = mime.split(';').next().unwrap_or(mime).trim();
if base.starts_with("audio/") {
Self::Audio
} else if base.starts_with("image/") {
Self::Image
} else {
Self::Document
}
}
}
/// A file or media attachment on an incoming message.
#[derive(Debug, Clone)]
pub struct IncomingAttachment {
/// Unique identifier within the channel (e.g., Telegram file_id).
pub id: String,
/// What kind of content this is.
pub kind: AttachmentKind,
/// MIME type (e.g., "image/jpeg", "audio/ogg", "application/pdf").
pub mime_type: String,
/// Original filename, if known.
pub filename: Option<String>,
/// File size in bytes, if known.
pub size_bytes: Option<u64>,
/// URL to download the file from the channel's API.
pub source_url: Option<String>,
/// Opaque key for host-side storage (e.g., after download/caching).
pub storage_key: Option<String>,
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
pub extracted_text: Option<String>,
/// Raw file bytes (for small files downloaded by the channel).
pub data: Vec<u8>,
/// Duration in seconds (for audio/video).
pub duration_secs: Option<u32>,
}
/// A message received from an external channel.
#[derive(Debug, Clone)]
pub struct IncomingMessage {
@@ -29,6 +79,8 @@ pub struct IncomingMessage {
pub received_at: DateTime<Utc>,
/// Channel-specific metadata.
pub metadata: serde_json::Value,
/// File or media attachments on this message.
pub attachments: Vec<IncomingAttachment>,
}
impl IncomingMessage {
@@ -47,6 +99,7 @@ impl IncomingMessage {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::Value::Null,
attachments: Vec::new(),
}
}
@@ -67,6 +120,12 @@ impl IncomingMessage {
self.user_name = Some(name.into());
self
}
/// Set attachments.
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
self.attachments = attachments;
self
}
}
/// Stream of incoming messages.
+4 -1
View File
@@ -36,7 +36,10 @@ pub mod wasm;
pub mod web;
mod webhook_server;
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
pub use channel::{
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse,
StatusUpdate,
};
pub use http::HttpChannel;
pub use manager::ChannelManager;
pub use repl::ReplChannel;
+329 -1
View File
@@ -5,6 +5,7 @@
//! - Workspace write access (scoped to channel namespace)
//! - Rate limiting for message emission
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
@@ -17,6 +18,52 @@ const MAX_EMITS_PER_EXECUTION: usize = 100;
/// Maximum message content size (64 KB).
const MAX_MESSAGE_CONTENT_SIZE: usize = 64 * 1024;
/// A file or media attachment on an incoming message.
#[derive(Debug, Clone)]
pub struct Attachment {
/// Unique identifier within the channel (e.g., Telegram file_id).
pub id: String,
/// MIME type (e.g., "image/jpeg", "audio/ogg", "application/pdf").
pub mime_type: String,
/// Original filename, if known.
pub filename: Option<String>,
/// File size in bytes, if known.
pub size_bytes: Option<u64>,
/// URL to download the file from the channel's API.
pub source_url: Option<String>,
/// Opaque key for host-side storage (e.g., after download/caching).
pub storage_key: Option<String>,
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
pub extracted_text: Option<String>,
/// Raw file bytes (for small files downloaded by the channel).
pub data: Vec<u8>,
/// Duration in seconds (for audio/video).
pub duration_secs: Option<u32>,
}
/// Maximum total attachment size per message (20 MB).
const MAX_ATTACHMENT_TOTAL_SIZE: u64 = 20 * 1024 * 1024;
/// Maximum number of attachments per message.
const MAX_ATTACHMENTS_PER_MESSAGE: usize = 10;
/// Allowed MIME type prefixes for attachments.
const ALLOWED_MIME_PREFIXES: &[&str] = &[
"image/",
"audio/",
"video/",
"application/pdf",
"application/vnd.",
"application/msword",
"application/rtf",
"text/",
"application/json",
"application/zip",
"application/gzip",
"application/x-tar",
"application/octet-stream",
];
/// A message emitted by a WASM channel to be sent to the agent.
#[derive(Debug, Clone)]
pub struct EmittedMessage {
@@ -35,6 +82,9 @@ pub struct EmittedMessage {
/// Channel-specific metadata as JSON string.
pub metadata_json: String,
/// File or media attachments on this message.
pub attachments: Vec<Attachment>,
/// Timestamp when the message was emitted.
pub emitted_at_millis: u64,
}
@@ -48,6 +98,7 @@ impl EmittedMessage {
content: content.into(),
thread_id: None,
metadata_json: "{}".to_string(),
attachments: Vec::new(),
emitted_at_millis: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
@@ -72,6 +123,12 @@ impl EmittedMessage {
self.metadata_json = metadata_json.into();
self
}
/// Set attachments.
pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Self {
self.attachments = attachments;
self
}
}
/// A pending workspace write operation.
@@ -112,6 +169,13 @@ pub struct ChannelHostState {
/// Count of emits dropped due to rate limiting.
emits_dropped: usize,
/// Binary data stored for attachments via `store-attachment-data`.
/// Keyed by attachment ID, cleared after callback completes.
attachment_data: HashMap<String, Vec<u8>>,
/// Total bytes stored in attachment_data (for enforcing limits).
attachment_data_total: u64,
}
impl std::fmt::Debug for ChannelHostState {
@@ -141,6 +205,8 @@ impl ChannelHostState {
emit_count: 0,
emit_enabled: true,
emits_dropped: 0,
attachment_data: HashMap::new(),
attachment_data_total: 0,
}
}
@@ -168,6 +234,7 @@ impl ChannelHostState {
///
/// Messages are queued and delivered after callback execution completes.
/// Rate limiting is enforced per-execution and globally.
/// Attachments are validated for count, total size, and MIME type.
pub fn emit_message(&mut self, msg: EmittedMessage) -> Result<(), WasmChannelError> {
// Check per-execution limit
if !self.emit_enabled {
@@ -186,6 +253,9 @@ impl ChannelHostState {
return Ok(());
}
// Validate attachments
let msg = self.validate_attachments(msg);
// Validate message content size
if msg.content.len() > MAX_MESSAGE_CONTENT_SIZE {
tracing::warn!(
@@ -209,6 +279,71 @@ impl ChannelHostState {
Ok(())
}
/// Validate and sanitize attachments on an emitted message.
///
/// Enforces count limits, total size limits, and MIME type allowlist.
/// Invalid attachments are dropped with a warning.
fn validate_attachments(&self, mut msg: EmittedMessage) -> EmittedMessage {
if msg.attachments.is_empty() {
return msg;
}
// Enforce attachment count limit
if msg.attachments.len() > MAX_ATTACHMENTS_PER_MESSAGE {
tracing::warn!(
channel = %self.channel_name,
count = msg.attachments.len(),
max = MAX_ATTACHMENTS_PER_MESSAGE,
"Too many attachments, truncating"
);
msg.attachments.truncate(MAX_ATTACHMENTS_PER_MESSAGE);
}
// Filter by MIME type and enforce total size limit
let mut total_size: u64 = 0;
msg.attachments.retain(|att| {
let mime_ok = ALLOWED_MIME_PREFIXES
.iter()
.any(|prefix| att.mime_type.starts_with(prefix));
if !mime_ok {
tracing::warn!(
channel = %self.channel_name,
mime_type = %att.mime_type,
"Attachment MIME type not allowed, dropping"
);
return false;
}
// Use the larger of reported size_bytes and actual stored data size
// to prevent WASM channels from under-reporting to bypass limits.
let stored_size = self
.attachment_data
.get(&att.id)
.map(|d| d.len() as u64)
.unwrap_or(att.data.len() as u64);
let size = att
.size_bytes
.map(|reported| reported.max(stored_size))
.unwrap_or(stored_size);
if size > 0 {
total_size = total_size.saturating_add(size);
if total_size > MAX_ATTACHMENT_TOTAL_SIZE {
tracing::warn!(
channel = %self.channel_name,
total_size,
max = MAX_ATTACHMENT_TOTAL_SIZE,
"Attachment total size exceeded, dropping"
);
return false;
}
}
true
});
msg
}
/// Take all emitted messages (clears the queue).
pub fn take_emitted_messages(&mut self) -> Vec<EmittedMessage> {
std::mem::take(&mut self.emitted_messages)
@@ -224,6 +359,69 @@ impl ChannelHostState {
self.emits_dropped
}
/// Store binary data for an attachment.
///
/// Called by WASM channels to associate downloaded bytes with an attachment ID.
/// The data is retrieved after callback completion and merged into `Attachment::data`.
pub fn store_attachment_data(
&mut self,
attachment_id: &str,
data: Vec<u8>,
) -> Result<(), WasmChannelError> {
const MAX_PER_ATTACHMENT: u64 = 20 * 1024 * 1024; // 20 MB
const MAX_TOTAL: u64 = 50 * 1024 * 1024; // 50 MB
let size = data.len() as u64;
if size > MAX_PER_ATTACHMENT {
return Err(WasmChannelError::CallbackFailed {
name: self.channel_name.clone(),
reason: format!(
"Attachment data too large: {} bytes (max {})",
size, MAX_PER_ATTACHMENT
),
});
}
// Subtract the old entry size (if overwriting) before adding new size
let old_size = self
.attachment_data
.get(attachment_id)
.map(|d| d.len() as u64)
.unwrap_or(0);
let adjusted_total = self.attachment_data_total.saturating_sub(old_size);
let new_total = adjusted_total.saturating_add(size);
if new_total > MAX_TOTAL {
return Err(WasmChannelError::CallbackFailed {
name: self.channel_name.clone(),
reason: format!(
"Total attachment data too large: {} bytes (max {})",
new_total, MAX_TOTAL
),
});
}
self.attachment_data_total = new_total;
self.attachment_data.insert(attachment_id.to_string(), data);
Ok(())
}
/// Remove stored binary data for a specific attachment ID.
pub fn remove_attachment_data(&mut self, id: &str) -> Option<Vec<u8>> {
if let Some(data) = self.attachment_data.remove(id) {
self.attachment_data_total =
self.attachment_data_total.saturating_sub(data.len() as u64);
Some(data)
} else {
None
}
}
/// Take all stored attachment data (clears the store).
pub fn take_attachment_data(&mut self) -> HashMap<String, Vec<u8>> {
self.attachment_data_total = 0;
std::mem::take(&mut self.attachment_data)
}
/// Write to workspace (scoped to channel namespace).
///
/// Writes are queued and committed after callback execution completes.
@@ -431,7 +629,8 @@ impl ChannelEmitRateLimiter {
mod tests {
use crate::channels::wasm::capabilities::{ChannelCapabilities, EmitRateLimitConfig};
use crate::channels::wasm::host::{
ChannelEmitRateLimiter, ChannelHostState, EmittedMessage, MAX_EMITS_PER_EXECUTION,
Attachment, ChannelEmitRateLimiter, ChannelHostState, EmittedMessage,
MAX_ATTACHMENT_TOTAL_SIZE, MAX_ATTACHMENTS_PER_MESSAGE, MAX_EMITS_PER_EXECUTION,
};
#[test]
@@ -760,4 +959,133 @@ mod tests {
Some("200".to_string())
);
}
// === Attachment validation tests ===
fn make_attachment(id: &str, mime: &str, size: Option<u64>) -> Attachment {
Attachment {
id: id.to_string(),
mime_type: mime.to_string(),
filename: None,
size_bytes: size,
source_url: None,
storage_key: None,
extracted_text: None,
data: Vec::new(),
duration_secs: None,
}
}
#[test]
fn test_emit_message_with_attachments() {
let caps = ChannelCapabilities::for_channel("test");
let mut state = ChannelHostState::new("test", caps);
let msg = EmittedMessage::new("user1", "Check this image")
.with_attachments(vec![make_attachment("file1", "image/jpeg", Some(1024))]);
state.emit_message(msg).unwrap();
let messages = state.take_emitted_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].attachments.len(), 1);
assert_eq!(messages[0].attachments[0].id, "file1");
assert_eq!(messages[0].attachments[0].mime_type, "image/jpeg");
assert_eq!(messages[0].attachments[0].size_bytes, Some(1024));
}
#[test]
fn test_emit_message_no_attachments_backward_compat() {
let caps = ChannelCapabilities::for_channel("test");
let mut state = ChannelHostState::new("test", caps);
let msg = EmittedMessage::new("user1", "Just text");
state.emit_message(msg).unwrap();
let messages = state.take_emitted_messages();
assert_eq!(messages.len(), 1);
assert!(messages[0].attachments.is_empty());
}
#[test]
fn test_attachment_count_limit() {
let caps = ChannelCapabilities::for_channel("test");
let mut state = ChannelHostState::new("test", caps);
let attachments: Vec<Attachment> = (0..MAX_ATTACHMENTS_PER_MESSAGE + 5)
.map(|i| make_attachment(&format!("file{}", i), "image/png", Some(100)))
.collect();
let msg = EmittedMessage::new("user1", "Many files").with_attachments(attachments);
state.emit_message(msg).unwrap();
let messages = state.take_emitted_messages();
assert_eq!(messages[0].attachments.len(), MAX_ATTACHMENTS_PER_MESSAGE);
}
#[test]
fn test_attachment_total_size_limit() {
let caps = ChannelCapabilities::for_channel("test");
let mut state = ChannelHostState::new("test", caps);
// Each file is 1/3 of the limit, so 3 fit but 4th does not
let chunk_size = MAX_ATTACHMENT_TOTAL_SIZE / 3;
let attachments = vec![
make_attachment("file1", "image/png", Some(chunk_size)),
make_attachment("file2", "image/png", Some(chunk_size)),
make_attachment("file3", "image/png", Some(chunk_size)),
make_attachment("file4", "image/png", Some(chunk_size)),
];
let msg = EmittedMessage::new("user1", "Big files").with_attachments(attachments);
state.emit_message(msg).unwrap();
let messages = state.take_emitted_messages();
// Only first 3 fit within the total size limit
assert_eq!(messages[0].attachments.len(), 3);
}
#[test]
fn test_attachment_mime_type_filtering() {
let caps = ChannelCapabilities::for_channel("test");
let mut state = ChannelHostState::new("test", caps);
let attachments = vec![
make_attachment("ok1", "image/jpeg", Some(100)),
make_attachment("bad1", "application/x-executable", Some(100)),
make_attachment("ok2", "application/pdf", Some(100)),
make_attachment("bad2", "application/x-msdos-program", Some(100)),
make_attachment("ok3", "text/plain", Some(100)),
make_attachment("ok4", "audio/mpeg", Some(100)),
make_attachment("ok5", "video/mp4", Some(100)),
];
let msg = EmittedMessage::new("user1", "Mixed files").with_attachments(attachments);
state.emit_message(msg).unwrap();
let messages = state.take_emitted_messages();
let ids: Vec<&str> = messages[0]
.attachments
.iter()
.map(|a| a.id.as_str())
.collect();
assert_eq!(ids, vec!["ok1", "ok2", "ok3", "ok4", "ok5"]);
}
#[test]
fn test_attachment_unknown_size_allowed() {
let caps = ChannelCapabilities::for_channel("test");
let mut state = ChannelHostState::new("test", caps);
let attachments = vec![
make_attachment("file1", "image/jpeg", None),
make_attachment("file2", "image/png", None),
];
let msg = EmittedMessage::new("user1", "No sizes").with_attachments(attachments);
state.emit_message(msg).unwrap();
let messages = state.take_emitted_messages();
assert_eq!(messages[0].attachments.len(), 2);
}
}
+441 -15
View File
@@ -532,9 +532,45 @@ impl near::agent::channel_host::Host for ChannelStoreData {
user_id = %msg.user_id,
user_name = ?msg.user_name,
content_len = msg.content.len(),
attachment_count = msg.attachments.len(),
"WASM emit_message called"
);
let attachments: Vec<crate::channels::wasm::host::Attachment> = msg
.attachments
.into_iter()
.map(|a| {
// Parse extras-json for well-known fields
let extras: serde_json::Value = if a.extras_json.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_str(&a.extras_json).unwrap_or(serde_json::Value::Null)
};
let duration_secs = extras
.get("duration_secs")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
// Merge stored binary data (from store-attachment-data host call)
let data = self
.host_state
.remove_attachment_data(&a.id)
.unwrap_or_default();
crate::channels::wasm::host::Attachment {
id: a.id,
mime_type: a.mime_type,
filename: a.filename,
size_bytes: a.size_bytes,
source_url: a.source_url,
storage_key: a.storage_key,
extracted_text: a.extracted_text,
data,
duration_secs,
}
})
.collect();
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);
@@ -543,6 +579,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
emitted = emitted.with_thread_id(tid);
}
emitted = emitted.with_metadata(msg.metadata_json);
emitted = emitted.with_attachments(attachments);
match self.host_state.emit_message(emitted) {
Ok(()) => {
@@ -554,6 +591,21 @@ impl near::agent::channel_host::Host for ChannelStoreData {
}
}
fn store_attachment_data(
&mut self,
attachment_id: String,
data: Vec<u8>,
) -> Result<(), String> {
tracing::debug!(
attachment_id = %attachment_id,
size = data.len(),
"WASM store_attachment_data called"
);
self.host_state
.store_attachment_data(&attachment_id, data)
.map_err(|e| e.to_string())
}
fn pairing_upsert_request(
&mut self,
channel: String,
@@ -1327,12 +1379,14 @@ impl WasmChannel {
content: &str,
thread_id: Option<&str>,
metadata_json: &str,
attachments: &[String],
) -> Result<(), WasmChannelError> {
tracing::info!(
channel = %self.name,
message_id = %message_id,
content_len = content.len(),
thread_id = ?thread_id,
attachment_count = attachments.len(),
"call_on_respond invoked"
);
@@ -1370,12 +1424,21 @@ impl WasmChannel {
let content = content.to_string();
let thread_id = thread_id.map(|s| s.to_string());
let metadata_json = metadata_json.to_string();
let attachments = attachments.to_vec();
// 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 || {
// Read attachment files from disk before entering WASM
let wit_attachments = read_attachments(&attachments).map_err(|e| {
WasmChannelError::CallbackFailed {
name: prepared.name.clone(),
reason: e,
}
})?;
tracing::info!("Creating WASM store for on_respond");
let mut store = Self::create_store(
&runtime,
@@ -1395,6 +1458,7 @@ impl WasmChannel {
content: content.clone(),
thread_id,
metadata_json,
attachments: wit_attachments,
};
// Truncate at char boundary for logging (avoid panic on multi-byte UTF-8)
@@ -1458,6 +1522,124 @@ impl WasmChannel {
}
}
/// Execute the on_broadcast callback.
///
/// Called to send a proactive message to a user without a prior incoming message.
pub async fn call_on_broadcast(
&self,
user_id: &str,
content: &str,
thread_id: Option<&str>,
attachments: &[String],
) -> Result<(), WasmChannelError> {
tracing::info!(
channel = %self.name,
user_id = %user_id,
content_len = content.len(),
attachment_count = attachments.len(),
"call_on_broadcast invoked"
);
// If no WASM bytes, do nothing (for testing)
if self.prepared.component().is_none() {
tracing::debug!(
channel = %self.name,
"WASM channel on_broadcast 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;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let pairing_store = self.pairing_store.clone();
let user_id = user_id.to_string();
let content = content.to_string();
let thread_id = thread_id.map(|s| s.to_string());
let attachments = attachments.to_vec();
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
// Read attachment files from disk
let wit_attachments = read_attachments(&attachments).map_err(|e| {
WasmChannelError::CallbackFailed {
name: prepared.name.clone(),
reason: e,
}
})?;
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
host_credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let wit_response = wit_channel::AgentResponse {
message_id: String::new(),
content: content.clone(),
thread_id,
metadata_json: String::new(),
attachments: wit_attachments,
};
let channel_iface = instance.near_agent_channel();
let wasm_result = channel_iface
.call_on_broadcast(&mut store, &user_id, &wit_response)
.map_err(|e| {
tracing::error!(error = %e, "WASM on_broadcast call failed");
Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel)
})?;
if let Err(ref err_msg) = wasm_result {
tracing::error!(error = %err_msg, "WASM on_broadcast 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_broadcast WASM execution completed successfully");
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(((), _host_state))) => {
tracing::debug!(
channel = %channel_name,
"WASM channel on_broadcast completed"
);
Ok(())
}
Ok(Err(e)) => Err(e),
Err(_) => Err(WasmChannelError::Timeout {
name: channel_name,
callback: "on_broadcast".to_string(),
}),
}
}
/// Execute the on_status callback.
///
/// Called to notify the WASM channel of agent status changes (e.g., typing).
@@ -1745,7 +1927,7 @@ impl WasmChannel {
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
if let Err(e) = self
.call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json)
.call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json, &[])
.await
{
tracing::warn!(
@@ -1847,6 +2029,27 @@ impl WasmChannel {
msg = msg.with_thread(thread_id);
}
// Convert attachments
if !emitted.attachments.is_empty() {
let incoming_attachments = emitted
.attachments
.iter()
.map(|a| crate::channels::IncomingAttachment {
id: a.id.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
mime_type: a.mime_type.clone(),
filename: a.filename.clone(),
size_bytes: a.size_bytes,
source_url: a.source_url.clone(),
storage_key: a.storage_key.clone(),
extracted_text: a.extracted_text.clone(),
data: a.data.clone(),
duration_secs: a.duration_secs,
})
.collect();
msg = msg.with_attachments(incoming_attachments);
}
// Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
msg = msg.with_metadata(metadata);
@@ -1859,6 +2062,7 @@ impl WasmChannel {
channel = %self.name,
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
"Sending emitted message to agent"
);
@@ -2112,6 +2316,27 @@ impl WasmChannel {
msg = msg.with_thread(thread_id);
}
// Convert attachments
if !emitted.attachments.is_empty() {
let incoming_attachments = emitted
.attachments
.iter()
.map(|a| crate::channels::IncomingAttachment {
id: a.id.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
mime_type: a.mime_type.clone(),
filename: a.filename.clone(),
size_bytes: a.size_bytes,
source_url: a.source_url.clone(),
storage_key: a.storage_key.clone(),
extracted_text: a.extracted_text.clone(),
data: a.data.clone(),
duration_secs: a.duration_secs,
})
.collect();
msg = msg.with_attachments(incoming_attachments);
}
// Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
msg = msg.with_metadata(metadata);
@@ -2130,6 +2355,7 @@ impl WasmChannel {
channel = %channel_name,
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
"Sending polled message to agent"
);
@@ -2257,6 +2483,7 @@ impl Channel for WasmChannel {
&response.content,
response.thread_id.as_deref(),
&metadata_json,
&response.attachments,
)
.await
.map_err(|e| ChannelError::SendFailed {
@@ -2269,24 +2496,15 @@ impl Channel for WasmChannel {
async fn broadcast(
&self,
_user_id: &str,
user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let metadata_json = self
.last_broadcast_metadata
.read()
.await
.clone()
.ok_or_else(|| ChannelError::SendFailed {
name: self.name.clone(),
reason: "No messages received yet — no chat_id available for broadcast".into(),
})?;
self.call_on_respond(
uuid::Uuid::new_v4(),
self.cancel_typing_task().await;
self.call_on_broadcast(
user_id,
&response.content,
response.thread_id.as_deref(),
&metadata_json,
&response.attachments,
)
.await
.map_err(|e| ChannelError::SendFailed {
@@ -2749,6 +2967,79 @@ async fn resolve_channel_host_credentials(
resolved
}
// ============================================================================
// Attachment Helpers
// ============================================================================
/// Maximum total attachment size (50 MB).
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
/// Detect MIME type from file extension using the `mime_guess` crate.
fn mime_from_extension(path: &str) -> String {
mime_guess::from_path(path)
.first_or_octet_stream()
.to_string()
}
/// Read attachment files from disk and build WIT attachment records.
///
/// Validates total size against `MAX_TOTAL_ATTACHMENT_BYTES`.
fn read_attachments(paths: &[String]) -> Result<Vec<wit_channel::Attachment>, String> {
if paths.is_empty() {
return Ok(Vec::new());
}
let mut attachments = Vec::with_capacity(paths.len());
let mut total_bytes: u64 = 0;
let tmp_base = std::path::Path::new("/tmp");
let home_base = dirs::home_dir()
.map(|h| h.join(".ironclaw"))
.unwrap_or_default();
for path in paths {
// Validate paths are under /tmp/ or ~/.ironclaw/ to prevent arbitrary file reads
let validated = crate::tools::builtin::path_utils::validate_path(path, Some(tmp_base))
.or_else(|_| crate::tools::builtin::path_utils::validate_path(path, Some(&home_base)));
let validated = validated.map_err(|e| {
format!(
"Invalid attachment path '{}': must be under /tmp/ or ~/.ironclaw/: {}",
path, e
)
})?;
// Pre-check file size before reading into memory to avoid OOM
let file_size = std::fs::metadata(&validated)
.map_err(|e| format!("Failed to stat attachment '{}': {}", validated.display(), e))?
.len();
total_bytes += file_size;
if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
return Err(format!(
"Total attachment size exceeds {} MB limit",
MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)
));
}
let data = std::fs::read(&validated)
.map_err(|e| format!("Failed to read attachment '{}': {}", validated.display(), e))?;
let filename = validated
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let mime_type = mime_from_extension(path);
attachments.push(wit_channel::Attachment {
filename,
mime_type,
data,
});
}
Ok(attachments)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
@@ -3871,4 +4162,139 @@ mod tests {
// 404 because "000" is not a valid bot token
assert_eq!(result, 404);
}
#[tokio::test]
async fn test_dispatch_emitted_messages_preserves_attachments() {
use crate::channels::wasm::host::{Attachment, 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 attachments = vec![
Attachment {
id: "photo123".to_string(),
mime_type: "image/jpeg".to_string(),
filename: Some("cat.jpg".to_string()),
size_bytes: Some(50_000),
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
storage_key: None,
extracted_text: None,
data: Vec::new(),
duration_secs: None,
},
Attachment {
id: "doc456".to_string(),
mime_type: "application/pdf".to_string(),
filename: Some("report.pdf".to_string()),
size_bytes: Some(120_000),
source_url: None,
storage_key: Some("store/doc456".to_string()),
extracted_text: Some("Report contents...".to_string()),
data: Vec::new(),
duration_secs: None,
},
];
let messages =
vec![EmittedMessage::new("user1", "Check these files").with_attachments(attachments)];
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages(
"test-channel",
messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
)
.await;
assert!(result.is_ok());
let msg = rx.try_recv().expect("Should receive message");
assert_eq!(msg.content, "Check these files");
assert_eq!(msg.attachments.len(), 2);
// Verify first attachment
assert_eq!(msg.attachments[0].id, "photo123");
assert_eq!(msg.attachments[0].mime_type, "image/jpeg");
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string()));
assert_eq!(msg.attachments[0].size_bytes, Some(50_000));
assert_eq!(
msg.attachments[0].source_url,
Some("https://api.telegram.org/file/photo123".to_string())
);
// Verify second attachment
assert_eq!(msg.attachments[1].id, "doc456");
assert_eq!(msg.attachments[1].mime_type, "application/pdf");
assert_eq!(
msg.attachments[1].extracted_text,
Some("Report contents...".to_string())
);
assert_eq!(
msg.attachments[1].storage_key,
Some("store/doc456".to_string())
);
}
#[tokio::test]
async fn test_dispatch_emitted_messages_no_attachments_backward_compat() {
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", "Just text, no attachments")];
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages(
"test-channel",
messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
)
.await;
assert!(result.is_ok());
let msg = rx.try_recv().expect("Should receive message");
assert_eq!(msg.content, "Just text, no attachments");
assert!(msg.attachments.is_empty());
}
#[test]
fn test_mime_from_extension() {
use super::mime_from_extension;
assert_eq!(mime_from_extension("screenshot.png"), "image/png");
assert_eq!(mime_from_extension("photo.JPG"), "image/jpeg");
assert_eq!(mime_from_extension("photo.jpeg"), "image/jpeg");
assert_eq!(mime_from_extension("animation.gif"), "image/gif");
assert_eq!(mime_from_extension("doc.pdf"), "application/pdf");
assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
assert_eq!(mime_from_extension("data.csv"), "text/csv");
assert_eq!(
mime_from_extension("unknown.qqqzzz"),
"application/octet-stream"
);
assert_eq!(mime_from_extension("noext"), "application/octet-stream");
assert_eq!(
mime_from_extension("/home/user/.ironclaw/screenshot.png"),
"image/png"
);
}
}
+1
View File
@@ -62,6 +62,7 @@ pub async fn extensions_list_handler(
has_auth: ext.has_auth,
activation_status,
activation_error: ext.activation_error,
version: ext.version,
}
})
.collect();
+1
View File
@@ -244,6 +244,7 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
_ => Ok(ChatMessage {
role,
content: m.content.as_deref().unwrap_or("").to_string(),
content_parts: Vec::new(),
tool_call_id: None,
name: m.name.clone(),
tool_calls: None,
+2
View File
@@ -1438,6 +1438,7 @@ async fn extensions_list_handler(
has_auth: ext.has_auth,
activation_status,
activation_error: ext.activation_error,
version: ext.version,
}
})
.collect();
@@ -1731,6 +1732,7 @@ async fn extensions_registry_handler(
kind: kind_str,
description: e.description.clone(),
keywords: e.keywords.clone(),
version: e.version.clone(),
}
})
.collect();
+14
View File
@@ -1889,6 +1889,13 @@ function renderAvailableExtensionCard(entry) {
kind.textContent = kindLabels[entry.kind] || entry.kind;
header.appendChild(kind);
if (entry.version) {
const ver = document.createElement('span');
ver.className = 'ext-version';
ver.textContent = 'v' + entry.version;
header.appendChild(ver);
}
card.appendChild(header);
const desc = document.createElement('div');
@@ -2049,6 +2056,13 @@ function renderExtensionCard(ext) {
kind.textContent = kindLabels[ext.kind] || ext.kind;
header.appendChild(kind);
if (ext.version) {
const ver = document.createElement('span');
ver.className = 'ext-version';
ver.textContent = 'v' + ext.version;
header.appendChild(ver);
}
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
if (ext.kind !== 'wasm_channel') {
const authDot = document.createElement('span');
+6
View File
@@ -2438,6 +2438,12 @@ body {
color: var(--warning);
}
.ext-version {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.ext-auth-dot {
width: 8px;
height: 8px;
+5
View File
@@ -401,6 +401,9 @@ pub struct ExtensionInfo {
/// Human-readable error when activation_status is "failed".
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_error: Option<String>,
/// Extension version (semver).
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -503,6 +506,8 @@ pub struct RegistryEntryInfo {
pub description: String,
pub keywords: Vec<String>,
pub installed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Serialize)]