| //! A web terminal on the launcher. | |
| //! | |
| //! Each session is a pseudo-terminal running `amem launch` or `amem work`, kept alive by | |
| //! this server whether or not a browser is attached. Its output goes into a ring buffer and | |
| //! to every attached websocket; a browser that connects, or reconnects, gets the ring | |
| //! first, so a refresh or a dropped connection lands in the same place. Beside the terminal | |
| //! a reading pane renders entries from the record as they land. Loopback only, and behind a | |
| //! token: see [`access`]. | |
| use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; | |
| use axum::extract::{Path, State}; | |
| use axum::http::StatusCode; | |
| use axum::response::{Html, IntoResponse, Json, Response}; | |
| use axum::routing::{get, post}; | |
| use axum::Router; | |
| use bytes::Bytes; | |
| use dm_server::Memory; | |
| use futures_util::{SinkExt, StreamExt}; | |
| use portable_pty::{native_pty_system, CommandBuilder, PtySize}; | |
| use serde::{Deserialize, Serialize}; | |
| use serde_json::{json, Value}; | |
| use std::collections::{BTreeMap, VecDeque}; | |
| use std::io::{Read, Write}; | |
| use std::path::PathBuf; | |
| use std::sync::atomic::{AtomicBool, Ordering}; | |
| use std::sync::{Arc, Mutex}; | |
| use tokio::sync::broadcast; | |
| pub mod access; | |
| pub mod companion; | |
| mod pages; | |
| // what build.rs runs, run again by a test of pages.rs against what was embedded | |
| #[cfg(test)] | |
| #[path = "../reader_build.rs"] | |
| mod reader_build; | |
| pub mod state; | |
| pub use access::Access; | |
| /// What can go wrong. | |
| #[derive(Debug, thiserror::Error)] | |
| pub enum Error { | |
| /// The pseudo-terminal could not be opened or the launcher could not be started. | |
| #[error("session: {0}")] | |
| Session(String), | |
| /// A request the server cannot act on as given: no harness, say. | |
| #[error("{0}")] | |
| Request(String), | |
| /// The listener. | |
| #[error("listen: {0}")] | |
| Io(#[from] std::io::Error), | |
| /// The record. | |
| #[error("record: {0}")] | |
| Memory(#[from] dm_server::Error), | |
| } | |
| /// How much output a session keeps for late and returning browsers. | |
| const RING_BYTES: usize = 2 * 1024 * 1024; | |
| /// How to run the launcher. | |
| #[derive(Clone, Debug)] | |
| pub struct Config { | |
| /// The record. | |
| pub root: PathBuf, | |
| /// The `amem` binary; sessions run `<binary> --root <root> launch ...` or `work ...`. | |
| pub memory_binary: PathBuf, | |
| /// The working tree sessions start in. | |
| pub workdir: PathBuf, | |
| /// The harness the form preselects, if any; a request names its own. | |
| pub harness: Option<String>, | |
| /// Default auth mode. | |
| pub auth: String, | |
| /// Default backend. | |
| pub backend: String, | |
| /// For tests: run this command verbatim instead of the launcher. | |
| pub command_override: Option<Vec<String>>, | |
| /// Who may call: the token, and origins besides the server's own. | |
| pub access: Access, | |
| } | |
| /// A request to start a session. | |
| #[derive(Clone, Debug, Default, Deserialize)] | |
| pub struct NewSession { | |
| /// claude, codex, gemini, copilot, opencode. | |
| #[serde(default)] | |
| pub harness: Option<String>, | |
| /// subscription, key, gateway. | |
| #[serde(default)] | |
| pub auth: Option<String>, | |
| /// Another destination, where the harness allows it. | |
| #[serde(default)] | |
| pub provider: Option<String>, | |
| /// The model, in the harness's own flag. | |
| #[serde(default)] | |
| pub model: Option<String>, | |
| /// Where a subagent the harness would spawn runs: harness or board. | |
| #[serde(default)] | |
| pub subagents: Option<String>, | |
| /// Non-interactive: a prompt. | |
| #[serde(default)] | |
| pub prompt: Option<String>, | |
| /// Task mode. | |
| #[serde(default)] | |
| pub work: bool, | |
| /// Arguments for the harness. | |
| #[serde(default)] | |
| pub args: Vec<String>, | |
| /// A profile from `profiles.toml`, or a harness's own name; what the request sets | |
| /// itself wins over it. | |
| #[serde(default)] | |
| pub profile: Option<String>, | |
| /// Where the harness runs: auto, namespaces, image, wslc or none. | |
| #[serde(default)] | |
| pub backend: Option<String>, | |
| } | |
| /// The backends a request may name. | |
| pub const BACKENDS: &[&str] = &["auto", "namespaces", "image", "wslc", "none"]; | |
| /// Whether `name` may name a profile: lowercase letters, digits, `-` and `_`, starting | |
| /// with a letter, as the launcher's own check has it. | |
| fn profile_name(name: &str) -> bool { | |
| (1..=32).contains(&name.len()) | |
| && name.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) | |
| && name | |
| .bytes() | |
| .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') | |
| } | |
| /// One session as the API shows it. | |
| #[derive(Clone, Debug, Serialize, Deserialize)] | |
| pub struct SessionInfo { | |
| /// The id in the URL. | |
| pub id: String, | |
| /// One line. | |
| pub title: String, | |
| /// Still running. | |
| pub alive: bool, | |
| /// The exit code, once it has one. | |
| pub exit: Option<i32>, | |
| /// When it started, RFC 3339. | |
| pub started: String, | |
| /// What it was started with, for the session's bar: the harness, or the profile | |
| /// that names one, and whatever the request set itself. | |
| #[serde(default)] | |
| pub launch: Launched, | |
| } | |
| /// What a session was started with, as the request said it. | |
| #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| pub struct Launched { | |
| /// The profile, when one was named. | |
| pub profile: Option<String>, | |
| /// The harness, when the request named one. | |
| pub harness: Option<String>, | |
| /// The auth mode, when the request named one. | |
| pub auth: Option<String>, | |
| /// The destination, when the request named one. | |
| pub provider: Option<String>, | |
| /// The model, when the request named one. | |
| pub model: Option<String>, | |
| /// The backend, when the request named one. | |
| pub backend: Option<String>, | |
| /// interactive, prompt or work. | |
| pub mode: String, | |
| } | |
| impl Launched { | |
| fn of(req: &NewSession) -> Launched { | |
| let some = |v: &Option<String>| v.clone().filter(|v| !v.trim().is_empty()); | |
| Launched { | |
| profile: some(&req.profile), | |
| harness: some(&req.harness), | |
| auth: some(&req.auth), | |
| provider: some(&req.provider), | |
| model: some(&req.model), | |
| backend: some(&req.backend), | |
| mode: if req.work { | |
| "work" | |
| } else if req.prompt.as_deref().is_some_and(|p| !p.trim().is_empty()) { | |
| "prompt" | |
| } else { | |
| "interactive" | |
| } | |
| .into(), | |
| } | |
| } | |
| } | |
| struct Session { | |
| info: Mutex<SessionInfo>, | |
| writer: Mutex<Box<dyn Write + Send>>, | |
| master: Mutex<Box<dyn portable_pty::MasterPty + Send>>, | |
| child: Mutex<Box<dyn portable_pty::Child + Send + Sync>>, | |
| ring: Mutex<VecDeque<u8>>, | |
| live: broadcast::Sender<Bytes>, | |
| alive: AtomicBool, | |
| } | |
| impl Session { | |
| fn snapshot(&self) -> SessionInfo { | |
| let mut i = self | |
| .info | |
| .lock() | |
| .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| .clone(); | |
| i.alive = self.alive.load(Ordering::SeqCst); | |
| i | |
| } | |
| } | |
| /// A request's own session ([`App::session`]), or the response that says why there is | |
| /// none, returned from the handler. | |
| macro_rules! session { | |
| ($app:expr) => { | |
| match $app.session() { | |
| Ok(m) => m, | |
| Err(e) => { | |
| return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); | |
| } | |
| } | |
| }; | |
| } | |
| #[derive(Clone)] | |
| struct App { | |
| config: Config, | |
| /// The board's session for its life: the handles, the session id for the read and | |
| /// write logs, and the read scope. Each request gets a view of it with a label of | |
| /// its own ([`App::session`]). | |
| memory: Arc<Memory>, | |
| sessions: Arc<Mutex<BTreeMap<String, Arc<Session>>>>, | |
| /// Bundle exports run one at a time: each replaces a directory by renaming. | |
| exports: Arc<tokio::sync::Mutex<()>>, | |
| /// The browser extension beside the page, when one is connected. | |
| companion: Arc<companion::Companion>, | |
| } | |
| fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { | |
| m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) | |
| } | |
| fn now() -> String { | |
| time::OffsetDateTime::now_utc() | |
| .format(&time::format_description::well_known::Rfc3339) | |
| .unwrap_or_default() | |
| } | |
| /// A session id: unguessable, since a websocket to it is a keyboard on the harness. | |
| fn new_id() -> Result<String, Error> { | |
| Ok(access::random_hex::<16>()?) | |
| } | |
| /// The harness a request names, or the one the server preselects; a request that | |
| /// names none where the server has none is refused, since there is no default harness. | |
| fn harness_of(config: &Config, req: &NewSession) -> Result<String, String> { | |
| req.harness | |
| .clone() | |
| .filter(|h| !h.trim().is_empty()) | |
| .or_else(|| config.harness.clone()) | |
| .ok_or_else(|| "say which harness: there is no default".to_string()) | |
| } | |
| /// The launcher's command line for a request. With a profile, the request's own fields | |
| /// are passed only where it sets them, so the profile's stand where it does not. | |
| fn launch_argv(config: &Config, req: &NewSession) -> Result<Vec<String>, String> { | |
| if let Some(c) = &config.command_override { | |
| return Ok(c.clone()); | |
| } | |
| let set = |v: &Option<String>| { | |
| v.as_deref() | |
| .map(str::trim) | |
| .filter(|v| !v.is_empty()) | |
| .map(str::to_string) | |
| }; | |
| let profile = set(&req.profile); | |
| if let Some(p) = &profile { | |
| if !profile_name(p) { | |
| return Err(format!("`{p}` is not a profile name")); | |
| } | |
| } | |
| let backend = set(&req.backend); | |
| if let Some(b) = &backend { | |
| if !BACKENDS.contains(&b.as_str()) { | |
| return Err(format!("no backend named {b}: {}", BACKENDS.join(", "))); | |
| } | |
| // a browser may pick among confinements, not leave confinement off: that is the | |
| // operator's to allow, by starting the server with --backend none | |
| if b == "none" && config.backend != "none" { | |
| return Err( | |
| "a session from the browser runs confined; `amem web --backend none` \ | |
| is how the operator allows otherwise" | |
| .into(), | |
| ); | |
| } | |
| } | |
| let mut a = vec![ | |
| config.memory_binary.display().to_string(), | |
| "--root".into(), | |
| config.root.display().to_string(), | |
| if req.work { "work" } else { "launch" }.into(), | |
| ]; | |
| let mut flag = |name: &str, value: Option<String>| { | |
| if let Some(v) = value { | |
| a.extend([name.to_string(), v]); | |
| } | |
| }; | |
| match profile { | |
| Some(p) => { | |
| flag("--profile", Some(p)); | |
| flag("--harness", set(&req.harness)); | |
| flag("--auth", set(&req.auth)); | |
| // the operator's own backend stands over the profile's; auto leaves it be | |
| let operator = (config.backend != "auto").then(|| config.backend.clone()); | |
| flag("--backend", backend.or(operator)); | |
| } | |
| None => { | |
| flag("--harness", Some(harness_of(config, req)?)); | |
| flag( | |
| "--auth", | |
| Some(set(&req.auth).unwrap_or_else(|| config.auth.clone())), | |
| ); | |
| flag( | |
| "--backend", | |
| Some(backend.unwrap_or_else(|| config.backend.clone())), | |
| ); | |
| } | |
| } | |
| flag("--provider", set(&req.provider)); | |
| flag("--model", set(&req.model)); | |
| flag("--subagents", set(&req.subagents)); | |
| if !req.work { | |
| if let Some(p) = &req.prompt { | |
| a.extend(["--prompt".to_string(), p.clone()]); | |
| } | |
| if !req.args.is_empty() { | |
| a.push("--".into()); | |
| a.extend(req.args.iter().cloned()); | |
| } | |
| } | |
| Ok(a) | |
| } | |
| fn title_for(req: &NewSession, config: &Config) -> String { | |
| let named = req.profile.clone().filter(|p| !p.trim().is_empty()); | |
| let mut h = harness_of(config, req) | |
| .ok() | |
| .filter(|_| req.harness.is_some() || named.is_none()) | |
| .or(named) | |
| .unwrap_or_else(|| "?".into()); | |
| if let Some(m) = req.model.as_deref().filter(|m| !m.trim().is_empty()) { | |
| h = format!("{h} on {}", m.trim()); | |
| } | |
| match (&req.prompt, req.work) { | |
| (_, true) => format!("{h}: next task"), | |
| (Some(p), _) => format!("{h}: {}", p.chars().take(60).collect::<String>()), | |
| (None, _) => h, | |
| } | |
| } | |
| /// What the form offers: the harnesses, the auth modes, the destinations each auth mode | |
| /// may name, where subagents may go, and the server's defaults. One list, in Rust, so | |
| /// the page and the launcher agree. | |
| fn options(config: &Config) -> Value { | |
| json!({ | |
| "harnesses": ["claude", "codex", "gemini", "antigravity", "qwen", "grok", "muse", "pi", "copilot", "opencode"], | |
| "auth": ["subscription", "key", "gateway"], | |
| "destinations": { | |
| "subscription": [], | |
| "key": ["anthropic", "openai", "xai", "gemini", "dashscope", "meta"], | |
| "gateway": ["llmao"] | |
| }, | |
| "subagents": ["harness", "board"], | |
| "backends": BACKENDS | |
| .iter() | |
| .filter(|b| **b != "none" || config.backend == "none") | |
| .collect::<Vec<_>>(), | |
| // what each choice means, for the form's hints: one sentence each, in the words | |
| // the launcher's own help uses | |
| "descriptions": { | |
| "backends": { | |
| "auto": "Picks for each harness: the unpacked image when it carries the harness, this host's namespaces otherwise; WSL containers on Windows.", | |
| "namespaces": "Linux namespaces over this host's own binaries: the harness you installed, confined, with only the working tree, a private home and the egress reachable.", | |
| "image": "The harness image, unpacked by `make unpack`, pivoted into: the same pinned harnesses and Node on every machine, confined the same way.", | |
| "wslc": "WSL containers on Windows: an egress container and a harness container with no network, from the same image.", | |
| "none": "No confinement at all. The egress still proxies and meters, but nothing is sandboxed; for platforms without a backend, and for debugging." | |
| }, | |
| "subagents": { | |
| "harness": "A subagent the harness would spawn runs inside the harness, as it would without the launcher.", | |
| "board": "A subagent the harness would spawn becomes a task on the board instead, under the task this run holds, and the run resumes as a continuation once it is done; the call is refused with the task ids." | |
| } | |
| }, | |
| "defaults": { "harness": config.harness, "auth": config.auth, "backend": config.backend } | |
| }) | |
| } | |
| /// The models a destination lists, asked of the launcher itself: `amem models` opens | |
| /// the record, routes as a launch would, and prints what the destination's own model | |
| /// list says. A login mode has no list, since the login is the harness's. | |
| async fn models_via_launcher( | |
| config: &Config, | |
| auth: &str, | |
| provider: Option<&str>, | |
| ) -> Result<Value, String> { | |
| if auth == "subscription" { | |
| return Ok( | |
| json!({ "models": [], "note": "a login is the harness's own; name the model, or leave it to the harness" }), | |
| ); | |
| } | |
| let mut cmd = tokio::process::Command::new(&config.memory_binary); | |
| cmd.arg("--root") | |
| .arg(&config.root) | |
| .arg("models") | |
| .arg("--auth") | |
| .arg(auth); | |
| if let Some(p) = provider.filter(|p| !p.is_empty()) { | |
| cmd.arg("--provider").arg(p); | |
| } | |
| let out = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output()) | |
| .await | |
| .map_err(|_| "the model list did not come back in time".to_string())? | |
| .map_err(|e| format!("could not run the launcher: {e}"))?; | |
| if !out.status.success() { | |
| return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); | |
| } | |
| serde_json::from_slice(&out.stdout).map_err(|e| format!("not a model list: {e}")) | |
| } | |
| /// What the launcher prints as JSON for `args`, asked of it as `models` is: the same | |
| /// binary over the same root, within `secs`. | |
| async fn launcher_json(config: &Config, args: &[&str], secs: u64) -> Result<Value, String> { | |
| let mut cmd = tokio::process::Command::new(&config.memory_binary); | |
| cmd.arg("--root") | |
| .arg(&config.root) | |
| .args(args) | |
| .kill_on_drop(true); | |
| let out = tokio::time::timeout(std::time::Duration::from_secs(secs), cmd.output()) | |
| .await | |
| .map_err(|_| format!("`{}` did not answer in time", args.join(" ")))? | |
| .map_err(|e| format!("could not run the launcher: {e}"))?; | |
| if !out.status.success() { | |
| return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); | |
| } | |
| serde_json::from_slice(&out.stdout).map_err(|e| format!("not JSON: {e}")) | |
| } | |
| /// `?online=1` asks the gateway too. | |
| #[derive(Debug, Default, Deserialize)] | |
| struct DoctorQuery { | |
| #[serde(default)] | |
| online: Option<String>, | |
| } | |
| /// What `amem doctor` says: per harness and auth mode, whether a launch has what it | |
| /// needs. Offline unless asked, since asking the gateway can take seconds. | |
| async fn doctor_api( | |
| State(app): State<App>, | |
| axum::extract::Query(q): axum::extract::Query<DoctorQuery>, | |
| ) -> Response { | |
| let online = q.online.as_deref().is_some_and(|v| v == "1" || v == "true"); | |
| let args: &[&str] = if online { | |
| &["doctor", "--json"] | |
| } else { | |
| &["doctor", "--json", "--offline"] | |
| }; | |
| match launcher_json(&app.config, args, 30).await { | |
| Ok(v) => Json(v).into_response(), | |
| Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(), | |
| } | |
| } | |
| /// The profiles, from `profiles.toml` and the harnesses' own names. | |
| async fn profiles_api(State(app): State<App>) -> Response { | |
| match launcher_json(&app.config, &["profiles", "--json"], 10).await { | |
| Ok(v) => Json(v).into_response(), | |
| Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(), | |
| } | |
| } | |
| /// What the session's launcher says about itself, for its strip: an empty object until | |
| /// it has said anything, which a session started outside the launcher never does. | |
| async fn session_status(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| if app.get(&id).is_none() { | |
| return (StatusCode::NOT_FOUND, "no such session").into_response(); | |
| } | |
| let Some(path) = state::status_file(&app.config.root, &id) else { | |
| return Json(json!({})).into_response(); | |
| }; | |
| match tokio::fs::read_to_string(&path).await { | |
| Ok(text) => match serde_json::from_str::<Value>(&text) { | |
| Ok(v) => Json(v).into_response(), | |
| Err(_) => Json(json!({})).into_response(), | |
| }, | |
| Err(_) => Json(json!({})).into_response(), | |
| } | |
| } | |
| impl App { | |
| /// The board's session for one request: the same handles and session id as every | |
| /// other request, so what the board reads and then writes is joined in the logs, | |
| /// with a label of its own. What one request reads raises the label of that request | |
| /// only: a listing does not classify what a later request files or writes, as one | |
| /// long-lived label would. | |
| fn session(&self) -> Result<Memory, dm_server::Error> { | |
| self.memory.for_request() | |
| } | |
| fn start(&self, req: &NewSession) -> Result<Arc<Session>, Error> { | |
| let argv = launch_argv(&self.config, req).map_err(Error::Request)?; | |
| let id = new_id()?; | |
| let pty = native_pty_system(); | |
| let pair = pty | |
| .openpty(PtySize { | |
| rows: 40, | |
| cols: 120, | |
| pixel_width: 0, | |
| pixel_height: 0, | |
| }) | |
| .map_err(|e| Error::Session(e.to_string()))?; | |
| let mut cmd = CommandBuilder::new(&argv[0]); | |
| cmd.args(&argv[1..]); | |
| cmd.cwd(&self.config.workdir); | |
| cmd.env("TERM", "xterm-256color"); | |
| cmd.env("COLORTERM", "truecolor"); | |
| // the launcher writes its status under this id, which the strip reads | |
| cmd.env("AMEM_WEB_SESSION", &id); | |
| // and refuses to run unconfined unless the operator allowed it here; set either | |
| // way, so nothing in the server's own environment speaks for the operator | |
| let allowed = if self.config.backend == "none" { | |
| "1" | |
| } else { | |
| "0" | |
| }; | |
| cmd.env("AMEM_WEB_ALLOW_UNCONFINED", allowed); | |
| let child = pair | |
| .slave | |
| .spawn_command(cmd) | |
| .map_err(|e| Error::Session(e.to_string()))?; | |
| drop(pair.slave); | |
| let reader = pair | |
| .master | |
| .try_clone_reader() | |
| .map_err(|e| Error::Session(e.to_string()))?; | |
| let writer = pair | |
| .master | |
| .take_writer() | |
| .map_err(|e| Error::Session(e.to_string()))?; | |
| let (live, _) = broadcast::channel(1024); | |
| let session = Arc::new(Session { | |
| info: Mutex::new(SessionInfo { | |
| id: id.clone(), | |
| title: title_for(req, &self.config), | |
| alive: true, | |
| exit: None, | |
| started: now(), | |
| launch: Launched::of(req), | |
| }), | |
| writer: Mutex::new(writer), | |
| master: Mutex::new(pair.master), | |
| child: Mutex::new(child), | |
| ring: Mutex::new(VecDeque::with_capacity(64 * 1024)), | |
| live, | |
| alive: AtomicBool::new(true), | |
| }); | |
| lock(&self.sessions).insert(id, session.clone()); | |
| spawn_reader(session.clone(), reader); | |
| Ok(session) | |
| } | |
| fn get(&self, id: &str) -> Option<Arc<Session>> { | |
| lock(&self.sessions).get(id).cloned() | |
| } | |
| } | |
| /// Pump the pseudo-terminal into the ring and to the attached browsers, then record the | |
| /// exit when the launcher is done. | |
| fn spawn_reader(session: Arc<Session>, mut reader: Box<dyn Read + Send>) { | |
| std::thread::spawn(move || { | |
| let mut buf = [0u8; 16 * 1024]; | |
| loop { | |
| match reader.read(&mut buf) { | |
| Ok(0) | Err(_) => break, | |
| Ok(n) => { | |
| let chunk = Bytes::copy_from_slice(&buf[..n]); | |
| { | |
| let mut ring = lock(&session.ring); | |
| ring.extend(chunk.iter()); | |
| while ring.len() > RING_BYTES { | |
| ring.pop_front(); | |
| } | |
| } | |
| let _ = session.live.send(chunk); | |
| } | |
| } | |
| } | |
| let code = lock(&session.child) | |
| .wait() | |
| .ok() | |
| .map(|s| i32::try_from(s.exit_code()).unwrap_or(1)); | |
| lock(&session.info).exit = code; | |
| session.alive.store(false, Ordering::SeqCst); | |
| let _ = session.live.send(Bytes::new()); | |
| }); | |
| } | |
| /// The incoming side of the websocket: keystrokes and resizes. | |
| #[derive(Deserialize)] | |
| #[serde(tag = "type", rename_all = "lowercase")] | |
| enum Incoming { | |
| Input { data: String }, | |
| Resize { cols: u16, rows: u16 }, | |
| } | |
| async fn ws_session(session: Arc<Session>, socket: WebSocket) { | |
| let (mut tx, mut rx) = socket.split(); | |
| let mut live = session.live.subscribe(); | |
| let replay: Vec<u8> = lock(&session.ring).iter().copied().collect(); | |
| if tx.send(Message::Binary(Bytes::from(replay))).await.is_err() { | |
| return; | |
| } | |
| if !session.alive.load(Ordering::SeqCst) { | |
| let info = session.snapshot(); | |
| let _ = tx | |
| .send(Message::Text( | |
| json!({ "type": "exit", "code": info.exit }) | |
| .to_string() | |
| .into(), | |
| )) | |
| .await; | |
| } | |
| let s2 = session.clone(); | |
| let incoming = tokio::spawn(async move { | |
| while let Some(Ok(msg)) = rx.next().await { | |
| match msg { | |
| Message::Text(t) => match serde_json::from_str::<Incoming>(&t) { | |
| Ok(Incoming::Input { data }) => { | |
| let _ = lock(&s2.writer).write_all(data.as_bytes()); | |
| let _ = lock(&s2.writer).flush(); | |
| } | |
| Ok(Incoming::Resize { cols, rows }) => { | |
| let _ = lock(&s2.master).resize(PtySize { | |
| rows, | |
| cols, | |
| pixel_width: 0, | |
| pixel_height: 0, | |
| }); | |
| } | |
| Err(_) => {} | |
| }, | |
| Message::Binary(b) => { | |
| let _ = lock(&s2.writer).write_all(&b); | |
| let _ = lock(&s2.writer).flush(); | |
| } | |
| Message::Close(_) => break, | |
| _ => {} | |
| } | |
| } | |
| }); | |
| loop { | |
| match live.recv().await { | |
| Ok(chunk) if chunk.is_empty() => { | |
| let info = session.snapshot(); | |
| let _ = tx | |
| .send(Message::Text( | |
| json!({ "type": "exit", "code": info.exit }) | |
| .to_string() | |
| .into(), | |
| )) | |
| .await; | |
| break; | |
| } | |
| Ok(chunk) => { | |
| if tx.send(Message::Binary(chunk)).await.is_err() { | |
| break; | |
| } | |
| } | |
| Err(broadcast::error::RecvError::Lagged(_)) => { | |
| // a slow browser: resend the ring rather than lose bytes | |
| let replay: Vec<u8> = lock(&session.ring).iter().copied().collect(); | |
| if tx.send(Message::Binary(Bytes::from(replay))).await.is_err() { | |
| break; | |
| } | |
| } | |
| Err(broadcast::error::RecvError::Closed) => break, | |
| } | |
| } | |
| incoming.abort(); | |
| } | |
| /// `?nonce=`: that this is a server over a record, and proof that it holds the token, | |
| /// for a launcher deciding whether to start one. The token is never sent to ask. | |
| #[derive(Deserialize)] | |
| struct Hello { | |
| nonce: String, | |
| } | |
| async fn hello( | |
| State(app): State<App>, | |
| axum::extract::Query(q): axum::extract::Query<Hello>, | |
| ) -> Response { | |
| if q.nonce.len() < 16 || q.nonce.len() > 128 { | |
| return (StatusCode::BAD_REQUEST, "a nonce of 16 to 128 characters").into_response(); | |
| } | |
| match app.config.access.proof(&q.nonce) { | |
| Some(proof) => Json(json!({ | |
| "server": "amem", | |
| "version": env!("CARGO_PKG_VERSION"), | |
| "proof": proof, | |
| })) | |
| .into_response(), | |
| None => (StatusCode::SERVICE_UNAVAILABLE, "not bound yet").into_response(), | |
| } | |
| } | |
| /// A pairing code for a browser, for the token's holder, with where sessions start. | |
| async fn pair(State(app): State<App>) -> Response { | |
| match app.config.access.pairing_code() { | |
| Ok(code) => Json(json!({ | |
| "code": code, | |
| "workdir": app.config.workdir.display().to_string(), | |
| "origins": app.config.access.origins(), | |
| })) | |
| .into_response(), | |
| Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), | |
| } | |
| } | |
| #[derive(Deserialize)] | |
| struct Redeem { | |
| code: String, | |
| } | |
| /// A browser's key for a pairing code. | |
| async fn redeem(State(app): State<App>, Json(r): Json<Redeem>) -> Response { | |
| access::redeem_response(&app.config.access, &r.code) | |
| } | |
| async fn amem_js() -> Response { | |
| ( | |
| [ | |
| (axum::http::header::CONTENT_TYPE, "text/javascript"), | |
| (axum::http::header::CACHE_CONTROL, "no-cache"), | |
| ], | |
| pages::AMEM_JS, | |
| ) | |
| .into_response() | |
| } | |
| async fn index() -> Html<&'static str> { | |
| Html(pages::INDEX) | |
| } | |
| async fn vendor(Path(name): Path<String>) -> Response { | |
| match pages::vendor(&name) { | |
| Some((content_type, bytes)) => ( | |
| [ | |
| (axum::http::header::CONTENT_TYPE, content_type), | |
| ( | |
| axum::http::header::CACHE_CONTROL, | |
| "public, max-age=31536000, immutable", | |
| ), | |
| ], | |
| bytes, | |
| ) | |
| .into_response(), | |
| None => (StatusCode::NOT_FOUND, "no such file").into_response(), | |
| } | |
| } | |
| /// The session page by file name; the session is `?id=` in its address. | |
| async fn session_file() -> Html<&'static str> { | |
| Html(pages::SESSION) | |
| } | |
| async fn tokens_css() -> Response { | |
| ( | |
| [ | |
| (axum::http::header::CONTENT_TYPE, "text/css"), | |
| (axum::http::header::CACHE_CONTROL, "no-cache"), | |
| ], | |
| pages::TOKENS, | |
| ) | |
| .into_response() | |
| } | |
| async fn session_page(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| match app.get(&id) { | |
| Some(_) => Html(pages::SESSION).into_response(), | |
| None => (StatusCode::NOT_FOUND, "no such session").into_response(), | |
| } | |
| } | |
| async fn ws(State(app): State<App>, Path(id): Path<String>, up: WebSocketUpgrade) -> Response { | |
| match app.get(&id) { | |
| // a page offers its key as a second subprotocol; the answer names the first | |
| Some(s) => up | |
| .protocols([access::PROTOCOL]) | |
| .on_upgrade(move |socket| ws_session(s, socket)), | |
| None => (StatusCode::NOT_FOUND, "no such session").into_response(), | |
| } | |
| } | |
| async fn options_api(State(app): State<App>) -> Json<Value> { | |
| Json(options(&app.config)) | |
| } | |
| /// `?auth=<mode>&provider=<destination>`. | |
| #[derive(Debug, Default, Deserialize)] | |
| struct ModelsQuery { | |
| #[serde(default)] | |
| auth: Option<String>, | |
| #[serde(default)] | |
| provider: Option<String>, | |
| } | |
| async fn models_api( | |
| State(app): State<App>, | |
| axum::extract::Query(q): axum::extract::Query<ModelsQuery>, | |
| ) -> Response { | |
| let auth = q.auth.clone().unwrap_or_else(|| app.config.auth.clone()); | |
| match models_via_launcher(&app.config, &auth, q.provider.as_deref()).await { | |
| Ok(v) => Json(v).into_response(), | |
| Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(), | |
| } | |
| } | |
| async fn list_sessions(State(app): State<App>) -> Json<Vec<SessionInfo>> { | |
| let v: Vec<SessionInfo> = lock(&app.sessions).values().map(|s| s.snapshot()).collect(); | |
| Json(v) | |
| } | |
| async fn create_session(State(app): State<App>, Json(req): Json<NewSession>) -> Response { | |
| match app.start(&req) { | |
| Ok(s) => Json(s.snapshot()).into_response(), | |
| Err(Error::Request(why)) => (StatusCode::BAD_REQUEST, why).into_response(), | |
| Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), | |
| } | |
| } | |
| /// What the session has printed so far, the ring as text: what a browser replays, for | |
| /// a script or a test that has no websocket. | |
| async fn scrollback(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| match app.get(&id) { | |
| Some(s) => { | |
| let bytes: Vec<u8> = lock(&s.ring).iter().copied().collect(); | |
| ( | |
| [( | |
| axum::http::header::CONTENT_TYPE, | |
| "text/plain; charset=utf-8", | |
| )], | |
| String::from_utf8_lossy(&bytes).into_owned(), | |
| ) | |
| .into_response() | |
| } | |
| None => (StatusCode::NOT_FOUND, "no such session").into_response(), | |
| } | |
| } | |
| async fn session_info(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| match app.get(&id) { | |
| Some(s) => Json(s.snapshot()).into_response(), | |
| None => (StatusCode::NOT_FOUND, "no such session").into_response(), | |
| } | |
| } | |
| async fn kill_session(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| match app.get(&id) { | |
| Some(s) => { | |
| let _ = lock(&s.child).kill(); | |
| StatusCode::NO_CONTENT.into_response() | |
| } | |
| None => (StatusCode::NOT_FOUND, "no such session").into_response(), | |
| } | |
| } | |
| /// Recent entries, newest first: what the pane watches. | |
| async fn entries(State(app): State<App>) -> Response { | |
| let m = session!(app); | |
| match m.list(None, None, 30).await { | |
| Ok(hits) => Json( | |
| hits.iter() | |
| .map(|h| { | |
| json!({ | |
| "id": h.id, "title": h.title, "type": h.entry_type.to_string(), | |
| "summary": h.summary, "classification": h.classification.to_string(), | |
| }) | |
| }) | |
| .collect::<Vec<Value>>(), | |
| ) | |
| .into_response(), | |
| Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), | |
| } | |
| } | |
| /// One entry in full, for the pane. | |
| async fn entry(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| let m = session!(app); | |
| match m.get(&id).await { | |
| Ok(e) => Json(json!({ | |
| "id": e.front.id, "title": e.title(), "type": e.front.entry_type.to_string(), | |
| "author": e.front.author, "classification": e.front.classification.to_string(), | |
| "trust_tier": e.front.trust_tier.to_string(), "body": e.body, | |
| })) | |
| .into_response(), | |
| Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(), | |
| } | |
| } | |
| /// The browser reader. | |
| async fn reader() -> Html<&'static str> { | |
| Html(pages::READER) | |
| } | |
| /// The board: tasks by status, each under the task it was filed from. | |
| async fn board() -> Html<&'static str> { | |
| Html(pages::BOARD) | |
| } | |
| fn task_error(e: &dm_server::Error) -> Response { | |
| let status = match e { | |
| dm_server::Error::NotFound(_) => StatusCode::NOT_FOUND, | |
| dm_server::Error::Denied(_) => StatusCode::FORBIDDEN, | |
| dm_server::Error::Ledger(_) => StatusCode::CONFLICT, | |
| _ => StatusCode::INTERNAL_SERVER_ERROR, | |
| }; | |
| (status, e.to_string()).into_response() | |
| } | |
| /// Every task of the domain, whatever its status, newest first within a status, with | |
| /// the domain named: what the board lists and nests by origin. | |
| async fn tasks(State(app): State<App>) -> Response { | |
| let m = session!(app); | |
| use dm_ledger::Status; | |
| let domain = match m.resolve_domain(None) { | |
| Ok(d) => d, | |
| Err(e) => return task_error(&e), | |
| }; | |
| let mut all = Vec::new(); | |
| for status in [ | |
| Status::Claimed, | |
| Status::Admitted, | |
| Status::Proposed, | |
| Status::Completed, | |
| Status::Invalidated, | |
| ] { | |
| match m.tasks(Some(&domain), status).await { | |
| Ok(mut t) => all.append(&mut t), | |
| Err(e) => return task_error(&e), | |
| } | |
| } | |
| Json(json!({ "domain": domain, "tasks": all })).into_response() | |
| } | |
| /// A task filed from the board, by the person. | |
| #[derive(Debug, Default, Deserialize)] | |
| struct FileTask { | |
| title: String, | |
| #[serde(default)] | |
| body: String, | |
| #[serde(default)] | |
| requirements: Vec<String>, | |
| #[serde(default)] | |
| priority: Option<i64>, | |
| } | |
| async fn file_task(State(app): State<App>, Json(req): Json<FileTask>) -> Response { | |
| let m = session!(app); | |
| if req.title.trim().is_empty() { | |
| return (StatusCode::BAD_REQUEST, "a task needs a title").into_response(); | |
| } | |
| let spec = dm_ledger::TaskSpec { | |
| domain: String::new(), | |
| title: req.title.trim().to_string(), | |
| body: req.body, | |
| requirements: req.requirements, | |
| depends_on: Vec::new(), | |
| origin: None, | |
| upstream_event: None, | |
| created_by: String::new(), | |
| classification: dm_core::Label::Public, | |
| priority: req.priority.unwrap_or(0), | |
| }; | |
| match m.create_task(spec).await { | |
| Ok(c) => Json(json!({ "task": c.task, "deduplicated": c.deduplicated })).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| async fn task_tree(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| let m = session!(app); | |
| match m.task_tree(&id).await { | |
| Ok(t) => Json(json!(t)).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| async fn admit_task(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| let m = session!(app); | |
| match m.admit(&id).await { | |
| Ok(t) => Json(json!({ "task": t })).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| /// The person claims a task, for half an hour at a time; the board's own heartbeat is | |
| /// the person coming back to complete it. | |
| async fn claim_task(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| let m = session!(app); | |
| match m.claim(&id, time::Duration::minutes(30)).await { | |
| Ok(c) => Json(json!({ "task": c.task })).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| #[derive(Debug, Default, Deserialize)] | |
| struct Handoff { | |
| #[serde(default)] | |
| handoff: String, | |
| } | |
| /// Complete a task the person holds: the handoff text becomes a handoff entry, and the | |
| /// task completes with it, as a harness's run would. | |
| async fn complete_task( | |
| State(app): State<App>, | |
| Path(id): Path<String>, | |
| Json(h): Json<Handoff>, | |
| ) -> Response { | |
| let m = session!(app); | |
| if h.handoff.trim().is_empty() { | |
| return (StatusCode::BAD_REQUEST, "a handoff needs a body").into_response(); | |
| } | |
| let task = match m.get_task(&id).await { | |
| Ok(t) => t, | |
| Err(e) => return task_error(&e), | |
| }; | |
| // a task that cannot be completed gets no handoff written for it: the entry would | |
| // stay in the record, citing nothing | |
| if task.status != dm_ledger::Status::Claimed { | |
| return ( | |
| StatusCode::CONFLICT, | |
| format!( | |
| "{id} is {:?}; only a claimed task is completed", | |
| task.status | |
| ), | |
| ) | |
| .into_response(); | |
| } | |
| let written = m | |
| .write(dm_server::WriteRequest { | |
| domain: Some(task.spec.domain.clone()), | |
| entry_type: dm_core::EntryType::Handoff, | |
| title: Some(format!("Handoff: {}", task.spec.title)), | |
| body: h.handoff, | |
| ..dm_server::WriteRequest::default() | |
| }) | |
| .await; | |
| let written = match written { | |
| Ok(w) => w, | |
| Err(e) => return task_error(&e), | |
| }; | |
| match m.complete(&id, &written.id).await { | |
| Ok(t) => Json(json!({ "task": t, "handoff": written.id })).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| #[derive(Debug, Default, Deserialize)] | |
| struct Reason { | |
| #[serde(default)] | |
| reason: Option<String>, | |
| } | |
| async fn invalidate_task( | |
| State(app): State<App>, | |
| Path(id): Path<String>, | |
| Json(r): Json<Reason>, | |
| ) -> Response { | |
| let m = session!(app); | |
| let reason = r.reason.unwrap_or_else(|| "closed from the board".into()); | |
| match m.invalidate(&id, &reason, None).await { | |
| Ok(t) => Json(json!({ "task": t })).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| async fn retry_task(State(app): State<App>, Path(id): Path<String>) -> Response { | |
| let m = session!(app); | |
| match m.retry_task(&id).await { | |
| Ok(t) => Json(json!({ "task": t })).into_response(), | |
| Err(e) => task_error(&e), | |
| } | |
| } | |
| /// The reader's wasm and glue. They change with the binary, under the same names, so | |
| /// the browser revalidates them rather than keeping a copy. | |
| async fn reader_asset(Path(name): Path<String>) -> Response { | |
| match pages::reader_asset(&name) { | |
| Some((content_type, bytes)) => ( | |
| [ | |
| (axum::http::header::CONTENT_TYPE, content_type), | |
| (axum::http::header::CACHE_CONTROL, "no-cache"), | |
| ], | |
| bytes, | |
| ) | |
| .into_response(), | |
| None => (StatusCode::NOT_FOUND, "no such asset").into_response(), | |
| } | |
| } | |
| /// The domains the record grants, for the reader's picker. | |
| async fn domains(State(app): State<App>) -> Json<Vec<String>> { | |
| Json(app.memory.domains()) | |
| } | |
| /// A bundle file name: letters, digits, dot, underscore and hyphen, not starting with a | |
| /// dot. Anything else is not a name and is never joined to a path. | |
| fn plain_file_name(file: &str) -> bool { | |
| !file.is_empty() | |
| && !file.starts_with('.') | |
| && file | |
| .bytes() | |
| .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-') | |
| } | |
| /// A status and the line that explains it. | |
| type Refusal = (StatusCode, String); | |
| /// What every bundle request passes before a path is built: the label parses, the file | |
| /// is a plain name, the domain is granted and the label is within the handling. The | |
| /// error is the refusal. | |
| fn admit_bundle_request( | |
| app: &App, | |
| domain: &str, | |
| label: &str, | |
| file: &str, | |
| ) -> Result<dm_core::Label, Refusal> { | |
| let Ok(label) = label.parse::<dm_core::Label>() else { | |
| return Err(( | |
| StatusCode::BAD_REQUEST, | |
| "label is public, internal or restricted".into(), | |
| )); | |
| }; | |
| if !plain_file_name(file) { | |
| return Err((StatusCode::NOT_FOUND, "no such file".into())); | |
| } | |
| if !app.memory.domains().iter().any(|d| d == domain) { | |
| return Err(( | |
| StatusCode::FORBIDDEN, | |
| format!("domain {domain} is not granted"), | |
| )); | |
| } | |
| let handling = app.memory.grants().handling; | |
| if label > handling { | |
| return Err(( | |
| StatusCode::FORBIDDEN, | |
| format!("{label} is above the handling {handling}"), | |
| )); | |
| } | |
| Ok(label) | |
| } | |
| /// The refusal for an export that did not happen: a denial is the client's, anything | |
| /// else is the server's. | |
| fn export_refusal(e: dm_server::Error) -> Refusal { | |
| let status = match e { | |
| dm_server::Error::Denied(_) | |
| | dm_server::Error::NotFound(_) | |
| | dm_server::Error::Bundle(dm_server::bundle::Error::Invalid(_)) => StatusCode::FORBIDDEN, | |
| _ => StatusCode::INTERNAL_SERVER_ERROR, | |
| }; | |
| (status, e.to_string()) | |
| } | |
| /// Export the domain afresh into a sibling directory and swap it in, so `dir` holds | |
| /// its last export and nothing that was there before. One export runs at a time. | |
| async fn refresh_bundle( | |
| app: &App, | |
| domain: &str, | |
| label: dm_core::Label, | |
| dir: &std::path::Path, | |
| ) -> Result<(), Refusal> { | |
| let _one_at_a_time = app.exports.lock().await; | |
| let sibling = |suffix: &str| { | |
| let mut name = dir.file_name().unwrap_or_default().to_os_string(); | |
| name.push(suffix); | |
| dir.with_file_name(name) | |
| }; | |
| let (fresh, old) = (sibling(".new"), sibling(".old")); | |
| let _ = tokio::fs::remove_dir_all(&fresh).await; | |
| let _ = tokio::fs::remove_dir_all(&old).await; | |
| app.session() | |
| .map_err(export_refusal)? | |
| .export(Some(domain), label, &fresh) | |
| .await | |
| .map_err(export_refusal)?; | |
| let failed = |e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); | |
| if tokio::fs::metadata(dir).await.is_ok() { | |
| tokio::fs::rename(dir, &old).await.map_err(failed)?; | |
| } | |
| tokio::fs::rename(&fresh, dir).await.map_err(failed)?; | |
| let _ = tokio::fs::remove_dir_all(&old).await; | |
| Ok(()) | |
| } | |
| /// Whether the directory's current manifest lists `file`; the manifest lists itself. | |
| async fn listed(dir: &std::path::Path, file: &str) -> bool { | |
| if file == "manifest.yaml" { | |
| return true; | |
| } | |
| let Ok(text) = tokio::fs::read_to_string(dir.join("manifest.yaml")).await else { | |
| return false; | |
| }; | |
| dm_core::manifest::Manifest::from_yaml(&text) | |
| .map(|m| m.entries.iter().any(|e| e.path == file)) | |
| .unwrap_or(false) | |
| } | |
| /// A file of a domain's bundle. Every request is checked against the grants before a | |
| /// path is built. Asking for the manifest exports the domain afresh at that label, | |
| /// under the owner's filter and the label bound export always applies; the entries | |
| /// are then served from that export, and only the ones its manifest lists. | |
| async fn bundle_file( | |
| State(app): State<App>, | |
| Path((domain, label, file)): Path<(String, String, String)>, | |
| ) -> Response { | |
| let label = match admit_bundle_request(&app, &domain, &label, &file) { | |
| Ok(label) => label, | |
| Err(refused) => return refused.into_response(), | |
| }; | |
| let dir = dm_server::bundle::bundle_dir(&app.config.root, &domain, label); | |
| if file == "manifest.yaml" { | |
| if let Err(failed) = refresh_bundle(&app, &domain, label, &dir).await { | |
| return failed.into_response(); | |
| } | |
| } else if !listed(&dir, &file).await { | |
| return ( | |
| StatusCode::NOT_FOUND, | |
| "no such file; load manifest.yaml first", | |
| ) | |
| .into_response(); | |
| } | |
| let content_type = if file.ends_with(".yaml") { | |
| "application/yaml" | |
| } else { | |
| "text/markdown; charset=utf-8" | |
| }; | |
| match tokio::fs::read(dir.join(&file)).await { | |
| Ok(bytes) => ( | |
| [ | |
| (axum::http::header::CONTENT_TYPE, content_type), | |
| (axum::http::header::CACHE_CONTROL, "no-store"), | |
| ], | |
| bytes, | |
| ) | |
| .into_response(), | |
| Err(_) => (StatusCode::NOT_FOUND, "no such file").into_response(), | |
| } | |
| } | |
| /// The router, for serving or for tests. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the record cannot be opened. | |
| pub fn router(config: Config) -> Result<Router, Error> { | |
| // the browser is the owner, a person: a task addressed to a person, one that | |
| // requires `human`, is one the board may claim | |
| let memory = Arc::new(Memory::open(&config.root)?.with_manifest(vec!["human".into()])); | |
| let access = config.access.clone(); | |
| let app = App { | |
| config, | |
| memory, | |
| sessions: Arc::new(Mutex::new(BTreeMap::new())), | |
| exports: Arc::new(tokio::sync::Mutex::new(())), | |
| companion: Arc::new(companion::Companion::default()), | |
| }; | |
| Ok(Router::new() | |
| .route("/", get(index)) | |
| .route(companion::WS_PATH, get(companion::ws)) | |
| .route(companion::STATUS_PATH, get(companion::status)) | |
| .route(companion::FETCH_PATH, post(companion::fetch)) | |
| .route("/amem.js", get(amem_js)) | |
| .route("/tokens.css", get(tokens_css)) | |
| // the pages by file name too: a hosted copy has only files, and the pages link | |
| // each other relatively so the same links work from both | |
| .route("/index.html", get(index)) | |
| .route("/session.html", get(session_file)) | |
| .route("/board.html", get(board)) | |
| .route("/reader.html", get(reader)) | |
| .route("/api/hello", get(hello)) | |
| .route("/api/pair", axum::routing::post(pair)) | |
| .route("/api/pair/redeem", axum::routing::post(redeem)) | |
| .route("/vendor/{name}", get(vendor)) | |
| .route("/s/{id}", get(session_page)) | |
| .route("/ws/{id}", get(ws)) | |
| .route("/api/options", get(options_api)) | |
| .route("/api/models", get(models_api)) | |
| .route("/api/sessions", get(list_sessions).post(create_session)) | |
| .route("/api/sessions/{id}", get(session_info).delete(kill_session)) | |
| .route("/api/sessions/{id}/scrollback", get(scrollback)) | |
| .route("/api/sessions/{id}/status", get(session_status)) | |
| .route("/api/profiles", get(profiles_api)) | |
| .route("/api/doctor", get(doctor_api)) | |
| .route("/api/entries", get(entries)) | |
| .route("/api/entry/{id}", get(entry)) | |
| .route("/api/domains", get(domains)) | |
| .route("/board", get(board)) | |
| .route("/api/tasks", get(tasks).post(file_task)) | |
| .route("/api/tasks/{id}/tree", get(task_tree)) | |
| .route("/api/tasks/{id}/admit", post(admit_task)) | |
| .route("/api/tasks/{id}/claim", post(claim_task)) | |
| .route("/api/tasks/{id}/complete", post(complete_task)) | |
| .route("/api/tasks/{id}/invalidate", post(invalidate_task)) | |
| .route("/api/tasks/{id}/retry", post(retry_task)) | |
| .route("/reader", get(reader)) | |
| .route("/reader/{name}", get(reader_asset)) | |
| .route("/bundle/{domain}/{label}/{file}", get(bundle_file)) | |
| .with_state(app) | |
| .layer(axum::middleware::from_fn_with_state(access, access::gate))) | |
| } | |
| /// A listener on `listen`, which must be a loopback address: the server has no identity | |
| /// in front of it but the token, and a token on the network is a password in the clear. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the address is not loopback or cannot be bound. | |
| pub async fn bind(listen: &str) -> Result<tokio::net::TcpListener, Error> { | |
| let listener = tokio::net::TcpListener::bind(listen).await?; | |
| if !listener.local_addr()?.ip().is_loopback() { | |
| return Err(Error::Request(format!( | |
| "{listen} is not a loopback address; the web server listens on 127.0.0.1 or ::1 only" | |
| ))); | |
| } | |
| Ok(listener) | |
| } | |
| /// Serve on a listener from [`bind`] until the process ends. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the record cannot be opened or the listener fails. | |
| pub async fn serve(config: Config, listener: tokio::net::TcpListener) -> Result<(), Error> { | |
| config.access.bound(listener.local_addr()?); | |
| let app = router(config)?; | |
| axum::serve(listener, app).await?; | |
| Ok(()) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| fn config(dir: &std::path::Path, command: &[&str]) -> Config { | |
| Config { | |
| root: dir.join("memory"), | |
| memory_binary: "memory".into(), | |
| workdir: dir.to_path_buf(), | |
| harness: Some("claude".into()), | |
| auth: "subscription".into(), | |
| backend: "auto".into(), | |
| command_override: Some(command.iter().map(|s| (*s).to_string()).collect()), | |
| access: Access::with_token(&dir.join("memory"), TOKEN), | |
| } | |
| } | |
| const TOKEN: &str = "test-token"; | |
| #[test] | |
| fn a_profile_passes_only_what_the_request_sets_and_names_are_checked() { | |
| let mut c = config(std::path::Path::new("/tmp/x"), &[]); | |
| c.command_override = None; | |
| let argv = |r: NewSession| launch_argv(&c, &r).map(|a| a[3..].join(" ")); | |
| assert_eq!( | |
| argv(NewSession { | |
| profile: Some("nightly".into()), | |
| ..NewSession::default() | |
| }) | |
| .unwrap(), | |
| "launch --profile nightly" | |
| ); | |
| assert_eq!( | |
| argv(NewSession { | |
| profile: Some("nightly".into()), | |
| auth: Some("key".into()), | |
| backend: Some("image".into()), | |
| model: Some(" m2 ".into()), | |
| work: true, | |
| ..NewSession::default() | |
| }) | |
| .unwrap(), | |
| "work --profile nightly --auth key --backend image --model m2" | |
| ); | |
| let plain = argv(NewSession { | |
| harness: Some("codex".into()), | |
| backend: Some("image".into()), | |
| ..NewSession::default() | |
| }) | |
| .unwrap(); | |
| assert!( | |
| plain.contains("--harness codex") && plain.contains("--backend image"), | |
| "{plain}" | |
| ); | |
| for bad in ["../x", "Nightly", "-p", ""] { | |
| let r = argv(NewSession { | |
| profile: Some(bad.into()), | |
| ..NewSession::default() | |
| }); | |
| assert!(bad.is_empty() || r.is_err(), "{bad}: {r:?}"); | |
| } | |
| assert!(argv(NewSession { | |
| harness: Some("codex".into()), | |
| backend: Some("chroot".into()), | |
| ..NewSession::default() | |
| }) | |
| .unwrap_err() | |
| .contains("no backend named chroot")); | |
| let l = Launched::of(&NewSession { | |
| profile: Some("nightly".into()), | |
| prompt: Some("go".into()), | |
| ..NewSession::default() | |
| }); | |
| assert_eq!( | |
| (l.profile.as_deref(), l.mode.as_str()), | |
| (Some("nightly"), "prompt") | |
| ); | |
| } | |
| #[test] | |
| fn no_confinement_is_the_operators_to_allow_and_the_operators_backend_stands() { | |
| let mut c = config(std::path::Path::new("/tmp/x"), &[]); | |
| c.command_override = None; | |
| let argv = |r: NewSession| launch_argv(&c, &r).map(|a| a[3..].join(" ")); | |
| // no confinement is the operator's to allow, not a browser's to pick | |
| let none = NewSession { | |
| harness: Some("codex".into()), | |
| backend: Some("none".into()), | |
| ..NewSession::default() | |
| }; | |
| assert!(argv(none.clone()).unwrap_err().contains("runs confined")); | |
| assert!(!options(&c)["backends"] | |
| .as_array() | |
| .unwrap() | |
| .iter() | |
| .any(|b| b == "none")); | |
| let mut open = c.clone(); | |
| open.backend = "none".into(); | |
| assert!(launch_argv(&open, &none).is_ok()); | |
| assert!(options(&open)["backends"] | |
| .as_array() | |
| .unwrap() | |
| .iter() | |
| .any(|b| b == "none")); | |
| // the operator's own backend stands over a profile's | |
| let mut strict = c.clone(); | |
| strict.backend = "namespaces".into(); | |
| let a = launch_argv( | |
| &strict, | |
| &NewSession { | |
| profile: Some("nightly".into()), | |
| ..NewSession::default() | |
| }, | |
| ) | |
| .unwrap() | |
| .join(" "); | |
| assert!( | |
| a.ends_with("launch --profile nightly --backend namespaces"), | |
| "{a}" | |
| ); | |
| } | |
| #[test] | |
| fn the_launcher_command_line_follows_the_request() { | |
| let mut c = config(std::path::Path::new("/tmp/x"), &[]); | |
| c.command_override = None; | |
| let a = launch_argv( | |
| &c, | |
| &NewSession { | |
| harness: Some("codex".into()), | |
| auth: None, | |
| provider: Some("xai".into()), | |
| prompt: Some("hi".into()), | |
| work: false, | |
| args: vec!["-v".into()], | |
| ..NewSession::default() | |
| }, | |
| ) | |
| .unwrap(); | |
| assert_eq!( | |
| a.join(" "), | |
| format!( | |
| "memory --root {} launch --harness codex --auth subscription --backend auto --provider xai --prompt hi -- -v", | |
| c.root.display() | |
| ) | |
| ); | |
| let a = launch_argv( | |
| &c, | |
| &NewSession { | |
| work: true, | |
| ..NewSession::default() | |
| }, | |
| ) | |
| .unwrap(); | |
| assert!( | |
| a.join(" ") | |
| .ends_with("work --harness claude --auth subscription --backend auto"), | |
| "{a:?}" | |
| ); | |
| assert_eq!( | |
| title_for( | |
| &NewSession { | |
| work: true, | |
| ..NewSession::default() | |
| }, | |
| &c | |
| ), | |
| "claude: next task" | |
| ); | |
| } | |
| #[test] | |
| fn a_model_and_the_board_go_into_the_command_line_and_the_title() { | |
| let mut c = config(std::path::Path::new("/tmp/x"), &[]); | |
| c.command_override = None; | |
| let req = NewSession { | |
| harness: Some("muse".into()), | |
| auth: Some("gateway".into()), | |
| provider: Some("llmao".into()), | |
| model: Some(" qwen3.8-27b ".into()), | |
| subagents: Some("board".into()), | |
| prompt: Some("say hi".into()), | |
| ..NewSession::default() | |
| }; | |
| let a = launch_argv(&c, &req).unwrap().join(" "); | |
| assert!( | |
| a.ends_with( | |
| "launch --harness muse --auth gateway --backend auto --provider llmao --model qwen3.8-27b --subagents board --prompt say hi" | |
| ), | |
| "{a}" | |
| ); | |
| assert_eq!(title_for(&req, &c), "muse on qwen3.8-27b: say hi"); | |
| // an empty model or destination is none | |
| let a = launch_argv( | |
| &c, | |
| &NewSession { | |
| harness: Some("codex".into()), | |
| model: Some(" ".into()), | |
| provider: Some(String::new()), | |
| ..NewSession::default() | |
| }, | |
| ) | |
| .unwrap() | |
| .join(" "); | |
| assert!(!a.contains("--model") && !a.contains("--provider"), "{a}"); | |
| // no harness anywhere is refused: there is no default | |
| c.harness = None; | |
| let err = launch_argv(&c, &NewSession::default()).unwrap_err(); | |
| assert!(err.contains("no default"), "{err}"); | |
| let o = options(&c); | |
| assert_eq!(o["destinations"]["gateway"][0], "llmao"); | |
| // every choice the form offers is described | |
| for b in o["backends"].as_array().unwrap() { | |
| assert!( | |
| o["descriptions"]["backends"][b.as_str().unwrap()].is_string(), | |
| "{b}" | |
| ); | |
| } | |
| for sa in o["subagents"].as_array().unwrap() { | |
| assert!( | |
| o["descriptions"]["subagents"][sa.as_str().unwrap()].is_string(), | |
| "{sa}" | |
| ); | |
| } | |
| assert!(o["harnesses"] | |
| .as_array() | |
| .unwrap() | |
| .iter() | |
| .any(|h| h == "muse")); | |
| assert!(o["defaults"]["harness"].is_null()); | |
| } | |
| async fn start( | |
| dir: &std::path::Path, | |
| command: &[&str], | |
| ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { | |
| Memory::init(&dir.join("memory"), "human:test:me", "dom-me").unwrap(); | |
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| let addr = listener.local_addr().unwrap(); | |
| let config = config(dir, command); | |
| let h = tokio::spawn(async move { | |
| serve(config, listener).await.unwrap(); | |
| }); | |
| (addr, h) | |
| } | |
| /// A small HTTP/1.1 client over a socket: enough for the tests, and no TLS stack. | |
| async fn http( | |
| addr: std::net::SocketAddr, | |
| method: &str, | |
| path: &str, | |
| body: Option<&str>, | |
| ) -> (u16, String) { | |
| use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
| let mut s = tokio::net::TcpStream::connect(addr).await.unwrap(); | |
| let body = body.unwrap_or(""); | |
| let req = format!( | |
| "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {TOKEN}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", | |
| body.len() | |
| ); | |
| s.write_all(req.as_bytes()).await.unwrap(); | |
| let mut out = Vec::new(); | |
| s.read_to_end(&mut out).await.unwrap(); | |
| let text = String::from_utf8_lossy(&out).into_owned(); | |
| let status: u16 = text | |
| .split(' ') | |
| .nth(1) | |
| .and_then(|c| c.parse().ok()) | |
| .unwrap_or(0); | |
| let body = text | |
| .split_once("\r\n\r\n") | |
| .map(|(_, b)| b.to_string()) | |
| .unwrap_or_default(); | |
| (status, body) | |
| } | |
| /// One request as written, headers and all; the status and the head of the reply. | |
| async fn raw(addr: std::net::SocketAddr, request: &str) -> (u16, String) { | |
| use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
| let mut s = tokio::net::TcpStream::connect(addr).await.unwrap(); | |
| s.write_all(request.as_bytes()).await.unwrap(); | |
| let mut out = vec![0u8; 4096]; | |
| let n = tokio::time::timeout(std::time::Duration::from_secs(5), s.read(&mut out)) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| let text = String::from_utf8_lossy(&out[..n]).into_owned(); | |
| let status = text | |
| .split(' ') | |
| .nth(1) | |
| .and_then(|c| c.parse().ok()) | |
| .unwrap_or(0); | |
| (status, text) | |
| } | |
| #[tokio::test] | |
| async fn the_gate_wants_a_loopback_host_its_own_origin_and_a_key() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let get = |path: &str, extra: &str| { | |
| format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\n{extra}Connection: close\r\n\r\n") | |
| }; | |
| let bearer = format!("Authorization: Bearer {TOKEN}\r\n"); | |
| // the record behind the API wants a key; the page that says how to get one does not | |
| assert_eq!(raw(addr, &get("/api/sessions", "")).await.0, 401); | |
| assert_eq!( | |
| raw( | |
| addr, | |
| &get("/api/sessions", "Authorization: Bearer nope\r\n") | |
| ) | |
| .await | |
| .0, | |
| 401 | |
| ); | |
| assert_eq!(raw(addr, &get("/api/sessions", &bearer)).await.0, 200); | |
| assert_eq!(raw(addr, &get("/", "")).await.0, 200); | |
| assert_eq!( | |
| raw(addr, &get("/bundle/dom-me/internal/manifest.yaml", "")) | |
| .await | |
| .0, | |
| 401 | |
| ); | |
| // a cookie is no key: the browser would send it to every port on this host | |
| let cookie = get( | |
| "/api/sessions", | |
| &format!("Cookie: amem-{}={TOKEN}\r\n", addr.port()), | |
| ); | |
| assert_eq!(raw(addr, &cookie).await.0, 401); | |
| // a name that rebinds to loopback arrives with its own Host, key or not | |
| let rebound = format!( | |
| "GET /api/sessions HTTP/1.1\r\nHost: attacker.example:{}\r\n{bearer}Connection: close\r\n\r\n", | |
| addr.port() | |
| ); | |
| assert_eq!(raw(addr, &rebound).await.0, 403); | |
| // a page from another origin, another port on this host included, is refused | |
| for origin in ["https://attacker.example", "http://127.0.0.1:1", "null"] { | |
| let r = get("/api/sessions", &format!("{bearer}Origin: {origin}\r\n")); | |
| assert_eq!(raw(addr, &r).await.0, 403, "{origin}"); | |
| } | |
| let own = get( | |
| "/api/sessions", | |
| &format!("{bearer}Origin: http://{addr}\r\n"), | |
| ); | |
| assert_eq!(raw(addr, &own).await.0, 200); | |
| } | |
| fn post(addr: std::net::SocketAddr, path: &str, auth: &str, body: &str) -> String { | |
| format!( | |
| "POST {path} HTTP/1.1\r\nHost: {addr}\r\n{auth}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", | |
| body.len() | |
| ) | |
| } | |
| fn json_body(reply: &str) -> Value { | |
| serde_json::from_str(reply.split_once("\r\n\r\n").unwrap().1).unwrap() | |
| } | |
| #[tokio::test] | |
| async fn a_hosted_page_the_operator_allowed_is_answered_across_origins() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let root = dir.path().join("memory"); | |
| Memory::init(&root, "human:test:me", "dom-me").unwrap(); | |
| let mut c = config(dir.path(), &["true"]); | |
| c.access = Access::new( | |
| &root, | |
| &crate::state::WebState { | |
| token: TOKEN.into(), | |
| ..crate::state::WebState::default() | |
| }, | |
| vec!["https://amem.example".into()], | |
| ); | |
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| let addr = listener.local_addr().unwrap(); | |
| tokio::spawn(async move { serve(c, listener).await.unwrap() }); | |
| let req = |method: &str, path: &str, extra: &str| { | |
| format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\n{extra}Connection: close\r\n\r\n") | |
| }; | |
| let hosted = "Origin: https://amem.example\r\n"; | |
| let preflight = format!( | |
| "{hosted}Access-Control-Request-Method: GET\r\nAccess-Control-Request-Headers: authorization\r\nAccess-Control-Request-Private-Network: true\r\n" | |
| ); | |
| let (status, head) = raw(addr, &req("OPTIONS", "/api/sessions", &preflight)).await; | |
| assert_eq!(status, 204, "a preflight carries no key: {head}"); | |
| let head = head.to_ascii_lowercase(); | |
| assert!( | |
| head.contains("access-control-allow-origin: https://amem.example"), | |
| "{head}" | |
| ); | |
| assert!( | |
| head.contains("access-control-allow-private-network: true"), | |
| "{head}" | |
| ); | |
| assert!( | |
| head.contains("access-control-allow-headers: authorization, content-type"), | |
| "{head}" | |
| ); | |
| let keyed = format!("{hosted}Authorization: Bearer {TOKEN}\r\n"); | |
| let (status, head) = raw(addr, &req("GET", "/api/sessions", &keyed)).await; | |
| assert_eq!(status, 200); | |
| assert!(head | |
| .to_ascii_lowercase() | |
| .contains("access-control-allow-origin: https://amem.example")); | |
| let (status, head) = raw(addr, &req("GET", "/api/sessions", hosted)).await; | |
| assert_eq!(status, 401, "the call itself still wants a key"); | |
| assert!( | |
| head.to_ascii_lowercase() | |
| .contains("access-control-allow-origin"), | |
| "so the page can read why" | |
| ); | |
| let other = "Origin: https://attacker.example\r\nAccess-Control-Request-Method: GET\r\n"; | |
| assert_eq!( | |
| raw(addr, &req("OPTIONS", "/api/sessions", other)).await.0, | |
| 403 | |
| ); | |
| // the server's own pages get no cross-origin headers | |
| let (_, head) = raw( | |
| addr, | |
| &req( | |
| "GET", | |
| "/api/sessions", | |
| &format!("Authorization: Bearer {TOKEN}\r\nOrigin: http://{addr}\r\n"), | |
| ), | |
| ) | |
| .await; | |
| assert!( | |
| !head | |
| .to_ascii_lowercase() | |
| .contains("access-control-allow-origin"), | |
| "{head}" | |
| ); | |
| // the pages load with no key, by path and by file name | |
| for page in [ | |
| "/board", | |
| "/board.html", | |
| "/session.html", | |
| "/index.html", | |
| "/tokens.css", | |
| ] { | |
| assert_eq!(raw(addr, &req("GET", page, "")).await.0, 200, "{page}"); | |
| } | |
| } | |
| #[tokio::test] | |
| async fn the_token_mints_a_code_a_browser_trades_once_for_a_key_of_its_own() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let bearer = format!("Authorization: Bearer {TOKEN}\r\n"); | |
| assert_eq!(raw(addr, &post(addr, "/api/pair", "", "")).await.0, 401); | |
| let (status, reply) = raw(addr, &post(addr, "/api/pair", &bearer, "")).await; | |
| assert_eq!(status, 200, "{reply}"); | |
| let code = json_body(&reply)["code"].as_str().unwrap().to_string(); | |
| let redeem = post( | |
| addr, | |
| "/api/pair/redeem", | |
| "", | |
| &format!(r#"{{"code":"{code}"}}"#), | |
| ); | |
| let (status, reply) = raw(addr, &redeem).await; | |
| assert_eq!(status, 200, "{reply}"); | |
| let key = json_body(&reply)["key"].as_str().unwrap().to_string(); | |
| assert_ne!(key, TOKEN); | |
| assert_eq!(raw(addr, &redeem).await.0, 401, "a code is single use"); | |
| let with_key = format!("Authorization: Bearer {key}\r\n"); | |
| let list = format!( | |
| "GET /api/sessions HTTP/1.1\r\nHost: {addr}\r\n{with_key}Connection: close\r\n\r\n" | |
| ); | |
| assert_eq!(raw(addr, &list).await.0, 200); | |
| // a browser's key does not mint codes for more browsers | |
| assert_eq!( | |
| raw(addr, &post(addr, "/api/pair", &with_key, "")).await.0, | |
| 401 | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn hello_proves_the_token_without_it() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let nonce = "0123456789abcdef0123"; | |
| let (status, reply) = raw( | |
| addr, | |
| &format!("GET /api/hello?nonce={nonce} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{reply}"); | |
| assert!(!reply.contains(TOKEN)); | |
| let v = json_body(&reply); | |
| let root = dir.path().join("memory"); | |
| assert_eq!(v["proof"], access::proof(TOKEN, addr, &root, nonce)); | |
| assert!(v.get("root").is_none() && v.get("workdir").is_none(), "{v}"); | |
| assert_eq!(v["server"], "amem"); | |
| let short = | |
| format!("GET /api/hello?nonce=x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); | |
| assert_eq!(raw(addr, &short).await.0, 400); | |
| } | |
| #[cfg(unix)] | |
| #[tokio::test] | |
| async fn a_websocket_carries_its_key_as_a_subprotocol_and_another_origin_is_refused() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["sh", "-c", "sleep 5"]).await; | |
| let mut ids = Vec::new(); | |
| for _ in 0..2 { | |
| let (status, body) = | |
| http(addr, "POST", "/api/sessions", Some(r#"{"auth":"key"}"#)).await; | |
| assert_eq!(status, 200, "{body}"); | |
| ids.push( | |
| serde_json::from_str::<Value>(&body).unwrap()["id"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(), | |
| ); | |
| } | |
| assert!(ids | |
| .iter() | |
| .all(|i| i.len() == 32 && i.bytes().all(|b| b.is_ascii_hexdigit()))); | |
| assert_ne!(ids[0], ids[1]); | |
| let id = &ids[0]; | |
| let upgrade = |origin: &str, protocols: &str| { | |
| format!( | |
| "GET /ws/{id} HTTP/1.1\r\nHost: {addr}\r\nOrigin: {origin}\r\n{protocols}Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" | |
| ) | |
| }; | |
| let offered = format!("Sec-WebSocket-Protocol: amem, amem.key.{TOKEN}\r\n"); | |
| let own = format!("http://{addr}"); | |
| assert_eq!( | |
| raw(addr, &upgrade("https://attacker.example", &offered)) | |
| .await | |
| .0, | |
| 403 | |
| ); | |
| assert_eq!( | |
| raw(addr, &upgrade(&own, "Sec-WebSocket-Protocol: amem\r\n")) | |
| .await | |
| .0, | |
| 401 | |
| ); | |
| let (status, head) = raw(addr, &upgrade(&own, &offered)).await; | |
| assert_eq!(status, 101, "{head}"); | |
| assert!( | |
| head.to_ascii_lowercase() | |
| .contains("sec-websocket-protocol: amem\r\n"), | |
| "{head}" | |
| ); | |
| } | |
| async fn get_json(addr: std::net::SocketAddr, path: &str) -> (u16, Value) { | |
| let (status, body) = http(addr, "GET", path, None).await; | |
| (status, serde_json::from_str(&body).unwrap_or(Value::Null)) | |
| } | |
| /// A request with a browser's key rather than the token. | |
| async fn as_browser( | |
| addr: std::net::SocketAddr, | |
| key: &str, | |
| method: &str, | |
| path: &str, | |
| body: &str, | |
| ) -> (u16, String) { | |
| use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
| let mut s = tokio::net::TcpStream::connect(addr).await.unwrap(); | |
| let req = format!( | |
| "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {key}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", | |
| body.len() | |
| ); | |
| s.write_all(req.as_bytes()).await.unwrap(); | |
| let mut out = String::new(); | |
| s.read_to_string(&mut out).await.unwrap(); | |
| let status = out.split(' ').nth(1).unwrap().parse().unwrap(); | |
| (status, out.split_once("\r\n\r\n").unwrap().1.to_string()) | |
| } | |
| #[tokio::test] | |
| async fn the_companion_is_asked_with_the_token_and_shown_to_a_page() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| // a paired page sees whether a companion is there, and there is none | |
| let (_, pairing) = http(addr, "POST", "/api/pair", None).await; | |
| let code = serde_json::from_str::<Value>(&pairing).unwrap()["code"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(); | |
| let (status, redeemed) = http( | |
| addr, | |
| "POST", | |
| "/api/pair/redeem", | |
| Some(&json!({"code": code}).to_string()), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{redeemed}"); | |
| let key = serde_json::from_str::<Value>(&redeemed).unwrap()["key"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(); | |
| let (status, body) = as_browser(addr, &key, "GET", companion::STATUS_PATH, "").await; | |
| assert_eq!(status, 200, "{body}"); | |
| let shown: companion::Status = serde_json::from_str(&body).unwrap(); | |
| assert_eq!(shown, companion::Status::default()); | |
| // a fetch with the browser's cookies is the launcher's to ask, with the token: | |
| // a page's key is refused before anything is looked at | |
| let ask = json!({"url": "https://a.example/", "hosts": ["a.example"]}).to_string(); | |
| let (status, _) = as_browser(addr, &key, "POST", companion::FETCH_PATH, &ask).await; | |
| assert_eq!(status, 401); | |
| // with the token and no companion connected: unavailable, and said so | |
| let (status, body) = http(addr, "POST", companion::FETCH_PATH, Some(&ask)).await; | |
| assert_eq!(status, 503, "{body}"); | |
| assert!(body.contains("no companion is connected"), "{body}"); | |
| // the socket wants a key too | |
| let (status, _) = as_browser(addr, "not-a-key", "GET", companion::WS_PATH, "").await; | |
| assert_eq!(status, 401); | |
| } | |
| #[tokio::test] | |
| async fn the_board_leaves_out_a_task_above_the_handling() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let root = dir.path().join("memory"); | |
| let owner = Memory::init(&root, "human:test:me", "dom-me").unwrap(); | |
| let mut hidden = String::new(); | |
| for (title, classification) in [ | |
| ("Rotate the signing key", dm_core::Label::Restricted), | |
| ("Tidy the docs", dm_core::Label::Public), | |
| ] { | |
| let t = owner | |
| .file_own(dm_ledger::TaskSpec { | |
| domain: "dom-me".into(), | |
| title: title.into(), | |
| body: "Do it.".into(), | |
| requirements: vec!["human".into()], | |
| depends_on: vec![], | |
| origin: None, | |
| upstream_event: None, | |
| created_by: String::new(), | |
| classification, | |
| priority: 0, | |
| }) | |
| .await | |
| .unwrap() | |
| .task; | |
| if t.status == dm_ledger::Status::Proposed { | |
| owner.admit(&t.id).await.unwrap(); | |
| } | |
| if classification == dm_core::Label::Restricted { | |
| hidden = t.id; | |
| } | |
| } | |
| drop(owner); | |
| // the board's handling drops to internal, under the restricted task | |
| let grants = root.join("grants.toml"); | |
| let lowered = std::fs::read_to_string(&grants) | |
| .unwrap() | |
| .replace("handling = \"restricted\"", "handling = \"internal\""); | |
| assert!(lowered.contains("handling = \"internal\"")); | |
| std::fs::write(&grants, lowered).unwrap(); | |
| let app = router(config(dir.path(), &["true"])).unwrap(); | |
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| let addr = listener.local_addr().unwrap(); | |
| let _h = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); | |
| let (_, all) = get_json(addr, "/api/tasks").await; | |
| let all = all.to_string(); | |
| assert!(all.contains("Tidy the docs"), "{all}"); | |
| assert!(!all.contains("signing key"), "{all}"); | |
| for path in ["tree", "claim"] { | |
| let method = if path == "tree" { "GET" } else { "POST" }; | |
| let (status, body) = | |
| http(addr, method, &format!("/api/tasks/{hidden}/{path}"), None).await; | |
| let (missing, _) = http( | |
| addr, | |
| method, | |
| &format!("/api/tasks/task-me-none/{path}"), | |
| None, | |
| ) | |
| .await; | |
| assert_eq!(status, missing, "{path}: {body}"); | |
| assert!(!body.contains("signing key"), "{path}: {body}"); | |
| } | |
| } | |
| #[tokio::test] | |
| async fn a_listing_on_the_board_does_not_classify_what_it_files_after() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let owner = Memory::open(&dir.path().join("memory")).unwrap(); | |
| let secret = owner | |
| .file_own(dm_ledger::TaskSpec { | |
| domain: "dom-me".into(), | |
| title: "Rotate the signing key".into(), | |
| body: "Do it.".into(), | |
| requirements: vec![], | |
| depends_on: vec![], | |
| origin: None, | |
| upstream_event: None, | |
| created_by: String::new(), | |
| classification: dm_core::Label::Restricted, | |
| priority: 0, | |
| }) | |
| .await | |
| .unwrap() | |
| .task; | |
| drop(owner); | |
| let file = |title: &'static str| async move { | |
| let body = format!(r#"{{"title":"{title}"}}"#); | |
| let (status, body) = http(addr, "POST", "/api/tasks", Some(&body)).await; | |
| assert_eq!(status, 200, "{body}"); | |
| serde_json::from_str::<Value>(&body).unwrap()["task"]["classification"].clone() | |
| }; | |
| assert_eq!(file("Before the listing").await, "public"); | |
| let (_, all) = get_json(addr, "/api/tasks").await; | |
| assert!(all.to_string().contains(&secret.id), "{all}"); | |
| assert_eq!( | |
| file("After the listing").await, | |
| "public", | |
| "a listing in one request classified a filing in a later one" | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn a_read_on_the_board_is_joined_to_what_a_later_board_request_writes() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let owner = Memory::open(&dir.path().join("memory")).unwrap(); | |
| let poison = owner | |
| .write(dm_server::WriteRequest { | |
| body: "Something.\n".into(), | |
| ..dm_server::WriteRequest::default() | |
| }) | |
| .await | |
| .unwrap() | |
| .id; | |
| let (status, body) = http(addr, "GET", &format!("/api/entry/{poison}"), None).await; | |
| assert_eq!(status, 200, "{body}"); | |
| let (status, body) = | |
| http(addr, "POST", "/api/tasks", Some(r#"{"title":"Sign off"}"#)).await; | |
| assert_eq!(status, 200, "{body}"); | |
| let id = serde_json::from_str::<Value>(&body).unwrap()["task"]["id"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(); | |
| for step in ["admit", "claim"] { | |
| let (status, body) = http(addr, "POST", &format!("/api/tasks/{id}/{step}"), None).await; | |
| assert_eq!(status, 200, "{step}: {body}"); | |
| } | |
| let (status, body) = http( | |
| addr, | |
| "POST", | |
| &format!("/api/tasks/{id}/complete"), | |
| Some(r#"{"handoff":"Done, after reading."}"#), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{body}"); | |
| let handoff = serde_json::from_str::<Value>(&body).unwrap()["handoff"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(); | |
| let t = owner.taint(&poison); | |
| assert_eq!( | |
| t.readers.len(), | |
| 1, | |
| "the board reads as one session: {:?}", | |
| t.readers | |
| ); | |
| assert_eq!( | |
| t.written, | |
| vec![handoff], | |
| "the handoff the board wrote after reading the entry follows from the read" | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn the_board_answers_while_a_rebuild_holds_the_index() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let root = dir.path().join("memory"); | |
| let owner = Memory::open(&root).unwrap(); | |
| let id = owner | |
| .write(dm_server::WriteRequest { | |
| body: "Kept.\n".into(), | |
| ..dm_server::WriteRequest::default() | |
| }) | |
| .await | |
| .unwrap() | |
| .id; | |
| let entry = owner.get(&id).await.unwrap(); | |
| drop(owner); | |
| // a reindex in another process holds the index for writing until it has been | |
| // given every entry; the board polls meanwhile | |
| let (give, given) = std::sync::mpsc::channel::<()>(); | |
| let (held, holding) = std::sync::mpsc::channel::<()>(); | |
| let path = root.join(dm_server::INDEX_FILE); | |
| let rebuild = std::thread::spawn(move || { | |
| let mut ix = dm_index::Index::open(&path).unwrap(); | |
| let mut pending = Some(entry); | |
| ix.rebuild(std::iter::from_fn(move || { | |
| let _ = held.send(()); | |
| given.recv().ok()?; | |
| pending.take() | |
| })) | |
| .unwrap() | |
| }); | |
| holding.recv().unwrap(); | |
| let started = std::time::Instant::now(); | |
| for path in ["/api/tasks", "/api/entries"] { | |
| let (status, body) = http(addr, "GET", path, None).await; | |
| assert_eq!(status, 200, "{path} while the index is held: {body}"); | |
| } | |
| assert!( | |
| started.elapsed() < std::time::Duration::from_secs(4), | |
| "the board waited on the index: {:?}", | |
| started.elapsed() | |
| ); | |
| give.send(()).unwrap(); | |
| drop(give); | |
| assert_eq!(rebuild.join().unwrap(), 1); | |
| } | |
| #[tokio::test] | |
| async fn the_board_s_read_scope_is_decided_once_not_once_per_request() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let log = dir.path().join("memory/state/decisions.jsonl"); | |
| let lines = || { | |
| std::fs::read_to_string(&log) | |
| .map(|t| t.lines().count()) | |
| .unwrap_or(0) | |
| }; | |
| let (status, _) = http(addr, "GET", "/api/tasks", None).await; | |
| assert_eq!(status, 200); | |
| let after_one = lines(); | |
| for _ in 0..5 { | |
| let (status, _) = get_json(addr, "/api/tasks").await; | |
| assert_eq!(status, 200); | |
| } | |
| assert_eq!( | |
| lines(), | |
| after_one, | |
| "a board poll logged a decision; the read scope is decided once for the board" | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn the_board_files_admits_claims_and_completes_a_person_s_task() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let (status, body) = http( | |
| addr, | |
| "POST", | |
| "/api/tasks", | |
| Some(r#"{"title":"Sign off the release","body":"Check the notes.","requirements":["human"]}"#), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{body}"); | |
| let id = serde_json::from_str::<Value>(&body).unwrap()["task"]["id"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(); | |
| let (status, _) = http(addr, "POST", "/api/tasks", Some(r#"{"title":" "}"#)).await; | |
| assert_eq!(status, 400); | |
| let (_, all) = get_json(addr, "/api/tasks").await; | |
| assert_eq!(all["domain"], "dom-me"); | |
| assert_eq!(all["tasks"][0]["status"], "proposed"); | |
| let (status, _) = http(addr, "POST", &format!("/api/tasks/{id}/admit"), None).await; | |
| assert_eq!(status, 200); | |
| // a task that is not claimed is not completed, and no handoff is left behind | |
| let (status, _) = http( | |
| addr, | |
| "POST", | |
| &format!("/api/tasks/{id}/complete"), | |
| Some(r#"{"handoff":"too early"}"#), | |
| ) | |
| .await; | |
| assert_eq!(status, 409); | |
| let (_, listed) = get_json(addr, "/api/entries").await; | |
| assert!( | |
| !listed.to_string().contains("Handoff:"), | |
| "a refused completion wrote a handoff: {listed}" | |
| ); | |
| // the board is a person, so a task that requires one is claimable from it | |
| let (status, body) = http(addr, "POST", &format!("/api/tasks/{id}/claim"), None).await; | |
| assert_eq!(status, 200, "{body}"); | |
| let (status, _) = http( | |
| addr, | |
| "POST", | |
| &format!("/api/tasks/{id}/complete"), | |
| Some(r#"{"handoff":" "}"#), | |
| ) | |
| .await; | |
| assert_eq!(status, 400, "a handoff needs a body"); | |
| let (status, body) = http( | |
| addr, | |
| "POST", | |
| &format!("/api/tasks/{id}/complete"), | |
| Some(r#"{"handoff":"Signed off; ship it."}"#), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{body}"); | |
| let done: Value = serde_json::from_str(&body).unwrap(); | |
| assert_eq!(done["task"]["status"], "completed"); | |
| assert!(done["handoff"].as_str().unwrap().starts_with("mem-")); | |
| let (status, tree) = get_json(addr, &format!("/api/tasks/{id}/tree")).await; | |
| assert_eq!(status, 200); | |
| assert_eq!(tree["id"], id); | |
| assert_eq!(tree["children"].as_array().unwrap().len(), 0); | |
| let (status, _) = http(addr, "POST", "/api/tasks/task-me-none/retry", None).await; | |
| assert!(status == 404 || status == 409, "{status}"); | |
| } | |
| #[cfg(unix)] | |
| #[tokio::test] | |
| async fn a_session_is_told_its_id_and_its_status_is_what_its_launcher_wrote() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start( | |
| dir.path(), | |
| &["sh", "-c", "echo id=$AMEM_WEB_SESSION; sleep 5"], | |
| ) | |
| .await; | |
| let (status, body) = http( | |
| addr, | |
| "POST", | |
| "/api/sessions", | |
| Some(r#"{"profile":"nightly"}"#), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{body}"); | |
| let v: Value = serde_json::from_str(&body).unwrap(); | |
| let id = v["id"].as_str().unwrap().to_string(); | |
| assert_eq!(v["launch"]["profile"], "nightly"); | |
| assert_eq!(v["title"], "nightly"); | |
| let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); | |
| loop { | |
| let (_, text) = | |
| http(addr, "GET", &format!("/api/sessions/{id}/scrollback"), None).await; | |
| if text.contains(&format!("id={id}")) { | |
| break; | |
| } | |
| assert!( | |
| tokio::time::Instant::now() < deadline, | |
| "the session was not told its id: {text}" | |
| ); | |
| tokio::time::sleep(std::time::Duration::from_millis(100)).await; | |
| } | |
| let (status, v) = get_json(addr, &format!("/api/sessions/{id}/status")).await; | |
| assert_eq!((status, v), (200, json!({})), "nothing written yet"); | |
| let file = dir | |
| .path() | |
| .join("memory/state/web") | |
| .join(format!("{id}.json")); | |
| std::fs::create_dir_all(file.parent().unwrap()).unwrap(); | |
| std::fs::write(&file, r#"{"destination":"llmao","usage":{"calls":3}}"#).unwrap(); | |
| let (_, v) = get_json(addr, &format!("/api/sessions/{id}/status")).await; | |
| assert_eq!(v["destination"], "llmao"); | |
| assert_eq!(v["usage"]["calls"], 3); | |
| let (status, _) = get_json( | |
| addr, | |
| "/api/sessions/0123456789abcdef0123456789abcdef/status", | |
| ) | |
| .await; | |
| assert_eq!(status, 404, "only a session this server started"); | |
| let (_, o) = get_json(addr, "/api/options").await; | |
| assert_eq!(o["backends"][0], "auto"); | |
| } | |
| #[cfg(unix)] | |
| #[tokio::test] | |
| async fn the_scrollback_is_the_ring_as_text_and_a_request_without_a_harness_is_400() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["sh", "-c", "echo hello-ring"]).await; | |
| let (status, body) = http(addr, "POST", "/api/sessions", Some(r#"{"auth":"key"}"#)).await; | |
| assert_eq!(status, 200, "the test config preselects claude: {body}"); | |
| let id = serde_json::from_str::<Value>(&body).unwrap()["id"] | |
| .as_str() | |
| .unwrap() | |
| .to_string(); | |
| let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); | |
| loop { | |
| let (_, info) = get_json(addr, &format!("/api/sessions/{id}")).await; | |
| if info["alive"] == false || tokio::time::Instant::now() > deadline { | |
| break; | |
| } | |
| tokio::time::sleep(std::time::Duration::from_millis(100)).await; | |
| } | |
| let (status, text) = | |
| http(addr, "GET", &format!("/api/sessions/{id}/scrollback"), None).await; | |
| assert_eq!(status, 200); | |
| assert!(text.contains("hello-ring"), "{text}"); | |
| let (status, _) = http(addr, "GET", "/api/sessions/nope/scrollback", None).await; | |
| assert_eq!(status, 404); | |
| let (status, _) = get_json(addr, "/api/options").await; | |
| assert_eq!(status, 200); | |
| } | |
| #[cfg(unix)] | |
| async fn ws_connect( | |
| addr: std::net::SocketAddr, | |
| id: &str, | |
| ) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> | |
| { | |
| use tokio_tungstenite::tungstenite::client::IntoClientRequest; | |
| let mut req = format!("ws://{addr}/ws/{id}") | |
| .into_client_request() | |
| .unwrap(); | |
| req.headers_mut() | |
| .insert("authorization", format!("Bearer {TOKEN}").parse().unwrap()); | |
| let (ws, _) = tokio_tungstenite::connect_async(req).await.unwrap(); | |
| ws | |
| } | |
| #[cfg(unix)] | |
| async fn read_until( | |
| ws: &mut (impl StreamExt< | |
| Item = Result< | |
| tokio_tungstenite::tungstenite::Message, | |
| tokio_tungstenite::tungstenite::Error, | |
| >, | |
| > + Unpin), | |
| needle: &str, | |
| ) -> String { | |
| let mut got = String::new(); | |
| let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); | |
| while !got.contains(needle) && tokio::time::Instant::now() < deadline { | |
| match tokio::time::timeout(std::time::Duration::from_secs(1), ws.next()).await { | |
| Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(b)))) => { | |
| got.push_str(&String::from_utf8_lossy(&b)) | |
| } | |
| Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(t)))) => { | |
| got.push_str(&format!("<{t}>")) | |
| } | |
| Ok(Some(Ok(_))) => {} | |
| Ok(Some(Err(_)) | None) => break, | |
| Err(_) => {} | |
| } | |
| } | |
| got | |
| } | |
| #[tokio::test] | |
| #[cfg(unix)] | |
| async fn a_session_streams_echoes_and_replays_its_scrollback() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start( | |
| dir.path(), | |
| &[ | |
| "sh", | |
| "-c", | |
| "echo hello-from-pty; read x; echo got-$x; exit 3", | |
| ], | |
| ) | |
| .await; | |
| let (status, body) = http( | |
| addr, | |
| "POST", | |
| "/api/sessions", | |
| Some(&json!({ "prompt": "say hi" }).to_string()), | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{body}"); | |
| let created: SessionInfo = serde_json::from_str(&body).unwrap(); | |
| assert!(created.alive); | |
| assert_eq!(created.title, "claude: say hi"); | |
| let mut ws = ws_connect(addr, &created.id).await; | |
| let got = read_until(&mut ws, "hello-from-pty").await; | |
| assert!(got.contains("hello-from-pty"), "{got}"); | |
| ws.send(tokio_tungstenite::tungstenite::Message::Text( | |
| json!({"type":"resize","cols":100,"rows":30}) | |
| .to_string() | |
| .into(), | |
| )) | |
| .await | |
| .unwrap(); | |
| ws.send(tokio_tungstenite::tungstenite::Message::Text( | |
| json!({"type":"input","data":"pong\n"}).to_string().into(), | |
| )) | |
| .await | |
| .unwrap(); | |
| let got = read_until(&mut ws, "exit").await; | |
| assert!(got.contains("got-pong"), "{got}"); | |
| assert!( | |
| got.contains(r#"{"code":3,"type":"exit"}"#) | |
| || got.contains(r#"{"type":"exit","code":3}"#), | |
| "{got}" | |
| ); | |
| a_late_browser_sees_everything(addr, &created.id).await; | |
| } | |
| #[cfg(unix)] | |
| async fn a_late_browser_sees_everything(addr: std::net::SocketAddr, id: &str) { | |
| let created_id = id; | |
| // a second browser, after the fact, gets the whole scrollback and the exit | |
| let mut ws2 = ws_connect(addr, created_id).await; | |
| let got = read_until(&mut ws2, "exit").await; | |
| assert!( | |
| got.contains("hello-from-pty") && got.contains("got-pong"), | |
| "replayed: {got}" | |
| ); | |
| let (_, info) = get_json(addr, &format!("/api/sessions/{created_id}")).await; | |
| let info: SessionInfo = serde_json::from_value(info).unwrap(); | |
| assert!(!info.alive); | |
| assert_eq!(info.exit, Some(3)); | |
| let (_, list) = get_json(addr, "/api/sessions").await; | |
| let list: Vec<SessionInfo> = serde_json::from_value(list).unwrap(); | |
| assert_eq!(list.len(), 1); | |
| let (page, _) = http(addr, "GET", &format!("/s/{created_id}"), None).await; | |
| assert_eq!(page, 200); | |
| let (missing, _) = http(addr, "GET", "/s/nope", None).await; | |
| assert_eq!(missing, 404); | |
| } | |
| #[tokio::test] | |
| #[cfg(unix)] | |
| async fn a_session_can_be_killed_and_unknown_ids_are_404() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["sh", "-c", "sleep 30"]).await; | |
| let (status, body) = http(addr, "POST", "/api/sessions", Some("{}")).await; | |
| assert_eq!(status, 200, "{body}"); | |
| let created: SessionInfo = serde_json::from_str(&body).unwrap(); | |
| assert_eq!(created.title, "claude"); | |
| let (status, _) = http( | |
| addr, | |
| "DELETE", | |
| &format!("/api/sessions/{}", created.id), | |
| None, | |
| ) | |
| .await; | |
| assert_eq!(status, 204); | |
| let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); | |
| loop { | |
| let (_, info) = get_json(addr, &format!("/api/sessions/{}", created.id)).await; | |
| if info["alive"] == false || tokio::time::Instant::now() > deadline { | |
| assert_eq!(info["alive"], false, "{info}"); | |
| break; | |
| } | |
| tokio::time::sleep(std::time::Duration::from_millis(100)).await; | |
| } | |
| let (status, _) = http(addr, "GET", "/api/sessions/nope", None).await; | |
| assert_eq!(status, 404); | |
| let (status, _) = http(addr, "DELETE", "/api/sessions/nope", None).await; | |
| assert_eq!(status, 404); | |
| // without the upgrade headers the websocket route is a bad request, not a page | |
| let (status, _) = http(addr, "GET", "/ws/nope", None).await; | |
| assert_eq!(status, 400); | |
| } | |
| #[tokio::test] | |
| async fn a_launcher_that_cannot_start_is_a_500() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["/definitely/not/a/program"]).await; | |
| let (status, body) = http(addr, "POST", "/api/sessions", Some("{}")).await; | |
| assert_eq!(status, 500, "{body}"); | |
| assert!(body.contains("session:"), "{body}"); | |
| let (status, _) = http(addr, "GET", "/", None).await; | |
| assert_eq!(status, 200); | |
| } | |
| #[tokio::test] | |
| async fn the_libraries_are_served_from_the_binary() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| for (name, needle) in [ | |
| ("xterm.min.js", "Terminal"), | |
| ("xterm.min.css", ".xterm"), | |
| ("addon-fit.min.js", "FitAddon"), | |
| ("marked.min.js", "marked"), | |
| ("mermaid.min.js", "mermaid"), | |
| ] { | |
| let (status, body) = http(addr, "GET", &format!("/vendor/{name}"), None).await; | |
| assert_eq!(status, 200, "{name}"); | |
| assert!(body.contains(needle), "{name} lacks {needle}"); | |
| } | |
| let (status, _) = http(addr, "GET", "/vendor/evil.js", None).await; | |
| assert_eq!(status, 404); | |
| let (_, page) = http(addr, "GET", "/", None).await; | |
| assert!(!page.contains("cdn."), "no CDN on the index page"); | |
| assert!( | |
| !pages::SESSION.contains("cdn."), | |
| "no CDN on the session page" | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn the_pane_reads_entries_from_the_record() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let m = Memory::open(&dir.path().join("memory")) | |
| .unwrap() | |
| .with_author("agent:test"); | |
| let w = m | |
| .write(dm_server::WriteRequest { | |
| body: "# Plan\n\n```mermaid\ngraph TD; A-->B;\n```\n".into(), | |
| ..dm_server::WriteRequest::default() | |
| }) | |
| .await | |
| .unwrap(); | |
| let (_, list) = get_json(addr, "/api/entries").await; | |
| let list: Vec<Value> = serde_json::from_value(list).unwrap(); | |
| assert!(list.iter().any(|e| e["id"] == w.id), "{list:?}"); | |
| let (_, e) = get_json(addr, &format!("/api/entry/{}", w.id)).await; | |
| assert_eq!(e["title"], "Plan"); | |
| assert!(e["body"].as_str().unwrap().contains("mermaid")); | |
| let (missing, _) = http(addr, "GET", "/api/entry/mem-me-nothere", None).await; | |
| assert_eq!(missing, 404); | |
| } | |
| #[tokio::test] | |
| async fn the_reader_its_assets_and_a_bundle_are_served() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let m = Memory::open(&dir.path().join("memory")).unwrap(); | |
| let w = m | |
| .write(dm_server::WriteRequest { | |
| title: Some("Retry policy".into()), | |
| body: "Operation R retries with backoff.\n".into(), | |
| ..dm_server::WriteRequest::default() | |
| }) | |
| .await | |
| .unwrap(); | |
| let (status, page) = http(addr, "GET", "/reader", None).await; | |
| assert_eq!(status, 200); | |
| assert!(page.contains("Reader · amem") && page.contains("reader/dm_reader.js")); | |
| let (status, glue) = http(addr, "GET", "/reader/dm_reader.js", None).await; | |
| assert_eq!(status, 200); | |
| assert!(glue.contains("parse_entry")); | |
| let (status, _) = http(addr, "GET", "/reader/dm_reader_bg.wasm", None).await; | |
| assert_eq!(status, 200); | |
| let (status, _) = http(addr, "GET", "/reader/other.js", None).await; | |
| assert_eq!(status, 404); | |
| for v in ["sqlite3.mjs", "sqlite3.wasm"] { | |
| let (status, _) = http(addr, "GET", &format!("/vendor/{v}"), None).await; | |
| assert_eq!(status, 200, "{v}"); | |
| } | |
| let (status, domains) = get_json(addr, "/api/domains").await; | |
| assert_eq!((status, domains), (200, serde_json::json!(["dom-me"]))); | |
| let (status, manifest) = | |
| http(addr, "GET", "/bundle/dom-me/internal/manifest.yaml", None).await; | |
| assert_eq!(status, 200, "{manifest}"); | |
| assert!(manifest.contains("durable-memory/bundle/0") && manifest.contains(&w.id)); | |
| let (status, text) = http( | |
| addr, | |
| "GET", | |
| &format!("/bundle/dom-me/internal/{}.md", w.id), | |
| None, | |
| ) | |
| .await; | |
| assert_eq!(status, 200); | |
| assert!( | |
| text.starts_with("---\n") && text.contains("backoff"), | |
| "{text}" | |
| ); | |
| let (status, public) = http(addr, "GET", "/bundle/dom-me/public/manifest.yaml", None).await; | |
| assert_eq!(status, 200); | |
| assert!( | |
| !public.contains(&w.id), | |
| "an internal entry stays out of a public bundle" | |
| ); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-me/secret/manifest.yaml", None).await; | |
| assert_eq!(status, 400); | |
| let (status, _) = http( | |
| addr, | |
| "GET", | |
| "/bundle/dom-me/internal/..%2Fgrants.toml", | |
| None, | |
| ) | |
| .await; | |
| assert_eq!(status, 404, "a path is not a file name"); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-me/internal/.hidden", None).await; | |
| assert_eq!(status, 404); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-else/internal/manifest.yaml", None).await; | |
| assert_eq!(status, 403, "a domain the owner does not hold"); | |
| } | |
| #[tokio::test] | |
| async fn a_bundle_serves_only_what_its_manifest_lists_under_the_grants() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let (addr, _h) = start(dir.path(), &["true"]).await; | |
| let root = dir.path().join("memory"); | |
| let m = Memory::open(&root).unwrap(); | |
| let w = m | |
| .write(dm_server::WriteRequest { | |
| title: Some("Key rotation".into()), | |
| body: "Rotate the signing key quarterly.\n".into(), | |
| classification: Some(dm_core::Label::Restricted), | |
| ..dm_server::WriteRequest::default() | |
| }) | |
| .await | |
| .unwrap(); | |
| // the grants file sits three directories above a bundle | |
| let (status, body) = http( | |
| addr, | |
| "GET", | |
| "/bundle/dom-me/restricted/..%2F..%2F..%2Fgrants.toml", | |
| None, | |
| ) | |
| .await; | |
| assert_eq!(status, 404); | |
| assert!(!body.contains("owner ="), "a traversal reached the grants"); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-me/restricted/manifest.yaml", None).await; | |
| assert_eq!(status, 200); | |
| let bundle = root.join("state/bundles/dom-me-restricted"); | |
| std::fs::write( | |
| bundle.join("stray.md"), | |
| "---\nid: mem-me-stray\n---\nnot exported\n", | |
| ) | |
| .unwrap(); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-me/restricted/stray.md", None).await; | |
| assert_eq!( | |
| status, 404, | |
| "a file the manifest does not list is not served" | |
| ); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-me/restricted/C:x.md", None).await; | |
| assert_eq!(status, 404, "a drive letter is not a file name"); | |
| let (status, _) = http(addr, "GET", "/bundle/dom-else/restricted/x.md", None).await; | |
| assert_eq!(status, 403, "the domain is checked on every file"); | |
| let file = format!("/bundle/dom-me/restricted/{}.md", w.id); | |
| let (status, text) = http(addr, "GET", &file, None).await; | |
| assert_eq!(status, 200); | |
| assert!(text.contains("quarterly")); | |
| // the handling drops to internal: a second server over the same root must not | |
| // serve the restricted export that is still on disk | |
| let grants = root.join("grants.toml"); | |
| let lowered = std::fs::read_to_string(&grants) | |
| .unwrap() | |
| .replace("handling = \"restricted\"", "handling = \"internal\""); | |
| assert!(lowered.contains("handling = \"internal\"")); | |
| std::fs::write(&grants, lowered).unwrap(); | |
| let app = router(config(dir.path(), &["true"])).unwrap(); | |
| let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| let addr2 = listener.local_addr().unwrap(); | |
| let _h2 = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); | |
| let (status, _) = http(addr2, "GET", &file, None).await; | |
| assert_eq!( | |
| status, 403, | |
| "a stale export above the handling is not served" | |
| ); | |
| let (status, _) = http( | |
| addr2, | |
| "GET", | |
| "/bundle/dom-me/restricted/manifest.yaml", | |
| None, | |
| ) | |
| .await; | |
| assert_eq!(status, 403); | |
| // an export replaces its directory rather than adding to it | |
| let (status, _) = http(addr2, "GET", "/bundle/dom-me/internal/manifest.yaml", None).await; | |
| assert_eq!(status, 200); | |
| let stray = root.join("state/bundles/dom-me-internal/stray.md"); | |
| std::fs::write(&stray, "x").unwrap(); | |
| let (status, _) = http(addr2, "GET", "/bundle/dom-me/internal/manifest.yaml", None).await; | |
| assert_eq!(status, 200); | |
| assert!( | |
| !stray.exists(), | |
| "a bundle directory holds only its last export" | |
| ); | |
| } | |
| } |