Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work

Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.

A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.

TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 13:24:36 -04:00
1 parent 6180663f14
commit 801618ba0e
12 files changed
+499 -57

No files matched your search

+132 -10
View File
@@ -267,6 +267,9 @@ struct SshRequest {
identity_file: Option<String>,
#[serde(default)]
options: Vec<String>,
/// Where attached files land on that machine; see `SshConfig`.
#[serde(default)]
attachments_dir: Option<String>,
}
impl SshRequest {
@@ -288,6 +291,15 @@ impl SshRequest {
.iter()
.filter_map(|o| crate::setups::tidy(o))
.collect(),
// Not `tidy`: that expands `~` to *this* machine's home, and
// this path is on the other one. The remote shell expands it
// there (`ssh::quote_path`).
attachments_dir: self
.attachments_dir
.as_deref()
.map(str::trim)
.filter(|dir| !dir.is_empty())
.map(std::path::PathBuf::from),
})
}
}
@@ -1279,21 +1291,32 @@ async fn compact(
Ok(StatusCode::NO_CONTENT)
}
/// 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;
/// The most one attachment may be. Streamed to disk, so this bounds the
/// session directory rather than memory; a day of `perfetto` is under a
/// gigabyte, and this leaves room for a few of them.
const ATTACHMENT_LIMIT: usize = 4 * 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`.
///
/// Written to disk as it arrives rather than collected first: a trace is
/// bigger than this process should hold, and the phone streams it for the
/// same reason. Under a `.part` name until it is whole, so a tunnel that
/// drops mid-upload leaves nothing a message could reference.
///
/// A file for a session on another machine is copied there too, because
/// the path the session is told has to exist where the session runs. The
/// copy is part of the upload: if it fails, the upload fails and says so,
/// rather than a message later naming a file that is not there.
async fn upload_attachment(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
mut multipart: axum::extract::Multipart,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let session = lookup(&manager, &id)?;
let field = multipart
let mut field = multipart
.next_field()
.await
.map_err(|err| ApiError::BadRequest(format!("bad upload: {err}")))?
@@ -1303,16 +1326,115 @@ async fn upload_attachment(
.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, file_name.as_deref())
let (name, path) = session
.new_attachment(&content_type, file_name.as_deref())
.map_err(bad_request)?;
let part = path.with_file_name(format!("{name}.part"));
let received: Result<(), ApiError> = async {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::File::create(&part)
.await
.with_context(|| format!("create {}", part.display()))?;
while let Some(chunk) = field
.chunk()
.await
.map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?
{
file.write_all(&chunk)
.await
.with_context(|| format!("write {}", part.display()))?;
}
file.flush()
.await
.with_context(|| format!("finish {}", part.display()))?;
Ok(())
}
.await;
if let Err(err) = received {
let _ = tokio::fs::remove_file(&part).await;
return Err(err);
}
tokio::fs::rename(&part, &path)
.await
.with_context(|| format!("name {}", path.display()))
.map_err(ApiError::Internal)?;
// Images are not copied: they ride the message itself as base64.
if crate::media::media_type_for(&name).is_none()
&& let Some((ssh, cwd)) = manager.remote_of(&id)
{
match ship_attachment(&ssh, cwd.as_deref(), &path, &name).await {
Ok(remote) => {
tokio::fs::write(remote_marker(&path), remote)
.await
.with_context(|| format!("record where {name} went"))
.map_err(ApiError::Internal)?;
}
Err(err) => {
let _ = tokio::fs::remove_file(&path).await;
return Err(ApiError::BadRequest(format!(
"{name} reached the server but couldn't be copied to {}: {err:#}",
ssh.address
)));
}
}
}
Ok(axum::Json(serde_json::json!({ "id": name })))
}
/// Copies `local` to the machine `ssh` names, into the configured
/// attachments directory, else `cwd`, else the login home, and returns the
/// absolute path it has there.
///
/// One `ssh` invocation does the copy and answers the path: the file goes
/// over stdin to `cat`, and `pwd -P` afterwards resolves whatever the
/// directory was written as -- a `~`, a relative name, a symlink -- into
/// the path the session will be told, which is the one a CLI's file tools
/// take. `scp` would need a second round trip for that answer.
async fn ship_attachment(
ssh: &crate::config::SshConfig,
cwd: Option<&Path>,
local: &Path,
name: &str,
) -> anyhow::Result<String> {
let dir = ssh.attachments_dir.as_deref().or(cwd);
let mut script = String::new();
if let Some(dir) = dir {
let dir = crate::ssh::quote_path(&dir.to_string_lossy());
// Created if missing: a configured directory may not exist yet,
// and a session's own cwd already does, so this costs it nothing.
script.push_str(&format!("mkdir -p {dir} && cd {dir} && "));
}
script.push_str(&format!("cat > {} && pwd -P", crate::ssh::quote(name)));
let source = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?;
let mut command = tokio::process::Command::from(crate::ssh::command(
Some(ssh),
"sh",
&["-c".to_string(), script],
None,
));
command
.stdin(source)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let output = command.output().await.context("run ssh")?;
if !output.status.success() {
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
}
let dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
if dir.is_empty() {
anyhow::bail!("the remote shell did not say where it put the file");
}
Ok(format!("{dir}/{name}"))
}
/// Where the remote path of a shipped attachment is recorded, beside it.
/// Read by `ClaudeDriver`'s `attachment_path`; removed with the session.
fn remote_marker(local: &Path) -> std::path::PathBuf {
let name = local.file_name().unwrap_or_default().to_string_lossy();
local.with_file_name(format!("{name}.remote"))
}
/// 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.