blob: beb3b64cd7800ca334019e50c6755da9879eaaee [file]
//! The fetch tool tap: one web page, fetched for an agent and handed back as text.
//!
//! An agent calls it through the memory server mid-turn. It sends a GET to the URL,
//! follows redirects itself, each hop a fresh request, reads the body up to a cap, and
//! answers as `dm_fetch` shapes it: HTML made text, plain text, JSON and XML as they
//! are, anything else refused as not text. What is not the request itself lives in
//! that crate, shared with the tool server's native fetch, so the two answer alike.
//!
//! The tap decides nothing about where it may go. The host decides every request
//! before it leaves, the redirects included: the scheme, the method, the host against
//! the tool's grant, the address it resolves to, and the label of what the calling
//! session has read. A refused hop is an error naming the URL. Any credential the grant
//! names is put on the request by the host; the tap never holds it. What the tap
//! returns is scanned and kept by the memory server before the agent sees it.
//!
//! Config, JSON, all optional: `{"max_bytes": 2000000, "max_redirects": 5}`.
//! Args, JSON: `{"url": "https://..."}`.
//! Answer, JSON: `{"url", "final_url", "status", "content_type", "title", "text",
//! "truncated", "redirects"}`, where `redirects` lists the URLs followed to, in order.
wit_bindgen::generate!({
path: "../../crates/taps/wit",
world: "tool",
});
use dm_fetch::{Args, Config, Fetched};
use wasi::http::outgoing_handler;
use wasi::http::types::{Fields, Method, OutgoingRequest, Scheme};
use wasi::io::streams::StreamError;
/// A response: status, headers, and the body up to the cap.
struct Answer {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
truncated: bool,
}
impl Answer {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
/// GET one URL, reading at most `cap` bytes of its body.
fn get(url: &str, cap: usize) -> Result<Answer, String> {
let (scheme, authority, path) = dm_fetch::split(url)?;
let scheme = if scheme == "https" {
Scheme::Https
} else {
Scheme::Http
};
let headers = Fields::from_list(&[
("accept".to_string(), dm_fetch::ACCEPT.as_bytes().to_vec()),
(
"user-agent".to_string(),
dm_fetch::USER_AGENT.as_bytes().to_vec(),
),
])
.map_err(|e| format!("headers: {e}"))?;
let req = OutgoingRequest::new(headers);
req.set_method(&Method::Get).map_err(|()| "method")?;
req.set_scheme(Some(&scheme)).map_err(|()| "scheme")?;
req.set_authority(Some(&authority))
.map_err(|()| "authority")?;
req.set_path_with_query(Some(&path)).map_err(|()| "path")?;
let pending =
outgoing_handler::handle(req, None).map_err(|e| format!("request to {url}: {e}"))?;
pending.subscribe().block();
let response = pending
.get()
.ok_or("no response")?
.map_err(|()| "response taken twice")?
.map_err(|e| format!("request to {url}: {e}"))?;
let status = response.status();
let headers = response
.headers()
.entries()
.into_iter()
.map(|(k, v)| {
(
k.to_ascii_lowercase(),
String::from_utf8_lossy(&v).into_owned(),
)
})
.collect();
let body = response.consume().map_err(|()| "body")?;
let stream = body.stream().map_err(|()| "stream")?;
let mut bytes = Vec::new();
let mut truncated = false;
loop {
match stream.blocking_read(64 * 1024) {
Ok(chunk) => {
bytes.extend(chunk);
if bytes.len() > cap {
bytes.truncate(cap);
truncated = true;
break;
}
}
Err(StreamError::Closed) => break,
Err(StreamError::LastOperationFailed(e)) => {
return Err(format!("reading {url}: {}", e.to_debug_string()))
}
}
}
drop(stream);
Ok(Answer {
status,
headers,
body: bytes,
truncated,
})
}
/// GET the URL, following redirects up to the limit. Returns the final answer, the
/// URL it came from, and the URLs followed to.
fn follow(url: &str, config: &Config) -> Result<(Answer, String, Vec<String>), String> {
let mut at = url.to_string();
let mut redirects = Vec::new();
loop {
let answer = get(&at, config.max_bytes)?;
if !dm_fetch::is_redirect(answer.status) {
return Ok((answer, at, redirects));
}
let location = answer
.header("location")
.ok_or_else(|| format!("HTTP {} from {at} without a Location", answer.status))?;
if redirects.len() == config.max_redirects {
return Err(format!(
"more than {} redirects from {url}",
config.max_redirects
));
}
at = dm_fetch::resolve(&at, location);
redirects.push(at.clone());
}
}
struct Component;
impl Guest for Component {
fn call(config: String, args: String) -> Result<String, String> {
let config = Config::parse(&config)?;
let args = Args::parse(&args)?;
let (answer, final_url, redirects) = follow(&args.url, &config)?;
dm_fetch::answer(&Fetched {
url: &args.url,
final_url: &final_url,
status: answer.status,
content_type: answer.header("content-type").unwrap_or(""),
body: &answer.body,
truncated: answer.truncated,
redirects: &redirects,
})
}
}
export!(Component);