Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion crates/tinyagents-harness/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ tinyinference-embeddings = { path = "../../vendor/tinyinference/crates/tinyinfer
tinyinference-image = { path = "../../vendor/tinyinference/crates/tinyinference-image", version = "0.3.0", optional = true }
tinyinference-video = { path = "../../vendor/tinyinference/crates/tinyinference-video", version = "0.3.0", optional = true }
tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.5.0" }
# Shared URL admission for opt-in remote multimodal attachments.
tinytools-std = { path = "../../vendor/tinytools/crates/tinytools-std", version = "0.5.0", optional = true }
tokio = { workspace = true, features = ["sync", "time", "macros", "rt", "rt-multi-thread", "fs", "io-util", "process"] }
# Storage ports (no database client): the `storage-drivers` adapters put
# the harness `Store` / `AppendStore` on any tinystoragedrivers backend.
Expand All @@ -58,7 +60,7 @@ storage-drivers = ["dep:tinystoragedrivers-core"]
# still spell out the old name keep compiling.
tools = ["builtin-tools"]
builtin-tools = ["dep:chrono-tz"]
multimodal = ["dep:flate2", "dep:reqwest", "dep:tar", "dep:zip"]
multimodal = ["dep:flate2", "dep:reqwest", "dep:tar", "dep:tinytools-std", "dep:zip"]
# Lossless PNG re-compression (`multimodal::optimize_png_lossless`) through
# `oxipng`. Off by default: without it the helper returns `None` and callers
# keep the original bytes, and `oxipng`/`libdeflater` (a C build) are not linked.
Expand Down
9 changes: 7 additions & 2 deletions crates/tinyagents-harness/src/multimodal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,18 @@ format contributes a header naming it plus a content hash.
- **Check the sentinel before the clamp.** `max_files == 0` means *none*;
`FileLimits::effective` clamps it up to `1`. Consulting only the clamped
value would admit one attachment from a source that asked for zero.
- What stays with the host, deliberately: the `reqwest::Client` (proxy/timeout
policy), the `TextExtractor` implementation (which parser, if any, and its
- What stays with the host, deliberately: the `TextExtractor` implementation (which parser, if any, and its
timeout), the attachment stash (where bytes live between ingress and
dispatch), and message-level marker counting (only the host knows its
message type). This module never decides which local paths may be read —
that is `FileLimits::files_disabled`'s lever, not a filesystem allowlist
here.
- Opt-in remote image and file URLs pass TinyTools' URL and DNS guard. The
resolver pins the vetted addresses in its own direct HTTP client and refuses
redirects. It uses 30-second request and 10-second connection timeouts.
The legacy `reqwest::Client` argument remains for source compatibility but is
ignored for remote fetches: its proxy, custom DNS, redirect and timeout
settings cannot safely be inherited from a built client.
- Text extraction failures degrade to a `FilePayload::Reference`. Resolution
errors (read/fetch/MIME/size) remain typed errors for the host to present or
skip according to its own policy.
Expand Down
2 changes: 1 addition & 1 deletion crates/tinyagents-harness/src/multimodal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
//!
//! | Host owns | Why |
//! | --- | --- |
//! | The `reqwest::Client` | proxy configuration and timeouts are host policy; this module borrows one |
//! | The `reqwest::Client` | retained in resolver signatures for compatibility; remote fetches use a DNS-pinned direct client with redirects disabled |
//! | [`TextExtractor`] | which document parser (if any) a host carries, and how long it may run |
//! | The stash policy | which directory holds bytes between ingress and dispatch, how large it may grow, how long files live ([`stash::AttachmentStash`] is the mechanism) |
//! | Message-level counting | only the host knows what its message type is |
Expand Down
49 changes: 39 additions & 10 deletions crates/tinyagents-harness/src/multimodal/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
//! text and not the turn.

use std::path::{Path, PathBuf};
use std::time::Duration;

use async_trait::async_trait;
use reqwest::Client;
Expand Down Expand Up @@ -89,6 +90,33 @@ impl TextExtractor for NoTextExtractor {
}
}

/// Fetch only from addresses vetted by TinyTools. The caller's `Client` cannot
/// be used here: reqwest does not let a request override its DNS or redirect
/// policy, so that client could reach a different address after validation.
async fn guarded_remote_get(source: &str) -> std::result::Result<reqwest::Response, String> {
let validated = tinytools_std::url_guard::validate_url_with_dns_check(source, &[])
.await
.map_err(|error| error.to_string())?;
let client = guarded_remote_client(&validated).map_err(|error| error.to_string())?;
client
.get(validated.url())
.send()
.await
.map_err(|error| error.to_string())
}

fn guarded_remote_client(
validated: &tinytools_std::url_guard::ValidatedUrl,
) -> reqwest::Result<Client> {
Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.resolve_to_addrs(&validated.host, validated.addresses())
.build()
}

// ── Images ───────────────────────────────────────────────────────────────

/// Resolve one `[IMAGE:…]` reference into a canonical `data:` URI.
Expand Down Expand Up @@ -156,14 +184,15 @@ fn resolve_image_data_uri(source: &str, max_bytes: usize) -> Result<String> {
async fn resolve_remote_image(
source: &str,
max_bytes: usize,
remote_client: &Client,
_remote_client: &Client,
Comment thread
senamakel marked this conversation as resolved.
) -> Result<String> {
let response = remote_client.get(source).send().await.map_err(|error| {
MultimodalError::RemoteFetchFailed {
input: source.to_string(),
reason: error.to_string(),
}
})?;
let response =
guarded_remote_get(source)
.await
.map_err(|error| MultimodalError::RemoteFetchFailed {
input: source.to_string(),
reason: error,
})?;

let status = response.status();
if !status.is_success() {
Expand Down Expand Up @@ -565,12 +594,12 @@ async fn read_local_file(source: &str, max_bytes: usize) -> Result<(Vec<u8>, Pat
async fn fetch_remote_file(
source: &str,
max_bytes: usize,
remote_client: &Client,
_remote_client: &Client,
) -> Result<(Vec<u8>, String, Option<String>)> {
let response = remote_client.get(source).send().await.map_err(|error| {
let response = guarded_remote_get(source).await.map_err(|error| {
MultimodalError::RemoteFileFetchFailed {
input: source.to_string(),
reason: error.to_string(),
reason: error,
}
})?;

Expand Down
Loading
Loading