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
@@ -57,6 +57,20 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
finishes between two polls reads as idle at both, and its own output
|
finishes between two polls reads as idle at both, and its own output
|
||||||
gets replayed on top of itself. That bug was visible on screen as
|
gets replayed on top of itself. That bug was visible on screen as
|
||||||
`donedone`.
|
`donedone`.
|
||||||
|
- `server/src/files.rs` — the file explorer's half of the backend:
|
||||||
|
listing a directory, reading a file, writing one, creating a file or a
|
||||||
|
directory, on whichever machine a setup names. Each is one small POSIX
|
||||||
|
script run through `Transport`, so the local and the ssh case are the
|
||||||
|
same code and a machine the backend cannot reach fails with ssh's own
|
||||||
|
message. The path is a **positional argument**, never text spliced into
|
||||||
|
the script; `PATH_PRELUDE` is the one line that gives a leading `~` its
|
||||||
|
meaning, since a shell expands a tilde in text and not in an argument.
|
||||||
|
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 (409) when the file has
|
||||||
|
moved on, which is the ordinary case when an agent is editing the same
|
||||||
|
file. `EXPLORER.md` is the design.
|
||||||
- `server/src/usage.rs` — rate-limit windows, asked **of each machine that
|
- `server/src/usage.rs` — rate-limit windows, asked **of each machine that
|
||||||
can run Claude**, not of the backend. Credentials are read through the
|
can run Claude**, not of the backend. Credentials are read through the
|
||||||
session `Transport`, so a remote setup is an ssh round trip and the local
|
session `Transport`, so a remote setup is an ssh round trip and the local
|
||||||
|
|||||||
@@ -790,6 +790,11 @@ POST /sessions/:id/attachments multipart upload → id (referenced by /me
|
|||||||
GET /sessions/:id/files/:ref images the session produced or was sent
|
GET /sessions/:id/files/:ref images the session produced or was sent
|
||||||
DELETE /sessions/:id kill process, release llama-server, delete transcript+files
|
DELETE /sessions/:id kill process, release llama-server, delete transcript+files
|
||||||
GET /usage cached usage windows
|
GET /usage cached usage windows
|
||||||
|
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}; 409 if it moved on
|
||||||
|
POST /setups/:id/file {path} create empty; refused if it exists
|
||||||
|
POST /setups/:id/dir {path} create; refused if it exists
|
||||||
GET/PUT /hosts, /models config editing from the phone
|
GET/PUT /hosts, /models config editing from the phone
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -798,6 +803,17 @@ directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
|
|||||||
attachments, produced images), owner-only. Deleting a session is the
|
attachments, produced images), owner-only. Deleting a session is the
|
||||||
complete path out of everything spawning one created.
|
complete path out of everything spawning one created.
|
||||||
|
|
||||||
|
### The file explorer (decided 2026-09-03)
|
||||||
|
|
||||||
|
**`EXPLORER.md` holds this design**, decision by decision with what was
|
||||||
|
rejected, the same way this file does — it is long enough to be its own
|
||||||
|
document and it is where a change to it belongs. The one-line version: a
|
||||||
|
machine's filesystem, seen from the phone through the backend, keyed on
|
||||||
|
the **setup** rather than on a session (a session only says where to
|
||||||
|
start), with every operation one fixed shell script run through
|
||||||
|
`Transport` so the local and the ssh case are one implementation. The
|
||||||
|
security consequence is in the token paragraph below.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- TLS with a self-signed CA, pinned in the app — same
|
- TLS with a self-signed CA, pinned in the app — same
|
||||||
@@ -853,6 +869,20 @@ complete path out of everything spawning one created.
|
|||||||
gates LAN-reachable RCE; it does not (and cannot) defend a compromised
|
gates LAN-reachable RCE; it does not (and cannot) defend a compromised
|
||||||
backend host or phone — those are inside the trust boundary, and a
|
backend host or phone — those are inside the trust boundary, and a
|
||||||
compromised phone is handled by rotation.
|
compromised phone is handled by rotation.
|
||||||
|
- **The explorer's routes take a path, and that is deliberate**
|
||||||
|
(2026-09-03; see EXPLORER.md's decision 3). Elsewhere the rule is that
|
||||||
|
the phone picks an **id** and the server resolves which file it names —
|
||||||
|
the import listing is written that way so an enrolled token cannot
|
||||||
|
become "read me an arbitrary file". `/setups/{id}/dir` and
|
||||||
|
`/setups/{id}/file` take the path, because the path is the whole
|
||||||
|
feature. It grants nothing new: the same token already spawns a
|
||||||
|
bypass-permissions agent in any directory on any machine a setup names,
|
||||||
|
and that agent already reads and writes every file its user can, so
|
||||||
|
this is a shorter path to authority the token holds either way. The
|
||||||
|
import rule stands where it is, because there a path was unnecessary
|
||||||
|
and refusing one cost nothing. What is unchanged is the harder line:
|
||||||
|
**no route accepts a command.** Listing, reading and writing are fixed
|
||||||
|
scripts in `files.rs`; the phone chooses only the path and the bytes.
|
||||||
- **Generation**: 256 bits from the OS CSPRNG on first run, base64url. A
|
- **Generation**: 256 bits from the OS CSPRNG on first run, base64url. A
|
||||||
machine credential, never typed twice, so unguessable costs nothing; at
|
machine credential, never typed twice, so unguessable costs nothing; at
|
||||||
this entropy no key stretching is needed.
|
this entropy no key stretching is needed.
|
||||||
|
|||||||
@@ -248,6 +248,39 @@ awk -v mb="$BIG_MB" 'BEGIN {
|
|||||||
}
|
}
|
||||||
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
||||||
}' > "$big"
|
}' > "$big"
|
||||||
|
|
||||||
|
# A tree for the file explorer, at the sandbox home's `~/files`, holding
|
||||||
|
# the states that are otherwise only reachable by finding a real machine
|
||||||
|
# in one of them. The default state is the one everybody looks at, so what
|
||||||
|
# is worth having here is the rest: an empty directory, names a shell would
|
||||||
|
# mangle, something that is not text, something too big to send, something
|
||||||
|
# nobody may read, a link that navigates, and one file per language so the
|
||||||
|
# highlighter is exercised rather than assumed.
|
||||||
|
FILES=$ROOT/home/files
|
||||||
|
mkdir -p "$FILES/empty" "$FILES/sub"
|
||||||
|
printf 'one\ntwo\nthree\n' >"$FILES/hello.txt"
|
||||||
|
# A real tab, via printf: `\t` inside double quotes is a backslash and a t,
|
||||||
|
# which is a different (and easier) name than the one worth testing.
|
||||||
|
tabbed=$(printf 'with\ta tab.txt')
|
||||||
|
printf 'a tab in the name\n' >"$FILES/$tabbed"
|
||||||
|
printf "an apostrophe in the name\n" >"$FILES/it's a file.txt"
|
||||||
|
printf 'fn main() {\n // a comment\n println!("hello, {}", 1_000);\n}\n' >"$FILES/main.rs"
|
||||||
|
printf 'fun main() {\n // a comment\n println("hello")\n}\n' >"$FILES/Main.kt"
|
||||||
|
printf 'def main():\n # a comment\n print("hello")\n' >"$FILES/main.py"
|
||||||
|
printf '#!/bin/sh\n# a comment\necho hello\n' >"$FILES/run.sh"
|
||||||
|
chmod +x "$FILES/run.sh"
|
||||||
|
printf '{"a": 1, "b": [true, null]}\n' >"$FILES/data.json"
|
||||||
|
# Not UTF-8, so it reads as binary rather than as mojibake.
|
||||||
|
printf '\377\376\000\001binary\n' >"$FILES/picture.bin"
|
||||||
|
# Over FILE_LIMIT (1 MiB), so the read refuses before anything transfers.
|
||||||
|
awk 'BEGIN { for (i = 0; i < 40000; i++) print "a line of a log that nobody is going to read" }' >"$FILES/big.log"
|
||||||
|
# Unreadable on purpose: a directory listing still shows it, and opening it
|
||||||
|
# fails with the machine's own words rather than with an empty file.
|
||||||
|
printf 'secret\n' >"$FILES/unreadable.txt"
|
||||||
|
chmod 000 "$FILES/unreadable.txt"
|
||||||
|
printf 'in a subdirectory\n' >"$FILES/sub/inside.txt"
|
||||||
|
ln -sfn sub "$FILES/link-to-sub"
|
||||||
|
ln -sfn nowhere "$FILES/broken-link"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$regen_config" ]; then
|
if [ -n "$regen_config" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
//! Reading and changing files on the machine a setup names.
|
||||||
|
//!
|
||||||
|
//! Every operation here is one small POSIX shell script handed to
|
||||||
|
//! `Transport`, exactly the way `setups::discover` and `import::list`
|
||||||
|
//! already ask a machine a question. That is what makes the local and the
|
||||||
|
//! ssh case one implementation: a second one written against `std::fs`
|
||||||
|
//! would be the one that gets tested, and the remote half -- the ordering
|
||||||
|
//! of entries, what a symlink reports, how a permission error reads --
|
||||||
|
//! would drift until it shipped broken. The cost is an `sh` process per
|
||||||
|
//! operation on this machine, which is under a millisecond.
|
||||||
|
//!
|
||||||
|
//! The scripts assume GNU coreutils and findutils (`find -printf`,
|
||||||
|
//! `stat -c`, `sha256sum`, `chmod --reference`), which is what
|
||||||
|
//! `session::import` already assumes and what both machines here run. One
|
||||||
|
//! without them fails with that tool's own message, which names what is
|
||||||
|
//! missing.
|
||||||
|
//!
|
||||||
|
//! **The phone names a path, and that is deliberate** -- see PLAN.md's
|
||||||
|
//! Security section. The enrolled token already spawns an agent in any
|
||||||
|
//! directory on any machine a setup names, and that agent reads and writes
|
||||||
|
//! every file its user can; this is a shorter path to authority the token
|
||||||
|
//! already holds. What is *not* given up: no route here accepts a command.
|
||||||
|
//! Listing, reading and writing are the fixed scripts below, and the phone
|
||||||
|
//! chooses only the path and the bytes.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::session::transport::{Input, Launch, Transport};
|
||||||
|
|
||||||
|
/// The most of a file that crosses the tunnel, in bytes.
|
||||||
|
///
|
||||||
|
/// Checked on the far machine before anything reads the file, so a 2 GB
|
||||||
|
/// log costs a `stat` rather than a transfer. A file over it is reported
|
||||||
|
/// as [`FileRead::TooBig`] with its size, because "we did not read this"
|
||||||
|
/// and "this is empty" must not look the same on the phone.
|
||||||
|
pub const FILE_LIMIT: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// The prelude every script here starts with: the path arrives as `$1`,
|
||||||
|
/// and this is where a leading `~` becomes that machine's own home.
|
||||||
|
///
|
||||||
|
/// The path is a **positional argument** and never text spliced into the
|
||||||
|
/// script -- the rule `import::find` follows with `"$1"`, for the reason
|
||||||
|
/// `ssh::quote` exists: a path is attacker-adjacent input in a server
|
||||||
|
/// whose job is running commands, and interpolated it would be syntax
|
||||||
|
/// rather than data.
|
||||||
|
///
|
||||||
|
/// `~` is the one character that costs something for it. A shell expands a
|
||||||
|
/// tilde in *text*, so a path handed over as an argument arrives with a
|
||||||
|
/// literal one; expanding it here, once, gives it the same meaning
|
||||||
|
/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and
|
||||||
|
/// it is the *far* machine's `$HOME` -- the only one that could be right.
|
||||||
|
/// `~user` stays literal here too, and fails with the shell's own message.
|
||||||
|
///
|
||||||
|
/// Everything below uses `$p` for the path and `$2` for whatever else it
|
||||||
|
/// was given.
|
||||||
|
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
|
||||||
|
|
||||||
|
/// What a directory turned out to be, and what is in it.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Listing {
|
||||||
|
/// `pwd -P` of the directory that was listed.
|
||||||
|
///
|
||||||
|
/// Answered by the machine rather than worked out here, so the phone
|
||||||
|
/// navigates on a resolved absolute path: the parent of one of these
|
||||||
|
/// is a string operation, and a `~` a session was spawned with is
|
||||||
|
/// shown as what it turned out to be.
|
||||||
|
pub path: String,
|
||||||
|
pub entries: Vec<Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Entry {
|
||||||
|
pub name: String,
|
||||||
|
/// What tapping it does, which for a symlink is decided by its
|
||||||
|
/// *target* -- a link to a directory navigates.
|
||||||
|
pub kind: EntryKind,
|
||||||
|
pub size: u64,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub modified: i64,
|
||||||
|
/// Whether the entry itself is a symlink, whatever [`Entry::kind`]
|
||||||
|
/// says its target is.
|
||||||
|
pub link: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq, Clone, Copy)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum EntryKind {
|
||||||
|
Directory,
|
||||||
|
File,
|
||||||
|
/// A socket, a device, a fifo -- and a symlink whose target is missing
|
||||||
|
/// or loops, which `find` reports the same way. Shown, because a
|
||||||
|
/// directory that hid what it held would be lying about being empty.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What reading a file produced -- four answers, not content-or-error.
|
||||||
|
///
|
||||||
|
/// A binary file drawn as text and a big file cut off silently are both
|
||||||
|
/// wrong in ways the reader cannot see, and "couldn't read it" must not
|
||||||
|
/// look like "it is empty". A file that is genuinely empty is
|
||||||
|
/// [`FileRead::Text`] with nothing in it, which is what it is.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
|
pub enum FileRead {
|
||||||
|
Text {
|
||||||
|
size: u64,
|
||||||
|
modified: i64,
|
||||||
|
/// What [`write`] is given back to prove the file has not moved on.
|
||||||
|
sha256: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
/// Not UTF-8. Its size is reported; nothing is shown.
|
||||||
|
Binary { size: u64, modified: i64 },
|
||||||
|
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what
|
||||||
|
/// they are looking at rather than only that they cannot have it.
|
||||||
|
TooBig { size: u64, modified: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a file is after being written, so the editor's precondition is
|
||||||
|
/// fresh without a second read.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Written {
|
||||||
|
pub size: u64,
|
||||||
|
pub modified: i64,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exit code the write script uses for "this is not the file you
|
||||||
|
/// read", which the route turns into a 409. Distinct from every other
|
||||||
|
/// failure, which is a message from the machine.
|
||||||
|
pub const STALE: i32 = 3;
|
||||||
|
|
||||||
|
/// A path the phone may name: absolute, or home-relative on that machine.
|
||||||
|
///
|
||||||
|
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for
|
||||||
|
/// the same reason -- a relative path is relative to something nobody
|
||||||
|
/// looking at the screen can see, so it is refused rather than resolved
|
||||||
|
/// against a guess. Returns the path with the whitespace a phone keyboard
|
||||||
|
/// adds taken off.
|
||||||
|
pub fn check_path(path: &str) -> Result<String> {
|
||||||
|
let path = path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
anyhow::bail!("that is a path with nothing in it");
|
||||||
|
}
|
||||||
|
if !path.starts_with('/') && !path.starts_with('~') {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{path} is not an absolute path, so where it would be depends on something \
|
||||||
|
nobody looking at this can see -- start it with / or ~"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one of the scripts below with `path` as `$1` and `extra` as `$2`.
|
||||||
|
fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
|
||||||
|
let mut args = vec![
|
||||||
|
"-c".to_string(),
|
||||||
|
script,
|
||||||
|
// `$0`, which is what `sh` names itself in a message about the
|
||||||
|
// script; the path is `$1`.
|
||||||
|
"sh".to_string(),
|
||||||
|
path.to_string(),
|
||||||
|
];
|
||||||
|
args.extend(extra.map(str::to_string));
|
||||||
|
Launch::new("sh", args, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything in `path`, and what `path` resolved to.
|
||||||
|
///
|
||||||
|
/// Entries are separated by `\0` and their fields by `\t`, so a filename
|
||||||
|
/// with a newline or a tab in it survives -- both are legal, and a listing
|
||||||
|
/// that lost one would quietly show the wrong thing.
|
||||||
|
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
||||||
|
let script = format!(
|
||||||
|
"{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \
|
||||||
|
find . -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\0'"
|
||||||
|
);
|
||||||
|
let out = transport
|
||||||
|
.capture_with_input(&launch(script, path, None), Input::None)
|
||||||
|
.await?
|
||||||
|
.ok()?;
|
||||||
|
let out = String::from_utf8_lossy(&out);
|
||||||
|
let (resolved, entries) = out
|
||||||
|
.split_once('\n')
|
||||||
|
.context("the machine did not say which directory it listed")?;
|
||||||
|
Ok(Listing {
|
||||||
|
path: resolved.to_string(),
|
||||||
|
entries: parse_entries(entries),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `find` output above, as rows.
|
||||||
|
///
|
||||||
|
/// A record that does not have all five fields is dropped rather than
|
||||||
|
/// guessed at: it can only come from a `find` that printed something else,
|
||||||
|
/// and half a row is worse than no row.
|
||||||
|
fn parse_entries(text: &str) -> Vec<Entry> {
|
||||||
|
text.split('\0')
|
||||||
|
.filter(|record| !record.is_empty())
|
||||||
|
.filter_map(|record| {
|
||||||
|
// Five, so that a name containing a tab keeps it: `%f` is last
|
||||||
|
// exactly so the split can stop.
|
||||||
|
let mut fields = record.splitn(5, '\t');
|
||||||
|
let own = fields.next()?;
|
||||||
|
let target = fields.next()?;
|
||||||
|
let size = fields.next()?.parse().ok()?;
|
||||||
|
// `%T@` is seconds with a fractional part; the phone shows a
|
||||||
|
// date, so the fraction is dropped rather than carried.
|
||||||
|
let modified = fields.next()?.split('.').next()?.parse().ok()?;
|
||||||
|
let name = fields.next()?;
|
||||||
|
Some(Entry {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind: match target {
|
||||||
|
"d" => EntryKind::Directory,
|
||||||
|
"f" => EntryKind::File,
|
||||||
|
_ => EntryKind::Other,
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
link: own == "l",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file's content, or the reason there is none to show.
|
||||||
|
///
|
||||||
|
/// The size is checked on the far machine *before* anything reads the
|
||||||
|
/// file, so a file over [`FILE_LIMIT`] costs a `stat` rather than a
|
||||||
|
/// transfer. `stat -L` and `sha256sum` both follow symlinks, as `cat`
|
||||||
|
/// does, so a link to a file reports the file.
|
||||||
|
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
|
||||||
|
// Two header lines, then the bytes: `<size> <mtime>`, then either
|
||||||
|
// `tooBig` or the digest. A header rather than a JSON envelope because
|
||||||
|
// the content is bytes and may not be text at all.
|
||||||
|
let script = format!(
|
||||||
|
"{PATH_PRELUDE}set -e; \
|
||||||
|
h=$(stat -L -c '%s %Y' -- \"$p\"); \
|
||||||
|
case ${{h%% *}} in *[!0-9]*) exit 1;; esac; \
|
||||||
|
if [ \"${{h%% *}}\" -gt {FILE_LIMIT} ]; then printf '%s\\ntooBig\\n' \"$h\"; exit 0; fi; \
|
||||||
|
d=$(sha256sum -- \"$p\"); \
|
||||||
|
printf '%s\\n%s\\n' \"$h\" \"${{d%% *}}\"; \
|
||||||
|
cat -- \"$p\""
|
||||||
|
);
|
||||||
|
let out = transport
|
||||||
|
.capture_with_input(&launch(script, path, None), Input::None)
|
||||||
|
.await?
|
||||||
|
.ok()?;
|
||||||
|
let (size, modified, second, content) = split_read(&out)?;
|
||||||
|
if second == "tooBig" {
|
||||||
|
return Ok(FileRead::TooBig { size, modified });
|
||||||
|
}
|
||||||
|
Ok(match String::from_utf8(content.to_vec()) {
|
||||||
|
Ok(content) => FileRead::Text {
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
sha256: second.to_string(),
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
Err(_) => FileRead::Binary { size, modified },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The read script's two header lines and the bytes after them.
|
||||||
|
fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
|
||||||
|
let missing = || anyhow::anyhow!("the machine did not describe the file it read");
|
||||||
|
let first = out.iter().position(|b| *b == b'\n').ok_or_else(missing)?;
|
||||||
|
let rest = &out[first + 1..];
|
||||||
|
let second = rest.iter().position(|b| *b == b'\n').ok_or_else(missing)?;
|
||||||
|
let header = std::str::from_utf8(&out[..first]).map_err(|_| missing())?;
|
||||||
|
let (size, modified) = header.split_once(' ').ok_or_else(missing)?;
|
||||||
|
Ok((
|
||||||
|
size.trim().parse()?,
|
||||||
|
modified.trim().parse()?,
|
||||||
|
std::str::from_utf8(&rest[..second]).map_err(|_| missing())?,
|
||||||
|
&rest[second + 1..],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces `path`'s contents, but only while it still hashes to
|
||||||
|
/// `expected`.
|
||||||
|
///
|
||||||
|
/// Agents edit files while people read them, so a stale copy landing on
|
||||||
|
/// top of somebody else's edit is the common case rather than the exotic
|
||||||
|
/// one. The digest the reader was shown is compared on the machine, and a
|
||||||
|
/// file that has moved on comes back as [`STALE`] rather than being
|
||||||
|
/// overwritten.
|
||||||
|
///
|
||||||
|
/// A temp file and a rename, so a connection dropped mid-write leaves the
|
||||||
|
/// old file whole rather than a truncated one, and `chmod --reference` so
|
||||||
|
/// the mode survives -- an executable script written as a fresh file would
|
||||||
|
/// stop being one. What that trades away: the inode changes, so a hard
|
||||||
|
/// link elsewhere stops being the same file. Editors do the same.
|
||||||
|
///
|
||||||
|
/// The check and the write are **not** atomic against a writer landing
|
||||||
|
/// between them -- a window of microseconds on that machine. Accepted: the
|
||||||
|
/// alternative is a lock this has no way to make every other writer take.
|
||||||
|
pub async fn write(
|
||||||
|
transport: &Transport,
|
||||||
|
path: &str,
|
||||||
|
expected: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> Result<Result<Written, Stale>> {
|
||||||
|
let script = format!(
|
||||||
|
"{PATH_PRELUDE}set -e; \
|
||||||
|
have=$(sha256sum -- \"$p\"); \
|
||||||
|
[ \"${{have%% *}}\" = \"$2\" ] || exit {STALE}; \
|
||||||
|
cat > \"$p.ai-app-tmp\"; \
|
||||||
|
chmod --reference=\"$p\" \"$p.ai-app-tmp\"; \
|
||||||
|
mv -f -- \"$p.ai-app-tmp\" \"$p\"; \
|
||||||
|
stat -L -c '%s %Y' -- \"$p\"; \
|
||||||
|
now=$(sha256sum -- \"$p\"); \
|
||||||
|
printf '%s\\n' \"${{now%% *}}\""
|
||||||
|
);
|
||||||
|
let captured = transport
|
||||||
|
.capture_with_input(&launch(script, path, Some(expected)), Input::Bytes(bytes))
|
||||||
|
.await?;
|
||||||
|
if captured.status.code() == Some(STALE) {
|
||||||
|
return Ok(Err(Stale));
|
||||||
|
}
|
||||||
|
let out = String::from_utf8_lossy(&captured.ok()?).into_owned();
|
||||||
|
let mut lines = out.lines();
|
||||||
|
let missing = || anyhow::anyhow!("the machine did not describe the file it wrote");
|
||||||
|
let header = lines.next().ok_or_else(missing)?;
|
||||||
|
let (size, modified) = header.split_once(' ').ok_or_else(missing)?;
|
||||||
|
Ok(Ok(Written {
|
||||||
|
size: size.trim().parse()?,
|
||||||
|
modified: modified.trim().parse()?,
|
||||||
|
sha256: lines.next().ok_or_else(missing)?.trim().to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file is not the one that was read. Its own type rather than an
|
||||||
|
/// error string, because the route answers it with a different status and
|
||||||
|
/// the phone with a different question.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Stale;
|
||||||
|
|
||||||
|
/// Creates an empty file, refusing to truncate one that is already there.
|
||||||
|
///
|
||||||
|
/// `set -C` is the shell's own noclobber, so an existing name fails with
|
||||||
|
/// the shell's own message rather than with a check that could race the
|
||||||
|
/// redirection it is guarding.
|
||||||
|
pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
|
||||||
|
let script = format!("{PATH_PRELUDE}set -C; : > \"$p\"");
|
||||||
|
transport
|
||||||
|
.capture_with_input(&launch(script, path, None), Input::None)
|
||||||
|
.await?
|
||||||
|
.ok()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a directory. Plain `mkdir`, not `-p`, for the same reason
|
||||||
|
/// [`create_file`] sets noclobber: a name that exists is something the
|
||||||
|
/// person typing it should be told about.
|
||||||
|
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
|
||||||
|
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
|
||||||
|
transport
|
||||||
|
.capture_with_input(&launch(script, path, None), Input::None)
|
||||||
|
.await?
|
||||||
|
.ok()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stdout of a script that must have succeeded, as text.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn text(captured: crate::session::transport::Captured) -> Result<String> {
|
||||||
|
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The names a listing has to survive. All four are legal, and each
|
||||||
|
/// one broke a listing somewhere before it was separated with `\0`.
|
||||||
|
#[test]
|
||||||
|
fn a_listing_survives_the_names_a_filesystem_allows() {
|
||||||
|
let record = |own: &str, target: &str, size: &str, time: &str, name: &str| {
|
||||||
|
format!("{own}\t{target}\t{size}\t{time}\t{name}\0")
|
||||||
|
};
|
||||||
|
let text = [
|
||||||
|
record("f", "f", "12", "1756900000.5", "with\ta tab"),
|
||||||
|
record("f", "f", "13", "1756900001.0", "with\na newline"),
|
||||||
|
record("d", "d", "4096", "1756900002.0", "-leading-dash"),
|
||||||
|
record("l", "d", "7", "1756900003.9", "it's a link"),
|
||||||
|
record("l", "N", "7", "1756900004.0", "dangling"),
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
let entries = parse_entries(&text);
|
||||||
|
assert_eq!(
|
||||||
|
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"with\ta tab",
|
||||||
|
"with\na newline",
|
||||||
|
"-leading-dash",
|
||||||
|
"it's a link",
|
||||||
|
"dangling",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(entries[0].kind, EntryKind::File);
|
||||||
|
assert_eq!(entries[0].modified, 1756900000);
|
||||||
|
assert_eq!(entries[2].kind, EntryKind::Directory);
|
||||||
|
assert_eq!(entries[2].size, 4096);
|
||||||
|
// The kind is the target's, so a link to a directory navigates --
|
||||||
|
// and one whose target is gone is neither a file nor a directory.
|
||||||
|
assert!(entries[3].link);
|
||||||
|
assert_eq!(entries[3].kind, EntryKind::Directory);
|
||||||
|
assert_eq!(entries[4].kind, EntryKind::Other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_half_written_record_is_dropped_rather_than_guessed_at() {
|
||||||
|
assert!(parse_entries("f\tf\t12\0").is_empty());
|
||||||
|
assert!(parse_entries("").is_empty());
|
||||||
|
assert!(parse_entries("\0\0").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_is_absolute_or_home_relative() {
|
||||||
|
assert_eq!(check_path(" /etc/hosts ").unwrap(), "/etc/hosts");
|
||||||
|
assert_eq!(check_path("~/repos").unwrap(), "~/repos");
|
||||||
|
assert!(check_path("").is_err());
|
||||||
|
assert!(check_path(" ").is_err());
|
||||||
|
let refused = check_path("repos/ai-app").unwrap_err().to_string();
|
||||||
|
assert!(refused.contains("repos/ai-app"), "{refused}");
|
||||||
|
assert!(refused.contains("start it with / or ~"), "{refused}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scripts, against a real tree, through the transport that runs
|
||||||
|
/// them here -- which is cheap, because `sh` is wherever `cargo test`
|
||||||
|
/// is. The remote transport runs the identical text.
|
||||||
|
fn tree() -> tempfile::TempDir {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
|
||||||
|
std::fs::write(dir.path().join("it's a file"), "quoted\n").unwrap();
|
||||||
|
std::fs::write(dir.path().join("binary.bin"), [0xff, 0xfe, 0x00, 0x01]).unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(dir: &tempfile::TempDir, name: &str) -> String {
|
||||||
|
dir.path().join(name).to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_directory_lists_with_its_resolved_path() {
|
||||||
|
let dir = tree();
|
||||||
|
let listing = list(&Transport::Here, &dir.path().to_string_lossy())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// `pwd -P`, so a temp directory reached through a symlinked /tmp
|
||||||
|
// answers with what it really is -- which is the path the phone
|
||||||
|
// then navigates on.
|
||||||
|
assert!(listing.path.starts_with('/'), "{}", listing.path);
|
||||||
|
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, ["binary.bin", "hello.txt", "it's a file", "sub"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_directory_fails_with_the_machine_s_own_message() {
|
||||||
|
let dir = tree();
|
||||||
|
let err = list(&Transport::Here, &at(&dir, "nope"))
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("nope"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_binary_and_too_big_are_three_different_answers() {
|
||||||
|
let dir = tree();
|
||||||
|
let read_at = async |name: &str| read(&Transport::Here, &at(&dir, name)).await.unwrap();
|
||||||
|
|
||||||
|
match read_at("hello.txt").await {
|
||||||
|
FileRead::Text {
|
||||||
|
size,
|
||||||
|
content,
|
||||||
|
sha256,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(size, 8);
|
||||||
|
assert_eq!(content, "one\ntwo\n");
|
||||||
|
assert_eq!(sha256.len(), 64);
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
read_at("binary.bin").await,
|
||||||
|
FileRead::Binary { size: 4, .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap();
|
||||||
|
assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
|
||||||
|
|
||||||
|
// Empty is text with nothing in it, which is what it is -- not a
|
||||||
|
// fourth state and not the same as any of the three above.
|
||||||
|
std::fs::write(dir.path().join("empty"), "").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
read_at("empty").await,
|
||||||
|
FileRead::Text { size: 0, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_write_lands_only_while_the_file_is_the_one_that_was_read() {
|
||||||
|
let dir = tree();
|
||||||
|
let path = at(&dir, "hello.txt");
|
||||||
|
let FileRead::Text { sha256, .. } = read(&Transport::Here, &path).await.unwrap() else {
|
||||||
|
panic!("expected text");
|
||||||
|
};
|
||||||
|
let written = write(&Transport::Here, &path, &sha256, b"three\n".to_vec())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("nothing had changed it");
|
||||||
|
assert_eq!(written.size, 6);
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "three\n");
|
||||||
|
|
||||||
|
// The same digest again, against a file that has moved on: the
|
||||||
|
// agent-edits-while-you-read case, which must refuse rather than
|
||||||
|
// overwrite.
|
||||||
|
std::fs::write(&path, "somebody else\n").unwrap();
|
||||||
|
assert!(
|
||||||
|
write(&Transport::Here, &path, &sha256, b"mine\n".to_vec())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "somebody else\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_write_keeps_the_mode_it_found() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tree();
|
||||||
|
let path = at(&dir, "script.sh");
|
||||||
|
std::fs::write(&path, "#!/bin/sh\n").unwrap();
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
let FileRead::Text { sha256, .. } = read(&Transport::Here, &path).await.unwrap() else {
|
||||||
|
panic!("expected text");
|
||||||
|
};
|
||||||
|
write(
|
||||||
|
&Transport::Here,
|
||||||
|
&path,
|
||||||
|
&sha256,
|
||||||
|
b"#!/bin/sh\ntrue\n".to_vec(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o755, "an executable script stopped being one");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn creating_refuses_a_name_that_is_already_there() {
|
||||||
|
let dir = tree();
|
||||||
|
create_file(&Transport::Here, &at(&dir, "new.txt"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(at(&dir, "new.txt")).unwrap(), "");
|
||||||
|
assert!(
|
||||||
|
create_file(&Transport::Here, &at(&dir, "new.txt"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
// And the file that was there is untouched.
|
||||||
|
std::fs::write(at(&dir, "new.txt"), "mine\n").unwrap();
|
||||||
|
assert!(
|
||||||
|
create_file(&Transport::Here, &at(&dir, "new.txt"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(at(&dir, "new.txt")).unwrap(),
|
||||||
|
"mine\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
create_dir(&Transport::Here, &at(&dir, "made"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(at(&dir, "made").parse::<std::path::PathBuf>().is_ok());
|
||||||
|
assert!(
|
||||||
|
create_dir(&Transport::Here, &at(&dir, "made"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path that tries to close the quote and start a command of its
|
||||||
|
/// own. It is an argument rather than syntax, so it stays one absurd
|
||||||
|
/// filename -- the same property `ssh.rs` tests for the remote side.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_path_full_of_shell_crosses_as_data() {
|
||||||
|
let dir = tree();
|
||||||
|
let evil = at(&dir, "'; touch pwned; '");
|
||||||
|
create_file(&Transport::Here, &evil).await.unwrap();
|
||||||
|
assert!(std::path::Path::new(&evil).is_file());
|
||||||
|
assert!(!dir.path().join("pwned").exists());
|
||||||
|
|
||||||
|
let listing = list(&Transport::Here, &dir.path().to_string_lossy())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
listing
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.name == "'; touch pwned; '")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tilde is the one character the prelude gives a meaning, and it
|
||||||
|
/// is the *machine's* home -- here, this one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_leading_tilde_means_the_machine_s_own_home() {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let script = format!("{PATH_PRELUDE}printf '%s' \"$p\"");
|
||||||
|
let captured = Transport::Here
|
||||||
|
.capture_with_input(&launch(script.clone(), "~/repos", None), Input::None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
text(captured).unwrap(),
|
||||||
|
home.join("repos").to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
let captured = Transport::Here
|
||||||
|
.capture_with_input(&launch(script.clone(), "~", None), Input::None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(text(captured).unwrap(), home.to_string_lossy());
|
||||||
|
|
||||||
|
// Leading, and its own segment only -- `ssh::quote_path`'s rule.
|
||||||
|
for literal in ["/tmp/~/x", "~user/x"] {
|
||||||
|
let captured = Transport::Here
|
||||||
|
.capture_with_input(&launch(script.clone(), literal, None), Input::None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(text(captured).unwrap(), literal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod files;
|
||||||
mod media;
|
mod media;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|||||||
+168
-27
@@ -7,6 +7,12 @@
|
|||||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||||
//! GET /setups/{id} one machine, for refetching after a change
|
//! 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?}
|
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
|
||||||
//! DELETE /setups/{id} remove, refused while sessions use it
|
//! DELETE /setups/{id} remove, refused while sessions use it
|
||||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||||
@@ -92,6 +98,15 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
|||||||
"/setups/{id}",
|
"/setups/{id}",
|
||||||
get(read_setup).put(update_setup).delete(delete_setup),
|
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", get(list_sessions).post(spawn_session))
|
||||||
.route("/sessions/{id}", get(read_session).delete(delete_session))
|
.route("/sessions/{id}", get(read_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/events", get(events))
|
.route("/sessions/{id}/events", get(events))
|
||||||
@@ -447,6 +462,143 @@ async fn delete_setup(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -1155,20 +1307,11 @@ async fn set_cwd(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|session| session.id == id)
|
.find(|session| session.id == id)
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("no session {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
|
// Absolute, because the alternative is relative to whatever the CLI is
|
||||||
// launched from, which is not something the person typing it can see.
|
// launched from, which is not something the person typing it can see.
|
||||||
if !cwd.starts_with('/') && !cwd.starts_with('~') {
|
// The same question the explorer asks of every path it is given, so it
|
||||||
return Err(ApiError::BadRequest(format!(
|
// is asked in one place and refused in one wording.
|
||||||
"{cwd} is not an absolute path, so where it would be depends on where the \
|
let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?;
|
||||||
session happens to start"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let setup = setup_by_id(&manager, &session.setup)?;
|
let setup = setup_by_id(&manager, &session.setup)?;
|
||||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||||
if !crate::session::import::directory_exists(&transport, &cwd).await {
|
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)));
|
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 source = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?;
|
||||||
let mut command = tokio::process::Command::from(crate::ssh::command(
|
// Through the transport's own "with this on stdin", which the
|
||||||
Some(ssh),
|
// explorer's write also uses -- one description of what that means
|
||||||
"sh",
|
// rather than an ssh invocation assembled here as well.
|
||||||
&["-c".to_string(), script],
|
let transport = crate::session::transport::Transport::Ssh {
|
||||||
None,
|
name: ssh.address.clone(),
|
||||||
));
|
ssh: ssh.clone(),
|
||||||
command
|
};
|
||||||
.stdin(source)
|
let launch = crate::session::transport::Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||||
.stdout(std::process::Stdio::piped())
|
let stdout = transport
|
||||||
.stderr(std::process::Stdio::piped());
|
.capture_with_input(&launch, crate::session::transport::Input::File(source))
|
||||||
let output = command.output().await.context("run ssh")?;
|
.await?
|
||||||
if !output.status.success() {
|
.ok()?;
|
||||||
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
|
let dir = String::from_utf8_lossy(&stdout).trim().to_string();
|
||||||
}
|
|
||||||
let dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
||||||
if dir.is_empty() {
|
if dir.is_empty() {
|
||||||
anyhow::bail!("the remote shell did not say where it put the file");
|
anyhow::bail!("the remote shell did not say where it put the file");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ impl Launch {
|
|||||||
pub enum Streams {
|
pub enum Streams {
|
||||||
/// Pipes owned by this server; the child is killed when they drop.
|
/// Pipes owned by this server; the child is killed when they drop.
|
||||||
Piped,
|
Piped,
|
||||||
|
/// The same, except that stdin is already open on something this
|
||||||
|
/// server holds -- the file being copied to another machine. Bytes
|
||||||
|
/// this process has in memory do not need this: [`Streams::Piped`]
|
||||||
|
/// gives a pipe to write them into as the child reads.
|
||||||
|
PipedFrom(Stdio),
|
||||||
/// Files -- and, for stdin, a fifo the child itself holds open so it
|
/// Files -- and, for stdin, a fifo the child itself holds open so it
|
||||||
/// never reads EOF -- that outlast this process.
|
/// never reads EOF -- that outlast this process.
|
||||||
Detached {
|
Detached {
|
||||||
@@ -117,6 +122,13 @@ impl Transport {
|
|||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true);
|
.kill_on_drop(true);
|
||||||
}
|
}
|
||||||
|
Streams::PipedFrom(stdin) => {
|
||||||
|
command
|
||||||
|
.stdin(stdin)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
}
|
||||||
Streams::Detached {
|
Streams::Detached {
|
||||||
stdin,
|
stdin,
|
||||||
stdout,
|
stdout,
|
||||||
@@ -174,6 +186,57 @@ impl Transport {
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs `launch` with `input` on its stdin and reports everything it
|
||||||
|
/// produced -- stdout as bytes, stderr as text, and the exit status.
|
||||||
|
///
|
||||||
|
/// The one description of "run this there, with this on stdin", so
|
||||||
|
/// that shipping an attachment and writing a file through the explorer
|
||||||
|
/// are the same operation rather than two. It is also the only capture
|
||||||
|
/// that hands back the **status**: a script can then answer with an
|
||||||
|
/// exit code the caller distinguishes (the explorer's write says
|
||||||
|
/// `exit 3` for "this file is not the one you read"), which
|
||||||
|
/// [`Transport::capture`] cannot express because it turns every
|
||||||
|
/// failure into one error.
|
||||||
|
///
|
||||||
|
/// Bytes rather than a `String`, because a file's contents are not
|
||||||
|
/// text until something has checked, and lossy decoding would replace
|
||||||
|
/// the evidence that they are not.
|
||||||
|
///
|
||||||
|
/// `Err` means the process could not be started at all; a process that
|
||||||
|
/// ran and failed is a [`Captured`] with a status saying so.
|
||||||
|
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
|
||||||
|
let (streams, to_write) = match input {
|
||||||
|
Input::None => (Streams::Piped, None),
|
||||||
|
Input::Bytes(bytes) => (Streams::Piped, Some(bytes)),
|
||||||
|
Input::File(file) => (Streams::PipedFrom(file.into()), None),
|
||||||
|
};
|
||||||
|
let mut child = self.spawn(launch, streams)?;
|
||||||
|
if let Some(bytes) = to_write {
|
||||||
|
// Written from a task rather than before the wait, because the
|
||||||
|
// child may not read all of it -- the write script exits
|
||||||
|
// without reading when the file has changed underneath -- and
|
||||||
|
// a caller blocked on filling a pipe nobody is draining would
|
||||||
|
// deadlock instead of getting that answer. The broken pipe is
|
||||||
|
// the expected end of this write, so it is dropped: what
|
||||||
|
// happened is the exit status below.
|
||||||
|
let mut stdin = child.stdin.take().context("the child has no stdin")?;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let _ = stdin.write_all(&bytes).await;
|
||||||
|
let _ = stdin.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("waiting for \"{}\" {}", launch.program, self.describe()))?;
|
||||||
|
Ok(Captured {
|
||||||
|
status: output.status,
|
||||||
|
stdout: output.stdout,
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// How to say where this runs, for a log line a person reads.
|
/// How to say where this runs, for a log line a person reads.
|
||||||
pub fn describe(&self) -> String {
|
pub fn describe(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
@@ -182,3 +245,41 @@ impl Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a command is given on its standard input.
|
||||||
|
///
|
||||||
|
/// Three cases rather than an `Option<Stdio>` because they are three
|
||||||
|
/// genuinely different arrangements and only this knows which: nothing to
|
||||||
|
/// say, bytes this process is holding, or a file it has open. The last one
|
||||||
|
/// is how a several-hundred-megabyte attachment reaches another machine
|
||||||
|
/// without passing through this server's memory.
|
||||||
|
pub enum Input {
|
||||||
|
None,
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
File(std::fs::File),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a finished command produced, including the status.
|
||||||
|
pub struct Captured {
|
||||||
|
pub status: std::process::ExitStatus,
|
||||||
|
pub stdout: Vec<u8>,
|
||||||
|
/// Trimmed, and what a failure is reported as: ssh's own refusals and
|
||||||
|
/// a tool's own message about the file it could not open are both the
|
||||||
|
/// useful half of why something did not work, and both are written to
|
||||||
|
/// name the thing.
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Captured {
|
||||||
|
/// The stdout of a command that succeeded, or the machine's own words.
|
||||||
|
pub fn ok(self) -> Result<Vec<u8>> {
|
||||||
|
if self.status.success() {
|
||||||
|
return Ok(self.stdout);
|
||||||
|
}
|
||||||
|
anyhow::bail!(if self.stderr.is_empty() {
|
||||||
|
format!("it failed with no explanation ({})", self.status)
|
||||||
|
} else {
|
||||||
|
self.stderr
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-19
@@ -18,7 +18,7 @@
|
|||||||
//! `config.ron` on the backend, which is exactly the authority the phone
|
//! `config.ron` on the backend, which is exactly the authority the phone
|
||||||
//! is not being given.
|
//! is not being given.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::config::{DriverKind, ProviderConfig};
|
use crate::config::{DriverKind, ProviderConfig};
|
||||||
use crate::session::transport::{Launch, Transport};
|
use crate::session::transport::{Launch, Transport};
|
||||||
@@ -186,26 +186,19 @@ pub fn shorten_home(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a launch to completion and returns its stdout.
|
/// Runs a launch to completion and returns its stdout as text.
|
||||||
|
///
|
||||||
|
/// The common case of [`Transport::capture_with_input`]: nothing on stdin,
|
||||||
|
/// a failure reported as the machine's own words (ssh's "Permission
|
||||||
|
/// denied" or "Could not resolve hostname" is the useful half of why a
|
||||||
|
/// setup cannot be reached), and the output read as text because every
|
||||||
|
/// caller here is asking a question whose answer is words.
|
||||||
impl Transport {
|
impl Transport {
|
||||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||||
let child = self.spawn(launch, super::session::transport::Streams::Piped)?;
|
let captured = self
|
||||||
let output = child
|
.capture_with_input(launch, super::session::transport::Input::None)
|
||||||
.wait_with_output()
|
.await?;
|
||||||
.await
|
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
|
||||||
.context("waiting for the probe to finish")?;
|
|
||||||
if !output.status.success() {
|
|
||||||
// ssh's own failures land on stderr -- "Permission denied",
|
|
||||||
// "Could not resolve hostname" -- and are the useful half of
|
|
||||||
// why a setup cannot be reached, so they are what comes back.
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
||||||
anyhow::bail!(if stderr.is_empty() {
|
|
||||||
format!("couldn't reach it ({})", output.status)
|
|
||||||
} else {
|
|
||||||
stderr
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user