diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index ab9e5d4..7a351ce 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -12,7 +12,8 @@ use std::sync::Arc; -use axum::extract::{Path, Query, State}; +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::{any, delete, get, post}; @@ -70,6 +71,10 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::patterns::STRUCTURED, any(structured_ws)) .route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay)) .route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt)) + .route( + routes::patterns::STRUCTURED_ATTACHMENT, + post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)), + ) .route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel)) .route(routes::patterns::STRUCTURED_CONFIG, post(structured_config)) .route( @@ -432,6 +437,46 @@ async fn structured_replay( } } +#[derive(Deserialize)] +struct AttachmentQuery { + name: String, +} + +async fn structured_attachment( + State(s): State, + Path(id): Path, + Query(query): Query, + bytes: Bytes, +) -> Response { + let name = query.name; + if name.is_empty() + || name.len() > 180 + || name == "." + || name == ".." + || name + .chars() + .any(|c| c.is_control() || c == '/' || c == '\\') + { + return error_response( + StatusCode::BAD_REQUEST, + "invalid_filename", + "use a filename without directory separators, up to 180 bytes", + ); + } + match s.manager.store_acp_attachment(id, &name, &bytes).await { + Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(), + Ok(None) => error_response( + StatusCode::CONFLICT, + "not_structured", + "session is not a live structured session", + ), + Err(e) => { + let info = e.to_error_info(); + error_response(session_error_status(&info.code), &info.code, &info.message) + } + } +} + async fn structured_prompt( State(s): State, Path(id): Path, diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index d377af3..78e3973 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -604,6 +604,36 @@ impl SessionManager { Ok(Some(replay)) } + /// Store attachment bytes for a live structured session. + pub async fn store_acp_attachment( + &self, + id: Uuid, + name: &str, + bytes: &[u8], + ) -> Result, SessionError> { + { + let sessions = self.inner.sessions.lock().await; + let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; + if !record.is_live_structured() { + return Ok(None); + } + } + let dir = self.inner.sidecar.dir().join(format!("{id}.attachments")); + tokio::fs::create_dir_all(&dir).await?; + let dir = tokio::fs::canonicalize(dir).await?; + let path = dir.join(format!("{}-{name}", Uuid::new_v4())); + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .await?; + use tokio::io::AsyncWriteExt; + file.write_all(bytes).await?; + file.sync_all().await?; + Ok(Some(path)) + } + /// Subscribe to the two projections of a structured session's ACP log. /// /// The transcript snapshot and whole reduced state are folded once by the diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index d2aa08f..77d6af6 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -234,6 +234,60 @@ async fn structured_ws_sends_snapshot_then_live_projection() { ); } +#[tokio::test] +async fn structured_attachments_preserve_bytes_and_reject_paths() { + let h = spawn("attachments").await; + let fake = FakeAcp::new(); + let id = structured_session(&h, &fake).await; + let client = reqwest::Client::new(); + let endpoint = url( + h.addr, + &format!("/api/sessions/{id}/structured/attachments"), + ); + let bytes = vec![0, 255, 10, 13]; + let response = client + .post(&endpoint) + .query(&[("name", "sample.bin")]) + .body(bytes.clone()) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let body: serde_json::Value = response.json().await.unwrap(); + let path = body["path"].as_str().unwrap(); + assert!(Path::new(path).is_absolute()); + assert_eq!(std::fs::read(path).unwrap(), bytes); + for name in ["../escape", "a/b", "a\\b", "..", ""] { + let response = client + .post(&endpoint) + .query(&[("name", name)]) + .body("text") + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400, "{name}"); + } + let response = client + .post(&endpoint) + .query(&[("name", "large.bin")]) + .body(vec![0; 20 * 1024 * 1024 + 1]) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 413); + let response = client + .post(url( + h.addr, + &format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()), + )) + .query(&[("name", "file.txt")]) + .body("text") + .send() + .await + .unwrap(); + assert_eq!(response.status(), 404); +} + #[tokio::test] async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 886fee6..fa7d223 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -86,6 +86,11 @@ pub fn structured_prompt(id: Uuid) -> String { format!("{SESSIONS}/{id}/structured/prompt") } +/// `POST /api/sessions/:id/structured/attachments` — upload a file. +pub fn structured_attachment(id: Uuid) -> String { + format!("{SESSIONS}/{id}/structured/attachments") +} + /// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. pub fn structured_cancel(id: Uuid) -> String { format!("{SESSIONS}/{id}/structured/cancel") @@ -113,6 +118,7 @@ pub mod patterns { pub const STRUCTURED: &str = "/api/structured/:id"; pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay"; pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt"; + pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments"; pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel"; pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config"; pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce"; diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 9eb9a2a..c1ce541 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -302,6 +302,26 @@ pending decision, entirely on the daemon side: ## Prompt and cancel: bounded liveness +The web composer accepts up to ten files through its attachment picker, file +drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes +a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can +be removed before sending; failed sends retain the draft and attachments. +Pastes also stay inline when all ten attachment slots are occupied or the text +exceeds the 20 MiB file limit. + +On send, `POST /api/sessions/:id/structured/attachments?name=` uploads +each file as raw bytes (20 MiB maximum) and returns its absolute `path`. +Only live structured sessions accept uploads. Filenames are limited to 180 +bytes and cannot contain directory separators or control characters. Files get unique names and private +file permissions in `.attachments` beside the session sidecar. They persist +across daemon restarts; removing a composer chip does not delete uploaded data. +There is currently no automatic attachment cleanup. + +The prompt includes each filename and host path so the adapter can read it +through its own file tools, including images when the provider supports them. +These references are persisted in the ordinary user transcript row. Attachment +bytes are not embedded in ACP messages or transcript history. + `POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized against each other through one per-session `operation` lock (`AcpEventStream::operation`), because ACP permission callbacks carry no diff --git a/web/src/api.ts b/web/src/api.ts index a7f98de..2174b54 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise { + return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/octet-stream" }, + body: file, + })); +} + /** Cancel a structured session's active ACP turn (#138). */ export async function cancelStructuredTurn(id: Uuid): Promise { await jsonOrError( diff --git a/web/src/routes.ts b/web/src/routes.ts index 2227ae4..49f7fb5 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string { return `${session(id)}/structured/prompt`; } +export function structuredAttachment(id: Uuid): string { + return `${session(id)}/structured/attachments`; +} + /** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */ export function structuredCancel(id: Uuid): string { return `${session(id)}/structured/cancel`; diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 5b86f04..97c17e6 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -6,6 +6,7 @@ vi.mock("./api.js", async () => { return { ...actual, sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), + uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }), cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), setStructuredConfig: vi.fn().mockResolvedValue(undefined), @@ -16,6 +17,7 @@ import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt, + uploadStructuredFile, setStructuredConfig, } from "./api.js"; import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; @@ -95,6 +97,53 @@ function mount(opts: { describe("structured connection: data flow", () => { beforeEach(() => vi.clearAllMocks()); + it("converts long pasted text into a removable file and sends it without reply text", async () => { + const conn = mount({ sockets: [new FakeSocket()] }); + const input = conn.el.querySelector("textarea")!; + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { + files: [], getData: () => "a".repeat(4000), + } }); + input.dispatchEvent(paste); + expect(paste.defaultPrevented).toBe(true); + expect(input.value).toBe(""); + expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt"); + conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); + await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt"))); + expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 })); + conn.dispose(); + }); + + it("leaves short pastes alone", () => { + const conn = mount({ sockets: [new FakeSocket()] }); + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } }); + conn.el.querySelector("textarea")!.dispatchEvent(paste); + expect(paste.defaultPrevented).toBe(false); + conn.dispose(); + }); + + it("retains failed uploads for retry and lets the operator remove them", async () => { + vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed")); + const conn = mount({ sockets: [new FakeSocket()] }); + const input = conn.el.querySelector("textarea")!; + input.value = "Read this"; + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { + files: [new File(["content"], "notes.txt")], getData: () => "", + } }); + input.dispatchEvent(paste); + conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); + await vi.waitFor(() => expect(input.disabled).toBe(false)); + expect(input.value).toBe("Read this"); + expect(sendStructuredPrompt).not.toHaveBeenCalled(); + const remove = conn.el.querySelector('[aria-label="Remove notes.txt"]')!; + expect(remove).not.toBeNull(); + remove.click(); + expect(conn.el.querySelector(".structured-attachments")!.hidden).toBe(true); + conn.dispose(); + }); + it("renders every row from the initial snapshot", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 3064c21..9aa52c7 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -10,6 +10,7 @@ import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt, + uploadStructuredFile, setStructuredConfig, structuredWsUrl, } from "./api.js"; @@ -90,7 +91,12 @@ export function createStructuredConnection( Thinking
+ +
+