Read and change a machine's files from the backend
The first half of EXPLORER.md: server/src/files.rs, which lists a directory, reads a file, writes one, and creates a file or a directory on whichever machine a setup names. Each operation is one small POSIX script run through `Transport`, the way the import listing and the usage fetch already ask a machine a question, so the local and the ssh case are one implementation rather than two that drift. The path crosses as a positional argument and never as script text; `PATH_PRELUDE` is the one line that gives a leading `~` its meaning, because a shell expands a tilde in text and not in an argument, and it is the far machine's home that has to answer. A read has four answers -- text, binary, tooBig, or the machine's own error -- because a binary file drawn as text and a big one cut off silently are both wrong in ways the reader cannot see. A write carries the sha256 the read reported and is refused with a 409 when the file has moved on, which is what happens whenever an agent is editing the file somebody is reading. `Transport::capture_with_input` is the one description of "run this there, with this on stdin", and `ship_attachment` moves onto it rather than assembling a second ssh invocation of its own. It is also the only capture that hands back the exit status, which is how the write says "this is not the file you read" without that answer looking like a failure. Exercised on both transports against the sandbox -- ssh to this VM with a throwaway key, since the quoting and the stdin path are what that proves -- including a filename with an apostrophe, one with a tab, an unreadable file, a binary one, one over the limit, and the 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f6bee1b8a5
commit
cc7e4f63ef
8 files changed
+1008
-46
No files matched your search
+168
-27
@@ -7,6 +7,12 @@
|
||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||
//! GET /setups/{id} one machine, for refetching after a change
|
||||
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
||||
//! GET /setups/{id}/file?path=P content of file P, or why not
|
||||
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||
//! (409 when the file no longer matches ifSha256)
|
||||
//! POST /setups/{id}/file {path} create empty; refused if it exists
|
||||
//! POST /setups/{id}/dir {path} create; refused if it exists
|
||||
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /setups/{id} remove, refused while sessions use it
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
@@ -92,6 +98,15 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
)
|
||||
// The filesystem of the machine a setup names -- see
|
||||
// `crate::files`. Under the setup rather than under a session
|
||||
// because a filesystem is a property of a machine; a session only
|
||||
// says where to start looking.
|
||||
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
|
||||
.route(
|
||||
"/setups/{id}/file",
|
||||
get(read_file).put(write_file).post(create_file),
|
||||
)
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
.route("/sessions/{id}", get(read_session).delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
@@ -447,6 +462,143 @@ async fn delete_setup(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// The five explorer routes below all begin the same way: find the
|
||||
/// machine, and check that what the phone named is a path this will act on.
|
||||
///
|
||||
/// The check is `files::check_path`, shared with [`set_cwd`] -- one rule
|
||||
/// about what an acceptable path is, and one wording for refusing it.
|
||||
fn files_on(
|
||||
manager: &Arc<SessionManager>,
|
||||
id: &str,
|
||||
path: &str,
|
||||
) -> Result<(crate::session::transport::Transport, String), ApiError> {
|
||||
let setup = setup_by_id(manager, id)?;
|
||||
let path = crate::files::check_path(path).map_err(bad_request)?;
|
||||
Ok((
|
||||
crate::session::transport::Transport::for_setup(&setup),
|
||||
path,
|
||||
))
|
||||
}
|
||||
|
||||
/// A failure from one of the scripts is the *machine's* message -- "no
|
||||
/// such file or directory", "permission denied", ssh refusing the
|
||||
/// connection -- and it is written to be read where it happened, which is
|
||||
/// the phone. So it comes back as a 400 with those words rather than as a
|
||||
/// 500 and a log line only the backend can see.
|
||||
fn from_machine(err: anyhow::Error) -> ApiError {
|
||||
ApiError::BadRequest(format!("{err:#}"))
|
||||
}
|
||||
|
||||
/// Where a path is named for these routes.
|
||||
///
|
||||
/// Query rather than a path segment: a path contains slashes, and a
|
||||
/// segment that had to be escaped and unescaped would be a second encoding
|
||||
/// to keep in step with the phone's.
|
||||
#[derive(Deserialize)]
|
||||
struct PathQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// What is in a directory, and what that directory resolved to.
|
||||
async fn list_dir(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
Query(query): Query<PathQuery>,
|
||||
) -> Result<axum::Json<crate::files::Listing>, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &query.path)?;
|
||||
crate::files::list(&transport, &path)
|
||||
.await
|
||||
.map(axum::Json)
|
||||
.map_err(from_machine)
|
||||
}
|
||||
|
||||
/// One file's content, or which of the three reasons there is none.
|
||||
///
|
||||
/// The path it was asked for rides along, so a phone that has moved on
|
||||
/// since can tell which answer this is.
|
||||
#[derive(Serialize)]
|
||||
struct FileResponse {
|
||||
path: String,
|
||||
#[serde(flatten)]
|
||||
read: crate::files::FileRead,
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
Query(query): Query<PathQuery>,
|
||||
) -> Result<axum::Json<FileResponse>, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &query.path)?;
|
||||
let read = crate::files::read(&transport, &path)
|
||||
.await
|
||||
.map_err(from_machine)?;
|
||||
Ok(axum::Json(FileResponse { path, read }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WriteFileRequest {
|
||||
path: String,
|
||||
content: String,
|
||||
/// The digest the read reported. Not optional: an editor that could
|
||||
/// omit it would be one overwrite away from losing an agent's edit,
|
||||
/// and "I did not check" is not something a caller should be able to
|
||||
/// say by leaving a field out.
|
||||
if_sha256: String,
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<WriteFileRequest>,
|
||||
) -> Result<axum::Json<crate::files::Written>, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &body.path)?;
|
||||
crate::files::write(
|
||||
&transport,
|
||||
&path,
|
||||
&body.if_sha256,
|
||||
body.content.into_bytes(),
|
||||
)
|
||||
.await
|
||||
.map_err(from_machine)?
|
||||
.map(axum::Json)
|
||||
.map_err(|crate::files::Stale| {
|
||||
ApiError::Conflict("this file changed on the machine since you opened it".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn create_file(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<CreateRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &body.path)?;
|
||||
crate::files::create_file(&transport, &path)
|
||||
.await
|
||||
.map_err(from_machine)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn create_dir(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<CreateRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &body.path)?;
|
||||
crate::files::create_dir(&transport, &path)
|
||||
.await
|
||||
.map_err(from_machine)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -1155,20 +1307,11 @@ async fn set_cwd(
|
||||
.into_iter()
|
||||
.find(|session| session.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))?;
|
||||
let cwd = body.cwd.to_string_lossy().trim().to_string();
|
||||
if cwd.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"a working directory is a path, and this one is empty".to_string(),
|
||||
));
|
||||
}
|
||||
// Absolute, because the alternative is relative to whatever the CLI is
|
||||
// launched from, which is not something the person typing it can see.
|
||||
if !cwd.starts_with('/') && !cwd.starts_with('~') {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{cwd} is not an absolute path, so where it would be depends on where the \
|
||||
session happens to start"
|
||||
)));
|
||||
}
|
||||
// The same question the explorer asks of every path it is given, so it
|
||||
// is asked in one place and refused in one wording.
|
||||
let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?;
|
||||
let setup = setup_by_id(&manager, &session.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
if !crate::session::import::directory_exists(&transport, &cwd).await {
|
||||
@@ -1417,21 +1560,19 @@ async fn ship_attachment(
|
||||
}
|
||||
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();
|
||||
// Through the transport's own "with this on stdin", which the
|
||||
// explorer's write also uses -- one description of what that means
|
||||
// rather than an ssh invocation assembled here as well.
|
||||
let transport = crate::session::transport::Transport::Ssh {
|
||||
name: ssh.address.clone(),
|
||||
ssh: ssh.clone(),
|
||||
};
|
||||
let launch = crate::session::transport::Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
let stdout = transport
|
||||
.capture_with_input(&launch, crate::session::transport::Input::File(source))
|
||||
.await?
|
||||
.ok()?;
|
||||
let dir = String::from_utf8_lossy(&stdout).trim().to_string();
|
||||
if dir.is_empty() {
|
||||
anyhow::bail!("the remote shell did not say where it put the file");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user