Merge branch 'main' of git.arirex.me:iris/ai-app

# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
This commit is contained in:
iris committed 2026-09-04 17:56:50 -04:00
commit 3c0214ece8
94 files changed
+8299 -9454

No files matched your search

+98 -134
View File
@@ -1,28 +1,26 @@
//! Building the command a driver actually spawns -- locally, or wrapped in
//! `ssh` when the session names a host to run on.
//!
//! The whole point of the session design is that a driver speaks JSONL over
//! a child process's stdio and doesn't care what that child is. A remote
//! session is therefore the identical command with `ssh host …` in front:
//! stdio doesn't care, so nothing downstream of here changes.
//! A driver speaks JSONL over a child process's stdio and doesn't care what
//! that child is, so a remote session is the identical command with `ssh host …`
//! in front.
//!
//! Uses the system `ssh` client rather than a Rust SSH library, so
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
//! only one place to configure connections (PLAN.md, rule 23).
//! `~/.ssh/config`, agents and jump hosts all keep working and there is only
//! one place to configure connections.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::SshConfig;
/// A port on the machine a command runs on, and the port that reaches it
/// from the backend.
/// A port on the machine a command runs on, and the port that reaches it from
/// the backend.
///
/// The second half of what a transport is (PLAN.md's SSH section): "run
/// this" plus "reach this port". Locally the two numbers are the same one
/// and nothing is forwarded; over ssh the connection carries an `-L`
/// tunnel, so a model server binds loopback on the far machine and is
/// never exposed to its network.
/// The second half of what a transport is (PLAN.md's SSH section): "run this"
/// plus "reach this port". Locally the two numbers are one and nothing is
/// forwarded; over ssh the connection carries an `-L` tunnel, so a model server
/// binds loopback on the far machine and is never exposed to its network.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Forward {
/// What the launched program should listen on, on its own machine.
@@ -32,31 +30,26 @@ pub struct Forward {
pub here: u16,
}
/// Options forced onto every connection. `BatchMode` makes a missing key
/// fail immediately with a readable message instead of hanging on a
/// password prompt that nothing can answer; the keepalives turn a silently
/// dropped link into a process exit, which the session reports as `exited`
/// rather than appearing to hang forever.
/// Options forced onto every connection. `BatchMode` makes a missing key fail
/// immediately with a readable message instead of hanging on a password prompt
/// nothing can answer; the keepalives turn a silently dropped link into a
/// process exit, which the session reports as `exited` rather than hanging.
const SSH_OPTIONS: [&str; 3] = [
"BatchMode=yes",
"ServerAliveInterval=30",
"ServerAliveCountMax=3",
];
/// Builds the child process for `program args…`, run in `cwd`, either on
/// this machine (`ssh` absent) or on the machine it describes.
/// Builds the child process for `program args…`, run in `cwd`, either on this
/// machine (`ssh` absent) or on the machine it describes.
///
/// Stdio is left alone: how the streams are connected is the caller's
/// decision and differs by more than the transport does -- a probe wants
/// pipes it will drain, a session wants files that outlive this server --
/// so `Transport::spawn` applies it rather than this.
/// Stdio is left alone: how the streams are connected is the caller's decision
/// and differs by more than the transport does -- a probe wants pipes it will
/// drain, a session wants files that outlive this server.
///
/// A plain [`std::process::Command`], which `tokio` converts from, because
/// not every caller is async: the usage fetch is blocking by nature (it
/// makes a blocking HTTP call) and reads a file from the same machine on
/// the way, and it should not have to build an ssh invocation of its own
/// to do that. One place knows what a correct invocation is; how it is run
/// is the caller's business.
/// A plain [`std::process::Command`], which `tokio` converts from, because not
/// every caller is async: the usage fetch is blocking by nature and should not
/// have to build an ssh invocation of its own.
pub fn command(
remote: Option<&SshConfig>,
program: &str,
@@ -68,14 +61,11 @@ pub fn command(
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
// Expanded here for the same reason `quote_path` expands it on
// the far side: a working directory typed as `~/repos/ai-app`
// has to mean the same thing whichever machine runs it. There
// is no shell in this branch, so nothing else would --
// `current_dir` would be handed the literal one-character
// directory `~`, and the session would fail to start with an
// error naming a path nobody typed. Only the cwd, matching
// the remote side, where arguments stay literal.
// Expanded here for the same reason `quote_path` expands it on the
// far side: a working directory typed as `~/repos/ai-app` has to
// mean the same thing whichever machine runs it. There is no shell
// in this branch, so nothing else would -- `current_dir` would be
// handed the literal one-character directory `~`.
command.current_dir(expand_home(cwd));
}
return command;
@@ -83,39 +73,28 @@ pub fn command(
let mut command = Command::new("ssh");
if let Some(forward) = forward {
// A forwarded process is not spoken to over stdio, and that
// changes how it has to be shut down. Everything else here is a
// CLI reading its stdin, so killing the ssh client closes that
// stdin and the far process ends; a `llama-server` never reads
// its own, so the same kill left it running on the far machine
// holding the model in memory -- measured 2026-09-04, an orphan
// per stopped session. A pty is what makes sshd hang the far side
// up: when the connection goes, the master closes and the session
// takes SIGHUP. `-tt` because this client has no terminal of its
// own to inherit one from.
//
// The cost is that its log arrives through a line discipline
// (CRLF, and whatever the program does when it thinks it is on a
// terminal). Nothing parses that log, so it is a fair trade for a
// process that reliably goes away.
// A forwarded process is not spoken to over stdio, and that changes how
// it is shut down. Everything else here is a CLI reading its stdin, so
// killing the ssh client ends it; a `llama-server` never reads its own,
// so the same kill left it running on the far machine with the model
// loaded -- measured 2026-09-04, an orphan per stopped session. A pty
// is what makes sshd hang the far side up. `-tt` because this client
// has no terminal to inherit one from. The cost is a log that arrives
// through a line discipline, which nothing parses.
command.arg("-tt");
// Loopback on both ends: the far side binds 127.0.0.1, so the
// port it serves is reachable only through this connection and
// never from that machine's network -- and the near end is bound
// to this host alone for the same reason.
// Loopback at both ends: the far side binds 127.0.0.1, so what it
// serves is reachable only through this connection.
command.args([
"-L",
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
]);
// Without this a forward that cannot be set up is a warning on
// stderr and a session that runs anyway, answering nothing: the
// failure would arrive as "the model never became ready", which
// is the wrong thing to go looking at.
// Without this a forward that cannot be set up is a warning on stderr
// and a session that runs anyway, answering nothing -- which would
// arrive as "the model never became ready".
command.args(["-o", "ExitOnForwardFailure=yes"]);
} else {
// -T: no pty. This carries JSONL, and a pty would rewrite it
// (echo, CRLF translation, ^C handling) into something the parser
// can't read.
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
// CRLF translation, ^C handling) into something the parser can't read.
command.arg("-T");
}
for option in SSH_OPTIONS {
@@ -129,9 +108,8 @@ pub fn command(
}
if let Some(identity) = &ssh.identity_file {
command.arg("-i").arg(identity);
// Without this, ssh may offer an agent key first and authenticate
// as somebody else entirely -- silently, and with different
// permissions than intended.
// Without this, ssh may offer an agent key first and authenticate as
// somebody else entirely -- silently, and with different permissions.
command.args(["-o", "IdentitiesOnly=yes"]);
}
command.arg(&ssh.address);
@@ -139,11 +117,10 @@ pub fn command(
command
}
/// The single argument handed to the remote login shell.
///
/// `exec` so the CLI replaces that shell: the process the connection is
/// attached to is then the CLI itself, and dropping the connection takes
/// it down rather than leaving an orphan behind a live wrapper.
/// The single argument handed to the remote login shell. `exec` so the CLI
/// replaces that shell: the process the connection is attached to is then the
/// CLI itself, and dropping the connection takes it down rather than leaving an
/// orphan behind a live wrapper.
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
let mut script = String::new();
if let Some(cwd) = cwd {
@@ -163,11 +140,9 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
/// A path with a leading `~` replaced by this machine's home directory.
///
/// The local half of the rule [`quote_path`] states for the remote one, and
/// the two are deliberately the same shape: the tilde is expanded, `~user`
/// is not (there is no portable expansion for another account's home), and
/// nothing else in the path gains a meaning. A machine with no home
/// directory at all leaves the path alone, which fails with the operating
/// system's own message rather than with a guess.
/// deliberately the same shape: the tilde is expanded, `~user` is not, and
/// nothing else in the path gains a meaning. A machine with no home directory
/// leaves the path alone, which fails with the operating system's own message.
pub(crate) fn expand_home(path: &Path) -> PathBuf {
let Some(rest) = path.to_str().and_then(|p| {
if p == "~" {
@@ -187,23 +162,19 @@ pub(crate) fn expand_home(path: &Path) -> PathBuf {
/// Quotes a path, expanding a leading `~` and nothing else.
///
/// [`quote`] is right for every other word crossing to the remote side and
/// wrong for exactly one character. `~` means "expand me", and single
/// quotes are what stop expansion -- so a working directory typed as
/// `~/repos/ai-app` arrived as the literal four-character directory `~`,
/// and the remote shell said it did not exist. Which is true, and reads
/// like the path being wrong rather than the quoting.
/// wrong for exactly one character. `~` means "expand me", and single quotes
/// are what stop expansion -- so a working directory typed as `~/repos/ai-app`
/// arrived as the literal four-character directory `~`, and the remote shell
/// said it did not exist, which reads like the path being wrong.
///
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the
/// variable is expanded, the expansion is not re-split or globbed because
/// it is double-quoted, and everything after it stays single-quoted and
/// literal. So the one character that has to mean something keeps meaning
/// it, and nothing else gains a meaning. `$HOME` is set by every shell
/// this can land in, including the fish login shell on the dev VM, which
/// is why this does not depend on the remote shell being POSIX.
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable
/// is expanded, the expansion is not re-split or globbed because it is
/// double-quoted, and everything after it stays single-quoted and literal.
/// `$HOME` is set by every shell this can land in, including the fish login
/// shell on the dev VM, so this does not depend on the remote shell being POSIX.
///
/// `~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.
/// `~user` is deliberately not handled: there is no portable expansion for it,
/// and inventing one would mean guessing another account's home directory.
pub(crate) fn quote_path(path: &str) -> String {
if path == "~" {
return "\"$HOME\"".to_string();
@@ -214,15 +185,13 @@ pub(crate) fn quote_path(path: &str) -> String {
}
}
/// Single-quotes one word for a POSIX shell.
///
/// Everything crossing to the remote side goes through here: paths, model
/// 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.
/// Single-quotes one word for a POSIX shell. Everything crossing to the remote
/// side goes through here: paths, model 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.
pub(crate) fn quote(word: &str) -> String {
// Inside single quotes every character is literal except `'` itself,
// which is closed, escaped, and reopened.
// Inside single quotes every character is literal except `'` itself, which
// is closed, escaped, and reopened.
format!("'{}'", word.replace('\'', r"'\''"))
}
@@ -243,8 +212,8 @@ mod tests {
}
/// A host with nothing configured but a name to dial, so `~/.ssh/config`
/// decides everything else -- the case that proves this adds no flags of
/// its own when it was not told to.
/// decides everything else -- the case that proves this adds no flags of its
/// own when it was not told to.
fn bare_host() -> SshConfig {
SshConfig {
address: "vm".to_string(),
@@ -311,13 +280,13 @@ mod tests {
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
}
/// The second half of a transport: the connection that runs the
/// command also carries the port that reaches it.
/// The second half of a transport: the connection that runs the command also
/// carries the port that reaches it.
///
/// Both ends are pinned to loopback, which is the property that keeps
/// a model server off the far machine's network -- asserted here
/// rather than trusted, because dropping the addresses is a one-word
/// edit that still works on a machine nobody else can reach.
/// Both ends are pinned to loopback, which is what keeps a model server off
/// the far machine's network -- asserted rather than trusted, because
/// dropping the addresses is a one-word edit that still works on a machine
/// nobody else can reach.
#[test]
fn a_forwarded_port_rides_the_same_connection_as_the_command() {
let ssh = bare_host();
@@ -337,9 +306,8 @@ mod tests {
.expect("a forward");
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
// The half that is easy to lose: without a pty the far process
// outlives the connection, because nothing closes a stdin it
// never reads.
// The half that is easy to lose: without a pty the far process outlives
// the connection, because nothing closes a stdin it never reads.
assert!(rendered.contains(&"-tt".to_string()));
assert!(!rendered.contains(&"-T".to_string()));
// Options come before the host, or ssh reads them as part of the
@@ -350,8 +318,8 @@ mod tests {
"exec 'llama-server' '--port' '24242'"
);
// Nothing forwarded is nothing added: every other session is one
// of these, and an -L on it would bind a port for no reason.
// Nothing forwarded is nothing added: every other session is one of
// these, and an -L on it would bind a port for no reason.
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
assert!(!plain.contains(&"-L".to_string()));
// And a session that *is* spoken to over stdio keeps its raw pipe.
@@ -359,19 +327,17 @@ mod tests {
assert!(!plain.contains(&"-tt".to_string()));
}
/// The one character quoting must not swallow.
///
/// A working directory typed as `~/repos/ai-app` was arriving as the
/// literal directory `~`, and the remote shell reported it missing --
/// which reads as the path being wrong rather than the quoting being
/// wrong, and cost an evening on exactly that misreading.
/// The one character quoting must not swallow. A working directory typed as
/// `~/repos/ai-app` was arriving as the literal directory `~`, and the
/// remote shell reported it missing -- which reads as the path being wrong
/// rather than the quoting being wrong, and cost an evening.
#[test]
fn a_leading_tilde_expands_and_nothing_else_does() {
assert_eq!(quote_path("~"), "\"$HOME\"");
assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'");
// Only leading, and only its own segment: a tilde anywhere else is
// an ordinary character in a filename, and `~user` has no portable
// expansion so it stays literal and fails with the shell's message.
// Only leading, and only its own segment: a tilde anywhere else is an
// ordinary character in a filename, and `~user` has no portable
// expansion so it stays literal.
assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'");
assert_eq!(quote_path("~user/x"), "'~user/x'");
@@ -382,13 +348,11 @@ mod tests {
);
}
/// The same character, on the transport with no shell to expand it.
///
/// The local branch runs the program directly, so a working directory
/// of `~/repos/ai-app` would reach `current_dir` as the literal
/// one-character directory `~` -- a session that fails to start,
/// naming a path nobody typed. The two transports have to agree about
/// what a tilde means or a path is only portable by accident.
/// The same character, on the transport with no shell to expand it. The
/// local branch runs the program directly, so a working directory of
/// `~/repos/ai-app` would reach `current_dir` as the literal one-character
/// directory `~`. The two transports have to agree about what a tilde means
/// or a path is only portable by accident.
#[test]
fn a_local_cwd_expands_its_tilde_the_same_way() {
let Some(home) = std::env::home_dir() else {
@@ -415,9 +379,9 @@ mod tests {
#[test]
fn shell_metacharacters_cross_as_data_not_syntax() {
// Expanding $HOME must not open a door for anything else: the rest
// stays single-quoted, so this remains one absurd path rather than
// three commands.
// Expanding $HOME must not open a door for anything else: the rest stays
// single-quoted, so this remains one absurd path rather than three
// commands.
assert_eq!(
quote_path("~/'; touch /tmp/pwned; '"),
r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#,
@@ -429,8 +393,8 @@ mod tests {
assert_eq!(quote("$(whoami)"), "'$(whoami)'");
assert_eq!(quote("it's"), r"'it'\''s'");
// The end-to-end version of the same worry: a working directory
// that tries to close the quote and start a new command.
// The end-to-end version of the same worry: a working directory that
// tries to close the quote and start a new command.
let ssh = bare_host();
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None));