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

+7
View File
@@ -106,6 +106,12 @@ pub struct SshConfig {
/// Extra `-o` settings, each written as `Key=value`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>,
/// Where a file attached from the phone is put on this machine so the
/// session can read it. Absent means the session's own working
/// directory, or the login home for a session that has none. A `~`
/// prefix is the remote home.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachments_dir: Option<PathBuf>,
}
/// Which translator runs a session. A new one is a new driver behind the
@@ -436,6 +442,7 @@ mod tests {
port: Some(2222),
identity_file: None,
options: Vec::new(),
attachments_dir: None,
}),
providers: vec![ProviderConfig {
name: "claude-cli".to_string(),
+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.
+32
View File
@@ -1220,6 +1220,14 @@ fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
anyhow::bail!("invalid attachment id");
}
let path = session_dir.join("attachments").join(id);
// A file copied to the session's own machine is named where it landed
// there -- `routes::upload_attachment` writes that down beside it --
// because the path has to be one the CLI can open, not one this server
// can.
let shipped = path.with_file_name(format!("{id}.remote"));
if let Ok(remote) = std::fs::read_to_string(&shipped) {
return Ok(PathBuf::from(remote.trim()));
}
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
}
@@ -1244,6 +1252,30 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
mod tests {
use super::*;
#[test]
fn an_attachment_is_named_where_the_cli_can_open_it() {
let dir = tempfile::tempdir().expect("temp dir");
let attachments = dir.path().join("attachments");
std::fs::create_dir(&attachments).unwrap();
std::fs::write(attachments.join("ab12-x.bin"), b"x").unwrap();
assert_eq!(
attachment_path(dir.path(), "ab12-x.bin").unwrap(),
attachments.join("ab12-x.bin").canonicalize().unwrap()
);
// Shipped to the session's machine: the path there, not here.
std::fs::write(
attachments.join("ab12-x.bin.remote"),
"/home/t/in/ab12-x.bin\n",
)
.unwrap();
assert_eq!(
attachment_path(dir.path(), "ab12-x.bin").unwrap(),
PathBuf::from("/home/t/in/ab12-x.bin")
);
assert!(attachment_path(dir.path(), "../config.ron").is_err());
assert!(attachment_path(dir.path(), "missing.bin").is_err());
}
/// Drives real CLI output lines through the reader and collects what
/// came out, which is the only way to check the wiring between "the
/// CLI said this" and "the transcript records that".
+45 -10
View File
@@ -485,9 +485,11 @@ impl LiveSession {
.expect("transcript lives in the session dir")
}
/// Stores one uploaded attachment, returning the id `POST /message`
/// references it by. Removed with the session directory on delete --
/// the same path out as everything else in it.
/// Reserves the name and path for one uploaded attachment; the caller
/// writes the bytes, since a trace is bigger than this should hold.
/// The name is the id `POST /message` references it by. Removed with
/// the session directory on delete -- the same path out as everything
/// else in it.
///
/// An image is named `<hex>.<extension>` and nothing else, since the
/// model is shown the picture rather than told its name. Anything else
@@ -496,21 +498,19 @@ impl LiveSession {
/// more to it than `3f9a…` would. The name is cleaned to characters a
/// path and a URL both take unquoted, and the hex keeps two uploads of
/// the same name apart. `AttachmentRef` documents the two shapes.
pub fn save_attachment(
pub fn new_attachment(
&self,
bytes: &[u8],
content_type: &str,
file_name: Option<&str>,
) -> Result<AttachmentRef> {
) -> Result<(AttachmentRef, PathBuf)> {
let name = match crate::media::extension_for(content_type) {
Some(extension) => format!("{}.{extension}", random_hex()),
None => format!("{}-{}", random_hex(), safe_file_name(file_name)),
};
let dir = self.dir().join("attachments");
wg_app_link::private::create_dir(&dir)?;
std::fs::write(dir.join(&name), bytes)
.with_context(|| format!("write attachment {name}"))?;
Ok(name)
let path = dir.join(&name);
Ok((name, path))
}
/// `setup_name` and `cwd` are passed in rather than read from the
@@ -983,6 +983,20 @@ impl SessionManager {
/// direction, and it exists for the same delete the phone offers a
/// toggle for: removing a session here can also remove the machine's
/// own transcript of it, and only the server knows which file that is.
/// The machine a session runs on when that is not this one, with the
/// session's working directory there: what an upload needs to put a
/// file where the session can read it. `None` for a local session.
pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option<PathBuf>)> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let setup = inner
.config
.setups
.iter()
.find(|setup| setup.id == meta.setup)?;
Some((setup.ssh.clone()?, meta.cwd.clone()))
}
pub fn foreign_transcript(&self, id: &str) -> Option<(String, String)> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
@@ -1802,7 +1816,9 @@ fn safe_file_name(name: Option<&str>) -> String {
})
.collect();
let cleaned = cleaned.replace("..", "_").trim_matches('.').to_string();
if cleaned.is_empty() {
// Nothing a person would recognise as a name is left: say so rather
// than store a file called `_`.
if !cleaned.chars().any(|c| c.is_ascii_alphanumeric()) {
return "file".to_string();
}
let excess = cleaned.chars().count().saturating_sub(FILE_NAME_LIMIT);
@@ -2393,6 +2409,25 @@ async fn pump(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_name_is_reduced_to_what_an_id_may_hold() {
assert_eq!(
safe_file_name(Some("trace komodo (1).perfetto-trace")),
"trace_komodo__1_.perfetto-trace"
);
let hostile = safe_file_name(Some("../../etc/passwd"));
assert!(
!hostile.contains('/') && !hostile.contains(".."),
"{hostile}"
);
assert_eq!(safe_file_name(Some("...")), "file");
assert_eq!(safe_file_name(None), "file");
let long = "x".repeat(200) + ".pftrace";
let kept = safe_file_name(Some(&long));
assert_eq!(kept.len(), FILE_NAME_LIMIT);
assert!(kept.ends_with(".pftrace"));
}
use std::time::Duration;
fn echo_spec() -> SpawnSpec {
+4 -2
View File
@@ -121,7 +121,7 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
/// `~user` is deliberately not handled: there is no portable expansion for
/// it, and inventing one would mean guessing another account's home
/// directory. It stays literal and fails with the shell's own message.
fn quote_path(path: &str) -> String {
pub(crate) fn quote_path(path: &str) -> String {
if path == "~" {
return "\"$HOME\"".to_string();
}
@@ -137,7 +137,7 @@ fn quote_path(path: &str) -> String {
/// names, and prompts-as-arguments are all attacker-adjacent input in a
/// server whose whole job is running commands, and unquoted they would be
/// shell syntax rather than data.
fn quote(word: &str) -> String {
pub(crate) fn quote(word: &str) -> String {
// Inside single quotes every character is literal except `'` itself,
// which is closed, escaped, and reopened.
format!("'{}'", word.replace('\'', r"'\''"))
@@ -168,6 +168,7 @@ mod tests {
port: None,
identity_file: None,
options: vec![],
attachments_dir: None,
}
}
@@ -190,6 +191,7 @@ mod tests {
port: Some(2222),
identity_file: Some("/home/me/.ssh/id_ai".into()),
options: vec!["StrictHostKeyChecking=accept-new".to_string()],
attachments_dir: None,
};
let rendered = argv(&command(
Some(&ssh),
+1
View File
@@ -423,6 +423,7 @@ mod tests {
port: None,
identity_file: None,
options: vec!["ConnectTimeout=1".to_string()],
attachments_dir: None,
}),
providers: vec![crate::config::ProviderConfig {
name: "claude-cli".to_string(),