Attach any file, take shares from other apps, and survive a backwards highlight

Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 08:15:10 -04:00
1 parent 4bc69e8f9c
commit 6180663f14
25 files changed
+672 -165

No files matched your search

+36 -15
View File
@@ -30,8 +30,8 @@
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
//! (starts the process first if it has exited)
//! POST /sessions/{id}/compact
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
//! POST /sessions/{id}/attachments multipart upload, image or any file -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced, and what it was sent
//! DELETE /sessions/{id} kill process, delete transcript + files
//! (?deleteForeign=true removes the machine's own copy too)
//! POST /sessions/{id}/notify {notify} -- announce this one or not
@@ -110,7 +110,13 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/notifications", get(notifications))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/command", post(command))
.route("/sessions/{id}/attachments", post(upload_attachment))
.route(
"/sessions/{id}/attachments",
// A trace or a log is bigger than a photo; the cap below is
// for everything else, and the innermost limit is the one
// axum applies.
post(upload_attachment).layer(axum::extract::DefaultBodyLimit::max(ATTACHMENT_LIMIT)),
)
.route("/sessions/{id}/files/{name}", get(serve_file))
// Phone photos overflow axum's 2 MB default body cap.
.layer(axum::extract::DefaultBodyLimit::max(32 * 1024 * 1024))
@@ -1273,8 +1279,14 @@ async fn compact(
Ok(StatusCode::NO_CONTENT)
}
/// Accepts one image (any multipart field) and stores it under the
/// The most one attachment may be. A day of `perfetto` is under a
/// gigabyte; a phone photo is a few megabytes; this is the room between.
const ATTACHMENT_LIMIT: usize = 1024 * 1024 * 1024;
/// Accepts one file (any multipart field) and stores it under the
/// session; the returned id goes into a later `/message`'s attachmentIds.
/// An image is later shown to the model, anything else is named to it by
/// path -- see `ClaudeDriver::send_user_message`.
async fn upload_attachment(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
@@ -1286,27 +1298,36 @@ async fn upload_attachment(
.await
.map_err(|err| ApiError::BadRequest(format!("bad upload: {err}")))?
.ok_or_else(|| ApiError::BadRequest("no file in the upload".to_string()))?;
let content_type = field.content_type().unwrap_or("image/jpeg").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let file_name = field.file_name().map(str::to_string);
let bytes = field
.bytes()
.await
.map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?;
let name = session
.save_attachment(&bytes, &content_type)
.save_attachment(&bytes, &content_type, file_name.as_deref())
.map_err(bad_request)?;
Ok(axum::Json(serde_json::json!({ "id": name })))
}
/// Serves a session's stored images -- both `files/` (produced by tools)
/// and `attachments/` (uploaded from the phone), by the id events and
/// uploads reference.
/// Serves a session's stored files -- both `files/` (images produced by
/// tools) and `attachments/` (uploaded from the phone), by the id events
/// and uploads reference.
async fn serve_file(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, name)): UrlPath<(String, String)>,
) -> Result<Response, ApiError> {
// Ids are server-generated hex + extension; anything else (and any
// path separator in particular) is refused, not resolved.
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.') || name.contains("..") {
// Ids are server-generated -- hex and an extension, or hex and a
// cleaned file name (`safe_file_name`); anything else (and any path
// separator in particular) is refused, not resolved.
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|| name.contains("..")
{
return Err(ApiError::BadRequest("invalid file id".to_string()));
}
let session = lookup(&manager, &id)?;
@@ -1324,9 +1345,9 @@ async fn serve_file(
let bytes = std::fs::read(path)
.with_context(|| format!("read {}", path.display()))
.map_err(ApiError::Internal)?;
// Names are server-generated, so an unrecognized extension can only
// mean a file this server didn't write.
let content_type = crate::media::media_type_for(&name).unwrap_or("image/jpeg");
// Every image this server writes has an extension it knows; the rest
// are files attached by name, served as the bytes they are.
let content_type = crate::media::media_type_for(&name).unwrap_or("application/octet-stream");
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
}