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:
1 parent
4bc69e8f9c
commit
6180663f14
25 files changed
+672
-165
No files matched your search
+36
-15
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user