You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/20 16:15:03 UTC

(maka) branch rust-preview updated: fix(files): read complete admitted text attachments

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/rust-preview by this push:
     new 8dd60f1fc6 fix(files): read complete admitted text attachments
8dd60f1fc6 is described below

commit 8dd60f1fc6b9bbca4c0d08b51dda381dd9763a41
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Oct 21 00:14:57 2026 +0800

    fix(files): read complete admitted text attachments
    
    Remove the duplicate text source limit and use the existing full upload
    window while preserving Session authority, complete UTF-8 validation and
    bounded canonical pages. Verify real uploaded large text and continuation
    behavior through the selected Native Files service.
    
    Generated-by: Codex
---
 .../agent/native/src/owner/tests/attachments.rs    | 244 +++++++++++++++++++++
 plugins/workspace/workspace-files/native/README.md |   2 +-
 .../workspace-files/native/README.zh-CN.md         |   2 +-
 .../workspace-files/native/src/attachments.rs      |  10 +-
 4 files changed, 247 insertions(+), 11 deletions(-)

diff --git a/plugins/agent/native/src/owner/tests/attachments.rs b/plugins/agent/native/src/owner/tests/attachments.rs
index 38956f2aac..b46bc4311a 100644
--- a/plugins/agent/native/src/owner/tests/attachments.rs
+++ b/plugins/agent/native/src/owner/tests/attachments.rs
@@ -20,6 +20,250 @@
 use super::*;
 use maka_agent_facts::artifact::content_digest;
 
+#[tokio::test]
+async fn admitted_large_text_attachment_reads_exact_ranges_and_canonical_continuations() {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let f = Fixture::with_services(
+        &format!("http://{}/v1", listener.local_addr().unwrap()),
+        None,
+        None,
+        Some(service_tools::Setup::files("readText")),
+    )
+    .await;
+    let session = f.create("Large admitted text").await;
+    let other = f.create("Continuation has no authority").await;
+    // Each numbered record occupies exactly 128 UTF-8 bytes including its newline.
+    let row = |index| {
+        let mut line = format!("record-{index:08} 中文😀 \\\" ledger ");
+        line.extend(std::iter::repeat_n('x', 127 - line.len()));
+        line.push('\n');
+        line
+    };
+    let mut source = String::from("K102 first marker\n");
+    for index in 0..90_112 {
+        source.push_str(&row(index));
+    }
+    let late_byte = source.len();
+    source.push_str("K102 late marker beyond ten MiB\n");
+    let long_line = "中文😀\\\"\t".repeat(2_000);
+    source.push_str(&long_line);
+    source.push_str("\nK102 final marker");
+    let digest = content_digest(source.as_bytes());
+    let uploaded = upload(
+        &f,
+        &session,
+        "large-ledger.md",
+        source.as_bytes(),
+        Uuid::new_v4(),
+    )
+    .await
+    .unwrap();
+    let id = uploaded["attachment"]["id"].as_str().unwrap();
+    let before = f
+        .store
+        .get_artifact(&session, id)
+        .await
+        .unwrap()
+        .record
+        .unwrap();
+    let window = maka_agent_facts::attachment::MAX_ATTACHMENT_BYTES as usize;
+    let original = f
+        .store
+        .read_artifact_chunk(&session, id, 0, window)
+        .await
+        .unwrap()
+        .unwrap();
+    let path = format!("attachment:{id}");
+    let alias = format!("attachment:{}", &id[.."attachment-".len() + 12]);
+
+    // Send the real uploaded reference through the provider, then invoke selected Files.
+    f.mutation(
+        2,
+        "send",
+        json!({"sessionId":session,"message":"","attachments":[id],
+        "model":f.selection().await}),
+        Uuid::new_v4(),
+    )
+    .await
+    .unwrap();
+    let (mut socket, _) = listener.accept().await.unwrap();
+    let prompt = request(&mut socket).await;
+    files::tool_response(&mut socket, json!({"path":alias,"offset":0,"limit":2})).await;
+    let early = files::finish(&listener, &f).await;
+    files::call(
+        &listener,
+        &f,
+        &session,
+        json!({"path":path,"offset":90_113,"limit":1}),
+    )
+    .await;
+    let late = files::finish(&listener, &f).await;
+
+    let mut input = json!({"path":alias,"offset":90_114,"limit":1});
+    let mut pages = Vec::new();
+    // This one 14,000-UTF16-unit line needs only a few bounded responses.
+    // A finite loop also lets cleanup run if a broken cursor stops making progress.
+    for _ in 0..8 {
+        files::call(&listener, &f, &session, input).await;
+        let page = files::finish(&listener, &f).await;
+        let next = page["value"]["next"].clone();
+        pages.push(page);
+        if next.is_null() {
+            break;
+        }
+        input = next;
+    }
+    let first_next = pages[0]["value"]["next"].clone();
+    let foreign = if first_next.is_null() {
+        None // Original RED has no continuation; settle both owners before asserting it.
+    } else {
+        files::call(&listener, &f, &other, first_next.clone()).await;
+        Some(files::finish(&listener, &f).await)
+    };
+    let after = f
+        .store
+        .get_artifact(&session, id)
+        .await
+        .unwrap()
+        .record
+        .unwrap();
+    let retained = f
+        .store
+        .read_artifact_chunk(&session, id, 0, window)
+        .await
+        .unwrap()
+        .unwrap();
+    f.app.shutdown().await.unwrap();
+    f.config.shutdown().await.unwrap();
+
+    assert_eq!(uploaded["kind"], "uploaded");
+    assert_eq!(uploaded["attachment"]["sessionId"], session);
+    assert_eq!(uploaded["attachment"]["mimeType"], "text/markdown");
+    assert_eq!(before.kind, maka_agent_facts::artifact::ArtifactKind::File);
+    assert_eq!(
+        before.source,
+        maka_agent_facts::artifact::ArtifactSource::UserUpload
+    );
+    assert_eq!(before.size_bytes, source.len() as u64);
+    assert_eq!(before.summary.as_deref(), Some(digest.as_str()));
+    assert_eq!(after, before);
+    assert_eq!(original.total_bytes, source.len() as u64);
+    assert!(
+        original.bytes == source.as_bytes(),
+        "upload changed original bytes"
+    );
+    assert!(
+        retained.bytes == source.as_bytes(),
+        "Read changed immutable uploaded bytes"
+    );
+    assert_eq!(retained.total_bytes, original.total_bytes);
+    assert_eq!(content_digest(&original.bytes), digest);
+    assert_eq!(content_digest(&retained.bytes), digest);
+    assert!(late_byte > 10 * 1024 * 1024);
+    assert!(prompt.to_string().contains(id));
+    assert!(
+        !prompt.to_string().contains("K102 first marker"),
+        "provider receives a reference rather than inline source"
+    );
+    assert_eq!(
+        early,
+        json!({"kind":"completed","value":{
+            "content":format!("K102 first marker\n{}", row(0).trim_end_matches('\n')),
+            "offset":0,"returnedLines":2,"totalLines":90_116,"next":null
+        }})
+    );
+    assert_eq!(
+        late,
+        json!({"kind":"completed","value":{
+            "content":"K102 late marker beyond ten MiB","offset":90_113,
+            "returnedLines":1,"totalLines":90_116,"next":null
+        }})
+    );
+    assert!(
+        pages.len() > 1,
+        "Unicode line must span actual public pages: {pages:?}"
+    );
+    let mut reconstructed = String::new();
+    for (index, page) in pages.iter().enumerate() {
+        assert_eq!(page["kind"], "completed", "{page}");
+        assert!(serde_json::to_string(page).unwrap().encode_utf16().count() <= 7_500);
+        let value = &page["value"];
+        assert_eq!(value["offset"], 90_114);
+        assert_eq!(value["totalLines"], 90_116);
+        assert_eq!(value["partialLine"], true);
+        assert_eq!(
+            value["returnedLines"],
+            usize::from(index + 1 == pages.len())
+        );
+        reconstructed.push_str(value["content"].as_str().unwrap());
+        if !value["next"].is_null() {
+            let next: maka_agent_facts::read::ReadInput =
+                serde_json::from_value(value["next"].clone()).unwrap();
+            let resolved = next.resolve().unwrap();
+            assert!(resolved.is_continuation());
+            assert_eq!(resolved.path(), format!("maka://runtime/attachments/{id}"));
+            assert_eq!(next.limit.map(std::num::NonZeroUsize::get), Some(1));
+        }
+    }
+    assert!(
+        pages.last().unwrap()["value"]["next"].is_null(),
+        "bounded range never finished"
+    );
+    assert!(
+        reconstructed == long_line,
+        "continuations changed original Unicode bytes"
+    );
+    assert_eq!(
+        foreign,
+        Some(json!({"kind":"failed","message":"Attachment was not found in this Session"}))
+    );
+}
+
+#[tokio::test]
+async fn attachment_early_read_validates_late_nul_and_invalid_utf8() {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let f = Fixture::with_services(
+        &format!("http://{}/v1", listener.local_addr().unwrap()),
+        None,
+        None,
+        Some(service_tools::Setup::files("readText")),
+    )
+    .await;
+    let session = f.create("Validate complete attachment").await;
+    let mut late_nul = b"apparently valid first line\n".to_vec();
+    late_nul.extend_from_slice("numbered 中文😀 record\n".repeat(450_560).as_bytes());
+    let nul_position = late_nul.len();
+    late_nul.extend_from_slice(b"late binary\0suffix");
+    let mut results = Vec::new();
+    for (name, bytes) in [
+        ("late-nul.md", late_nul.as_slice()),
+        ("invalid-utf8.md", b"valid first line\n\xff".as_slice()),
+    ] {
+        let uploaded = upload(&f, &session, name, bytes, Uuid::new_v4())
+            .await
+            .unwrap();
+        let id = uploaded["attachment"]["id"].as_str().unwrap();
+        files::call(
+            &listener,
+            &f,
+            &session,
+            json!({"path":format!("attachment:{id}"),"offset":0,"limit":1}),
+        )
+        .await;
+        results.push(files::finish(&listener, &f).await);
+    }
+    f.app.shutdown().await.unwrap();
+    f.config.shutdown().await.unwrap();
+    assert!(nul_position > 10 * 1024 * 1024);
+    assert_eq!(
+        results,
+        vec![
+            json!({"kind":"failed","message":"Read cannot decode this binary attachment as text"}),
+            json!({"kind":"failed","message":"Read cannot decode this binary attachment as UTF-8 text"}),
+        ]
+    );
+}
+
 pub(super) async fn upload(
     f: &Fixture,
     session: &str,
diff --git a/plugins/workspace/workspace-files/native/README.md b/plugins/workspace/workspace-files/native/README.md
index f144f8798e..428b300477 100644
--- a/plugins/workspace/workspace-files/native/README.md
+++ b/plugins/workspace/workspace-files/native/README.md
@@ -23,6 +23,6 @@
 
 Exposes file operations for an explicitly selected workspace. `provide` registers the service and `attachments` handles captured file resources. The wrapper supplies the original call scope and directory authority to the file engine. File tools and commands share the session sandbox preset: reads may reach outside the workspace; workspace-write permits writes in the workspace and system temporary directories. Other writes require permission approval. The selected directory remains captur [...]
 
-`readText` also pages uploaded attachments and original tool-result resources visible to the calling Session. A selected Read tool uses its current live Invocation; resource addresses and continuations do not grant access to another Session. Archived results reuse the original model projection without executing the source tool again. Each archive page currently loads the complete original projection before paging, and cancellation waits for that storage operation to settle.
+`readText` also pages uploaded attachments and original tool-result resources visible to the calling Session. A selected Read tool uses its current live Invocation; resource addresses and continuations do not grant access to another Session. Each text attachment page loads the complete admitted upload and validates UTF-8 and the absence of NUL bytes before returning a bounded page. Archived results reuse the original model projection without executing the source tool again. Each archive  [...]
 
 [Source](src/lib.rs) · [Cargo manifest](Cargo.toml)
diff --git a/plugins/workspace/workspace-files/native/README.zh-CN.md b/plugins/workspace/workspace-files/native/README.zh-CN.md
index a2f94d0695..f4452bd3c6 100644
--- a/plugins/workspace/workspace-files/native/README.zh-CN.md
+++ b/plugins/workspace/workspace-files/native/README.zh-CN.md
@@ -23,6 +23,6 @@
 
 为显式选择的工作区提供文件操作。`provide` 注册服务,`attachments` 处理捕获的文件资源。包装层向文件引擎传递原始调用作用域与目录权限。文件工具与命令共用会话沙箱策略:允许读取工作区外文件;workspace-write 默认允许写入工作区及系统临时目录,其他写入需要权限审批。所选目录仍通过捕获的句柄访问,审批不能绕过工作区元数据与 Host 私有路径保护。
 
-`readText` 也可分页读取调用方 Session 可见的上传附件与原始工具结果资源。已选择的 Read 工具使用当前活跃 Invocation 的权限;资源地址和续页地址不会授予其他 Session 的访问权。归档读取复用原始模型投影,不会再次执行源工具。当前每个归档页仍会先加载完整原始投影,取消也会等待该存储操作结束。
+`readText` 也可分页读取调用方 Session 可见的上传附件与原始工具结果资源。已选择的 Read 工具使用当前活跃 Invocation 的权限;资源地址和续页地址不会授予其他 Session 的访问权。每个文本附件页都会加载已受理上传的完整内容,验证 UTF-8 且不含 NUL 字节后,再返回大小受控的页面。归档读取复用原始模型投影,不会再次执行源工具。当前每个归档页仍会先加载完整原始投影,取消也会等待该存储操作结束。
 
 [源码](src/lib.rs) · [Cargo 配置](Cargo.toml)
diff --git a/plugins/workspace/workspace-files/native/src/attachments.rs b/plugins/workspace/workspace-files/native/src/attachments.rs
index b28ffa90ef..0fb4e55630 100644
--- a/plugins/workspace/workspace-files/native/src/attachments.rs
+++ b/plugins/workspace/workspace-files/native/src/attachments.rs
@@ -48,15 +48,7 @@ pub async fn read(
             "Use readImage for images and readText for UTF-8 text attachments",
         ));
     }
-    const TEXT_LIMIT: usize = 10 * 1024 * 1024;
-    if !image && record.size_bytes > TEXT_LIMIT as u64 {
-        return Err(failed("Attachment exceeds the Read content limit"));
-    }
-    let window = if image {
-        maka_agent_facts::attachment::MAX_ATTACHMENT_BYTES as usize
-    } else {
-        TEXT_LIMIT + 1
-    };
+    let window = maka_agent_facts::attachment::MAX_ATTACHMENT_BYTES as usize;
     let chunk = store
         .read_artifact_chunk(session, id, 0, window)
         .await


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org