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
19 changes: 19 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ pub struct HostGrants {
/// address, one hop at a time, with no cookies; never pages.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub public_images: bool,
/// `readPage` may read the preview fields of a web page on any public
/// HTTPS address, one hop at a time, with no cookies; never its HTML.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub public_pages: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -2270,6 +2274,21 @@ mod tests {
);
images["host"]["publicImages"] = serde_json::json!("true");
assert!(serde_json::from_value::<Manifest>(images).is_err());
assert!(!old.host.as_ref().unwrap().public_pages);
let mut pages = manifest.clone();
pages["host"]["publicPages"] = serde_json::json!(false);
let parsed: Manifest = serde_json::from_value(pages.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), manifest);
pages["host"]["publicPages"] = serde_json::json!(true);
let parsed: Manifest = serde_json::from_value(pages.clone()).unwrap();
assert!(parsed.host.as_ref().unwrap().public_pages);
assert_eq!(serde_json::to_value(&parsed).unwrap(), pages);
assert_ne!(
artifact_from_text(&manifest.to_string(), "export const x = 1".into()).unwrap(),
artifact_from_text(&pages.to_string(), "export const x = 1".into()).unwrap()
);
pages["host"]["publicPages"] = serde_json::json!("true");
assert!(serde_json::from_value::<Manifest>(pages).is_err());
for limit in [1, 4096, MAX_HOST_COMMAND_OUTPUT_BYTES] {
let mut valid = manifest.clone();
valid["host"]["commands"][0]["maxOutputBytes"] = serde_json::json!(limit);
Expand Down
14 changes: 14 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,20 @@ Names that resolve publicly but are reached only through a VPN (such as a
company's internal hosts) are not visible to this check, so the plugin must
refuse them by name on every hop. The site sees the user's IP address.

Declare `host: { publicPages: true }` to let `readPage(url, { timeoutMs? })`
read a link preview's fields from any public HTTPS page. It pins addresses and
follows no redirect, exactly as `publicImages` does, and sends
`Accept: text/html,application/xhtml+xml`. A redirect returns
`{ status, location }`; a 2xx HTML page returns `{ status, preview }`; anything
else returns `{ status }`. Native code reads at most the page's head (512 KiB,
stopping at `</head>` or `<body>`) and returns only `title`, `description`,
`siteName`, `image`, `imageAlt` and `icon`, preferring Open Graph over Twitter
card tags over `<title>` and `<meta name="description">`, with entities decoded,
whitespace collapsed and lengths capped. `image` and `icon` are absolute HTTPS
URLs; read the image with `requestBytes` under `publicImages`. The page's HTML
never reaches the plugin. The timeout defaults to 10000 ms and can be
1–30000 ms. The same VPN caveat applies: refuse internal names on every hop.

Only images come back, but the plugin chooses each URL, so the grant lets it
send data it holds to any public site in the path, query or hostname. It lifts
the bound `networkOrigins` otherwise puts on where a plugin's data can go; the
Expand Down
2 changes: 1 addition & 1 deletion docs/plugin-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Add `"host"` to `inject`, then:
- `await ctx.host.runCommand("status", input?)` runs a declared command (no shell, 5 s deadline, stdin only if `maxInputBytes` is declared). It returns stdout (4096 bytes by default, up to `maxOutputBytes`, at most 1048576), or `null` on failure or when output exceeds the bound. A command may declare `artifact: { path, sha256, siblings?: [{ name, sha256 }] }` (an absolute or `~/…` path, lowercase SHA-256 hex, and files beside it that must match too) to run only those verified bytes, never a program on PATH; `program` is then just its label and the declared `args` are passed to it. Interpreters and programs it calls by name are still found on PATH, so give it an absolute shebang such as `#!/bin/sh` and call programs by absolute path.
- `await ctx.host.request({ url, method?, headers?, body?, timeoutMs? })` returns `{ status, headers, body }` and throws on an undeclared origin or timeout. `url` must use an exact declared HTTPS origin (origin only in the manifest: scheme + host, no path or trailing slash). No redirects, no cookies. Plain `fetch` to other origins is blocked by the app's content security policy, so use this.
- `ctx.host.spawn?.(id, { args?, cwd?, env?, onStdout?, onStderr? })` starts a declared `processes` entry (desktop only; check it exists). Returns `{ write, end, kill, exited }`. Your `args` follow the declared ones.
- `files: true` enables `ctx.host.readFile(path, { cwd? })` (bytes, at most 50 MB) and `ctx.host.resolveDirectory(path)`. Other grants: `mcp: true` (`host.serveMcp`), `managedFiles: true`, `publicImages: true` (`host.requestBytes` may also read PNG, JPEG, GIF, WebP and AVIF images from any public HTTPS site, one hop at a time; check each redirect's `Location` yourself), `agentHooks: ["plugin/kind", ...]` (agent type keys such as `buzz.claude-code/claude-code`).
- `files: true` enables `ctx.host.readFile(path, { cwd? })` (bytes, at most 50 MB) and `ctx.host.resolveDirectory(path)`. Other grants: `mcp: true` (`host.serveMcp`), `managedFiles: true`, `publicImages: true` (`host.requestBytes` may also read PNG, JPEG, GIF, WebP and AVIF images from any public HTTPS site, one hop at a time; check each redirect's `Location` yourself), `publicPages: true` (`host.readPage(url)` returns a public HTTPS page's title, description, site name, image and icon, never its HTML; redirects come back as `location`), `agentHooks: ["plugin/kind", ...]` (agent type keys such as `buzz.claude-code/claude-code`).

## UI

Expand Down
1 change: 1 addition & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ fn main() {
"plugin_host_call_context",
"plugin_host_request",
"plugin_host_request_bytes",
"plugin_host_read_page",
"plugin_host_upload_bytes_once",
"plugin_host_save_bytes",
"plugin_host_delete_managed_file",
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"allow-plugin-host-call-context",
"allow-plugin-host-request",
"allow-plugin-host-request-bytes",
"allow-plugin-host-read-page",
"allow-plugin-host-upload-bytes-once",
"allow-plugin-host-save-bytes",
"allow-plugin-host-delete-managed-file",
Expand Down
42 changes: 42 additions & 0 deletions src-tauri/src/host_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use sha2::{Digest, Sha256};
use std::sync::{Mutex, OnceLock};

mod public_image;
mod public_page;

#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
Expand Down Expand Up @@ -480,6 +481,47 @@ pub(crate) async fn plugin_host_request_bytes(
.map_err(|_| "Host byte request timed out")?
}

/// One public page's preview fields, for a plugin whose manifest declares
/// `host.publicPages`. Redirects come back for the plugin to check.
#[tauri::command]
pub(crate) async fn plugin_host_read_page(
manager: tauri::State<'_, PluginManager>,
id: String,
revision: String,
url: String,
timeout_ms: Option<u64>,
) -> Result<public_page::PageReply, String> {
let timeout = timeout_ms.unwrap_or(10_000);
if !(1..=30_000).contains(&timeout) {
return Err("Invalid page request timeout".into());
}
let (url, _, _) = validate_request(&HostRequest {
url,
method: "GET".into(),
headers: BTreeMap::new(),
body: None,
timeout_ms: Some(timeout),
})?;
let end = tokio::time::Instant::now() + Duration::from_millis(timeout);
let operation = async {
let _place = crate::host_files::byte_admission(end).await?;
if !byte_grants(manager.clone(), &id, &revision)
.await?
.public_pages
{
return Err("Plugin may not read public pages".to_owned());
}
let reply = public_page::read(url, Duration::from_millis(timeout)).await?;
if !byte_grants(manager, &id, &revision).await?.public_pages {
return Err("Plugin may not read public pages".to_owned());
}
Ok(reply)
};
tokio::time::timeout_at(end, operation)
.await
.map_err(|_| "Host page request timed out")?
}

/// How a byte request may reach its URL: a declared origin gets any response;
/// any other origin, with `host.publicImages`, gets only a public image.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down
23 changes: 14 additions & 9 deletions src-tauri/src/host_request/public_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ fn embedded_v4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
/// The addresses to connect to for `url`, when its port is 443 and every
/// address its name resolves to is public. One private answer refuses all.
pub(super) async fn public_addresses(url: &reqwest::Url) -> Result<Vec<SocketAddr>, String> {
const REFUSED: &str = "Public image host is not a public address";
const REFUSED: &str = "Public host is not a public address";
if url.port_or_known_default() != Some(443) {
return Err("Public images are read from port 443 only".into());
return Err("Public hosts are read from port 443 only".into());
}
let addresses: Vec<SocketAddr> = match url.host() {
Some(url::Host::Domain(name)) => tokio::net::lookup_host((name, 443))
.await
.map_err(|_| "Public image host did not resolve")?
.map_err(|_| "Public host did not resolve")?
.collect(),
Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(ip.into(), 443)],
Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(ip.into(), 443)],
Expand All @@ -123,14 +123,19 @@ pub(super) async fn fetch(
max_bytes: usize,
timeout: Duration,
) -> Result<Reply, String> {
read_image(request(url, addresses, timeout)?, max_bytes).await
read_image(
request(url, addresses, &TYPES.join(","), timeout)?,
max_bytes,
)
.await
}

/// The GET `fetch` sends, on a client of its own that can reach only
/// `addresses` for the URL's name.
fn request(
/// A GET for `url`, on a client of its own that can reach only `addresses`
/// for the URL's name.
pub(super) fn request(
url: reqwest::Url,
addresses: &[SocketAddr],
accept: &str,
timeout: Duration,
) -> Result<reqwest::RequestBuilder, String> {
let mut builder = reqwest::Client::builder()
Expand All @@ -148,7 +153,7 @@ fn request(
.map_err(|_| "Host request client is unavailable")?;
Ok(client
.get(url)
.header(reqwest::header::ACCEPT, TYPES.join(","))
.header(reqwest::header::ACCEPT, accept)
.timeout(timeout))
}

Expand Down Expand Up @@ -305,7 +310,7 @@ mod tests {
let (address, server) = reply(response);
let url =
reqwest::Url::parse(&format!("http://image.invalid:{}/img", address.port())).unwrap();
let request = request(url, &[address], Duration::from_secs(5))?;
let request = request(url, &[address], &TYPES.join(","), Duration::from_secs(5))?;
let result = tauri::async_runtime::block_on(read_image(request, max));
let sent = server
.recv_timeout(Duration::from_secs(5))
Expand Down
Loading
Loading