Skip to content
Open
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
179 changes: 179 additions & 0 deletions crates/host-core/src/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use ignore::WalkBuilder;
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -549,6 +550,50 @@ const BINARY_EXTENSIONS: &[&str] = &[
"wasm", "war", "webp", "xls", "xlsx", "zip",
];

/// Image types this build will inline for a model that can view images. Both
/// Anthropic-family and OpenAI-family endpoints accept these four.
const IMAGE_EXTENSION_MIME: &[(&str, &str)] = &[
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
];

/// Raw bytes above this are not inlined. Base64 inflates by four thirds, and
/// the strictest per-image ceiling this build talks to is 5 MB encoded, so the
/// bound has to leave room for the encoding.
const MAX_INLINE_IMAGE_BYTES: usize = 3 * 1024 * 1024;

/// Public code for an image that is real but too large to inline.
const TOOL_IMAGE_TOO_LARGE: &str = "TOOL_IMAGE_TOO_LARGE";

fn image_mime_for_extension(extension: &str) -> Option<&'static str> {
IMAGE_EXTENSION_MIME
.iter()
.find(|(ext, _)| *ext == extension)
.map(|(_, mime)| *mime)
}

/// The declared type has to agree with the bytes. A text file renamed `.png`
/// is not an image, and a request carrying one fails as a whole, so the magic
/// number decides and the extension is only a hint.
fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Some("image/gif");
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
None
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolsExecuteParams {
Expand Down Expand Up @@ -1209,6 +1254,48 @@ fn root_label(root_kind: ToolRoot) -> &'static str {
}
}

/// One image, in the shape the runtime turns into a model-visible image block:
/// `{text, images: [{data, mimeType}]}`. The runtime attaches the image only
/// when the active model accepts images, and keeps the text either way, so the
/// text has to stand on its own for a model that cannot see it.
fn read_image(
resolved: &Path,
display: &str,
root_kind: ToolRoot,
size: u64,
) -> Result<Value, (String, String)> {
if size > MAX_INLINE_IMAGE_BYTES as u64 {
return Err((
TOOL_IMAGE_TOO_LARGE.into(),
format!(
"{display} is {:.1} MB, above the {} MB bound for reading an image into the model context; ask the user to attach it to the prompt instead, or shrink it first",
size as f64 / (1024.0 * 1024.0),
MAX_INLINE_IMAGE_BYTES / (1024 * 1024)
),
));
}
let bytes =
std::fs::read(resolved).map_err(|e| ("TOOL_FAILED".into(), format!("read failed: {e}")))?;
let Some(mime) = sniff_image_mime(&bytes) else {
return Err((
"TOOL_BINARY_CONTENT".into(),
format!(
"{display} has an image extension but its bytes are not a PNG, JPEG, GIF or WebP image"
),
));
};
Ok(json!({
"path": display,
"root": root_label(root_kind),
"text": format!(
"{display} is a {mime} image ({} bytes). It is attached for models that can view images; if you cannot see it, say so rather than guessing at its contents.",
bytes.len()
),
"images": [{ "data": B64.encode(&bytes), "mimeType": mime }],
"fileBytes": bytes.len(),
}))
}

fn tool_read(
workspace: Option<&Path>,
scratch: Option<&Path>,
Expand Down Expand Up @@ -1261,6 +1348,13 @@ fn tool_read(
.extension()
.map(|ext| ext.to_string_lossy().to_lowercase());
if let Some(ext) = &extension {
// An image the model may be shown is a different outcome from a binary
// it cannot read: the file has no text, but it does have content, and
// the runtime turns this shape into an image block for a model that
// accepts one (issue #711).
if image_mime_for_extension(ext).is_some() {
return read_image(&resolved, &display, root_kind, meta.len());
}
if BINARY_EXTENSIONS.contains(&ext.as_str()) {
return Err((
"TOOL_BINARY_CONTENT".into(),
Expand Down Expand Up @@ -3249,6 +3343,91 @@ mod tests {
}
}

/// A 1x1 PNG, so the fixture is a real image and not a renamed text file.
fn tiny_png() -> Vec<u8> {
const B64_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8AAAwAB/AL+2w0AAAAASUVORK5CYII=";
B64.decode(B64_PNG).expect("fixture decodes")
}

#[tokio::test]
async fn read_returns_an_image_for_the_model_to_view() {
let dir = tempfile::tempdir().unwrap();
let png = tiny_png();
std::fs::write(dir.path().join("shot.png"), &png).unwrap();

let result = execute_tool(
Some(dir.path()),
None,
"Read",
&serde_json::json!({ "path": "shot.png" }),
5_000,
)
.await;

assert!(
result.ok,
"an image read is not a failure: {:?}",
result.error_code
);
// The shape the runtime turns into a model-visible image block.
let images = result.content["images"]
.as_array()
.expect("images array is present");
assert_eq!(images.len(), 1);
assert_eq!(images[0]["mimeType"], "image/png");
assert_eq!(
images[0]["data"].as_str().expect("base64 payload"),
B64.encode(&png)
);
assert_eq!(result.content["fileBytes"], png.len());
// The text stands on its own for a model that cannot see images.
let text = result.content["text"].as_str().expect("text is present");
assert!(text.contains("shot.png"), "{text}");
assert!(text.contains("image/png"), "{text}");
assert!(text.contains("cannot see it"), "{text}");
}

#[tokio::test]
async fn read_refuses_an_image_extension_whose_bytes_are_not_an_image() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.png"), "just text, renamed\n").unwrap();

let result = execute_tool(
Some(dir.path()),
None,
"Read",
&serde_json::json!({ "path": "notes.png" }),
5_000,
)
.await;

assert!(!result.ok);
assert_eq!(result.error_code.as_deref(), Some("TOOL_BINARY_CONTENT"));
}

#[tokio::test]
async fn read_refuses_an_image_too_large_to_inline() {
let dir = tempfile::tempdir().unwrap();
let mut big = tiny_png();
big.resize(MAX_INLINE_IMAGE_BYTES + 1, 0);
std::fs::write(dir.path().join("huge.png"), &big).unwrap();

let result = execute_tool(
Some(dir.path()),
None,
"Read",
&serde_json::json!({ "path": "huge.png" }),
5_000,
)
.await;

assert!(!result.ok);
assert_eq!(result.error_code.as_deref(), Some("TOOL_IMAGE_TOO_LARGE"));
let error = result.content["error"].as_str().expect("error text");
assert!(error.contains("attach it to the prompt"), "{error}");
assert!(error.contains("3 MB"), "{error}");
}

#[tokio::test]
async fn security_denylist_blocks_read_write_edit_and_hides_search_results() {
let dir = tempfile::tempdir().unwrap();
Expand Down
3 changes: 2 additions & 1 deletion docs/spec/03-runtime/08-error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ does not turn temporary thread pressure into a host process exit.
| `PATH_OUTSIDE_WORKSPACE` | no | path escapes sandbox before an explicit outside-path permission decision, or a prompt attachment is outside its session scratch/project/attachment roots |
| `WORKSPACE_PATH_DENIED` | no | an explicit `Read`/`Write`/`Edit` path hit the always-on security denylist (private keys, `.env` files, credential bundles, `.git/objects`); an outside-path grant does not lift it (spec 15 §3) |
| `READ_PATH_IS_DIRECTORY` | no | `Read` was given a directory; the result carries a `Glob` suggestion |
| `TOOL_BINARY_CONTENT` | no | `Read` refused to dump a binary file into the model context |
| `TOOL_BINARY_CONTENT` | no | `Read` refused to dump a binary file into the model context, or a file whose image extension does not match its bytes |
| `TOOL_IMAGE_TOO_LARGE` | no | `Read` found a real image above the inline bound (3 MB raw); the message carries the size and the alternative |
| `TOOL_NOT_FOUND` | no | unknown tool |
| `TOOL_DENIED` | no | permission denied / mode forbidden |
| `TOOL_TIMEOUT` | yes | tool execution timeout |
Expand Down
31 changes: 28 additions & 3 deletions docs/spec/03-runtime/16-tool-result-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ ignore files from applying — the same rule that lets `path` reach into
or its UI/diagnostic result; it changes only future reconstructed model
context

**Image reads (issue #711).** A file whose extension is an image type
(`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`) is not a binary the model cannot
read: `Read` returns it in the shape the runtime turns into a model-visible
image block — `{path, root, text, images: [{data, mimeType}], fileBytes}` —
where `data` is standard base64. The runtime attaches the image only when the
active model accepts images (`input`/`modalities` includes `image`) and keeps
the text either way, so a model that cannot see images still learns that an
image is there instead of inventing its contents; the base64 payload never
enters a persisted UI message or transcript record, only `imageCount` does.

- the magic number decides the type, and it has to agree with the extension: a
text file renamed `.png` is refused as `TOOL_BINARY_CONTENT` rather than sent
as a broken image, which would fail the whole request;
- an image above 3 MB raw (base64 inflates by four thirds, and the strictest
per-image ceiling in use is 5 MB encoded) is refused as
`TOOL_IMAGE_TOO_LARGE` with the size and the actionable alternative, instead
of being truncated into an unreadable fragment;
- every other binary extension keeps the existing `TOOL_BINARY_CONTENT`
outcome, and the byte sniff after this point is unchanged for text.

## 5. Partial result flags

Every bounded tool reports `truncated: boolean`. For Read, that flag is true
Expand Down Expand Up @@ -174,8 +194,10 @@ counts are the stable signals. The UI truncated chip follows `truncated`.
96KB of progress noise is what makes the model retry blindly
3. binary files: do not dump raw binary into model; return metadata error
`TOOL_BINARY_CONTENT`. Detected by extension blacklist plus a sniff of the
first 4KB (any NUL byte, or >30% non-printable). Grep skips binary files
silently rather than matching lossily-decoded bytes
first 4KB (any NUL byte, or >30% non-printable). An image type is the
exception and is inlined as an image block (§4a); a file whose bytes are
not the image its extension claims is still refused. Grep skips binary
files silently rather than matching lossily-decoded bytes
4. a single line longer than the whole budget yields a char-boundary-safe prefix
(or suffix, for a tail cut), never an empty payload
5. aggregate checkpoint truncation must preserve every provider-valid assistant
Expand All @@ -194,7 +216,10 @@ counts are the stable signals. The UI truncated chip follows `truncated`.
from the `Edit` provenance set
- [x] Read paginates a multi-megabyte file instead of refusing it, reports the
next offset, and does not set `truncated` when the requested window was filled
- [x] Read refuses binary content with `TOOL_BINARY_CONTENT`
- [x] Read refuses binary content with `TOOL_BINARY_CONTENT`, except an image
type, which returns `{text, images}` for a model that accepts images
- [x] an image above the inline bound is refused with `TOOL_IMAGE_TOO_LARGE`,
and an `.png` that is not a PNG is refused as binary
- [x] an explicit `path` reaches into an ignored tree (`node_modules`, spill dir)
- [x] Glob and Grep order results by modification time, newest first
- [x] truncated results still valid UTF-8 text
Expand Down
27 changes: 27 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -8112,6 +8112,8 @@ identify the platform validation still needed.
| F — Persistence (stored model binding array) | E2E-PROVIDER-stored-binding-array-reads-entry-by-entry |
| Quality (stored model binding array) | E2E-PROVIDER-stored-binding-array-reads-entry-by-entry |
| Quality (unique tool-call ids) | E2E-RUNTIME-unique-tool-call-ids-per-request |
| E — Tools & permissions (image read) | E2E-TOOL-read-returns-an-image-the-model-can-see |
| C — Conversation & stream (image read) | E2E-TOOL-read-returns-an-image-the-model-can-see |
| G — Plugin host lifecycle (crash report) | E2E-PLUGIN-crash-report-names-the-exit-code |
| Quality (crash report) | E2E-PLUGIN-crash-report-names-the-exit-code |

Expand Down Expand Up @@ -14351,6 +14353,31 @@ the latest destination. These assertions measure work counts, not device FPS.
and a unique one (identity, no log line).
- **Status:** Unit-covered; no end-to-end driver issues a real provider request
against a duplicated transcript.
### E2E-TOOL-read-returns-an-image-the-model-can-see

- **Preconditions:** A workspace containing a real PNG, a text file renamed
`.png`, and an image above the inline bound; a configured model that declares
image input, plus one that does not. Deterministic provider fixture.
- **Steps:** Ask the agent to read the PNG, then the renamed file, then the
oversized image. Repeat the first step after switching to the model without
image input.
- **Expected:** The PNG read succeeds and the request carries an `image` block
with the matching `mimeType`; the renamed file fails with
`TOOL_BINARY_CONTENT`; the oversized image fails with `TOOL_IMAGE_TOO_LARGE`
naming its size and the alternative; with the non-vision model the request
carries the text alone and the model reports it cannot see the image. No
base64 payload appears in the transcript or in a persisted UI message.
- **Specs:** `03-runtime/16-tool-result-limits.md` §4/§6,
`03-runtime/08-error-codes.md`, `08-meta/decisions-log.md` D609.
- **Acceptance:** E (tools), C (conversation and stream), Quality.
- **Milestone:** Post-MVP regression coverage.
- **Automation:** host-core `tools::tests::read_returns_an_image_for_the_model_to_view`,
`read_refuses_an_image_extension_whose_bytes_are_not_an_image` and
`read_refuses_an_image_too_large_to_inline`; agent-runtime
`runtime.test.ts` covers both halves of the model-side bridge (attached with
vision, dropped without).
- **Status:** Unit-covered end to end for the mapping; no desktop E2E driver
reads a real image into a live provider request.

### E2E-MCP-HTTP-ACK — HTTP acknowledgement and authorization status

Expand Down
19 changes: 19 additions & 0 deletions docs/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -6514,6 +6514,25 @@ that was sitting at the bottom — including after the turn had finished.
- The guard is deliberately the request boundary rather than the history
rebuild: it also covers a duplicate that appears while the session runs, which
a rebuild-time filter cannot see.
## 2026-09-21 — `Read` returns an image the model can be shown (D609, issue #711)

- A file whose extension is an image type (`.png`, `.jpg`, `.jpeg`, `.gif`,
`.webp`) is no longer refused as binary content. `Read` returns
`{path, root, text, images: [{data, mimeType}], fileBytes}`, the shape the
runtime already turned into a model-visible image block but which no tool
produced until now; the block is attached only when the active model accepts
images, and the text is kept either way so a model that cannot see images
says so instead of inventing contents.
- The magic number decides the type and has to agree with the extension: a text
file renamed `.png` is still refused with `TOOL_BINARY_CONTENT`, because a
request carrying a broken image fails as a whole. An image above 3 MB raw
(base64 inflates by four thirds; the strictest per-image ceiling in use is
5 MB encoded) is refused with the new `TOOL_IMAGE_TOO_LARGE`, carrying the
size and the alternative.
- The base64 payload never enters a persisted UI message or transcript record;
the stored detail keeps `imageCount` only, which is what the existing
attachment rule already required. See `03-runtime/16-tool-result-limits.md`
§4 and `03-runtime/08-error-codes.md`.

## 2026-09-21 — A plugin crash reports its exit code without copying raw output (D607, issue #747)

Expand Down
3 changes: 2 additions & 1 deletion docs/zh-CN/spec/03-runtime/08-error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ stdio 与 Tokio 的动态阻塞池隔离,因此后一种情况
| `PATH_OUTSIDE_WORKSPACE` | 不 | 在显式外部路径权限决策之前路径逃逸沙箱,或提示词附件位于其会话 scratch/project/attachment 根目录之外 |
| `WORKSPACE_PATH_DENIED` | 不 | 显式的 `Read`/`Write`/`Edit` 路径命中了始终开启的安全拒绝名单(私钥、`.env` 文件、凭证包、`.git/objects`);外部路径授权不会解除它(规格 15 §3) |
| `READ_PATH_IS_DIRECTORY` | 不 | `Read` 拿到的是目录;结果附带一条 `Glob` 建议 |
| `TOOL_BINARY_CONTENT` | 不 | `Read` 拒绝把二进制文件倾倒进模型上下文 |
| `TOOL_BINARY_CONTENT` | 不 | `Read` 拒绝把二进制文件倾倒进模型上下文,或图片扩展名与实际字节不符 |
| `TOOL_IMAGE_TOO_LARGE` | 不 | `Read` 读到真实图片但超过内联上限(原始 3 MB);消息带上大小与替代做法 |
| `TOOL_NOT_FOUND` | 不 | 未知工具 |
| `TOOL_DENIED` | 不 | 权限被拒绝/模式被禁止 |
| `TOOL_TIMEOUT` | 是的 | 工具执行超时 |
Expand Down
Loading
Loading