diff --git a/Cargo.lock b/Cargo.lock index 1fc83bc9b..af993fbcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,9 +1604,11 @@ dependencies = [ "figcraft-collab-net", "figcraft-engine", "figcraft-format", + "figcraft-mcp", "figcraft-text", "figcraft-ui-egui", "fmv-macos-events", + "getrandom 0.4.3", "image", "log", "rfd", diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 971b39053..9a4393140 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -8,6 +8,7 @@ mod files; mod perf; use std::io::Write; +use std::path::PathBuf; use std::process::ExitCode; use args::Args; @@ -24,7 +25,7 @@ USAGE: figcraft-cli run [--in ] [--out ] (info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR) figcraft-cli commands [--json] [--filter TEXT] - figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... + figcraft-cli mcp [--attach PORT [--attach-token FILE]] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too) @@ -50,7 +51,9 @@ altogether. They reach the headless side only: `mcp` takes them to confine the a and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is confined by the app it attaches to, not by flags passed to the CLI: start that app with -`figcraft --control PORT` and the same roots (`--attach` refuses them). +`figcraft --control PORT` and the same roots (`--attach` refuses them). The app writes a token for +its control channel to a file in its config folder (`--attach` reads it there); when the app keeps +it elsewhere, such as a Flatpak's sandbox config, pass that file with `--attach-token FILE`. See docs/cli.md and docs/mcp.md."; /// The automation roots a file subcommand was given, if any. Omitting both leaves the run @@ -187,7 +190,7 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); + let valued = [&["attach", "attach-token"][..], &ROOT_FLAGS[..]].concat(); let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); @@ -200,7 +203,7 @@ fn run(argv: &[String]) -> CliResult { .into(), )); } - mcp(attach.as_deref(), roots_of(&a)?) + mcp(attach.as_deref(), a.value("attach-token").as_deref(), roots_of(&a)?) } "plugin" => { let a = parse_args(&rest, &["server"], &[])?; @@ -268,7 +271,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult { Ok(ExitCode::SUCCESS) } -fn mcp(attach: Option<&str>, roots: Option) -> CliResult { +fn mcp(attach: Option<&str>, attach_token: Option<&str>, roots: Option) -> CliResult { let backend: Box = match attach { Some(p) => { let addr = if p.contains(':') { @@ -276,7 +279,15 @@ fn mcp(attach: Option<&str>, roots: Option) } else { format!("127.0.0.1:{}", p.parse::().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?) }; - let b = figcraft_mcp::Attached::connect(&addr) + let port: u16 = addr.rsplit(':').next().and_then(|n| n.parse().ok()).ok_or_else(|| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?; + let token_file = match attach_token { + Some(f) => PathBuf::from(f), + None => figcraft_mcp::token_path(port).ok_or_else(|| "no config folder to find the app's control token in: pass --attach-token FILE".to_string())?, + }; + let token = std::fs::read_to_string(&token_file).map_err(|e| { + format!("can't read the app's control token at {}: {e} (the app writes it when started with `figcraft --control PORT`)", token_file.display()) + })?; + let b = figcraft_mcp::Attached::connect(&addr, token.trim()) .map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?; Box::new(b) } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bd8c09c81..dd8e93d02 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -15,6 +15,7 @@ figcraft-engine = { workspace = true } figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-format = { workspace = true } +figcraft-mcp = { workspace = true } figcraft-text = { workspace = true } # Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the # bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged @@ -28,6 +29,7 @@ rfd = { workspace = true } arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } webbrowser = { workspace = true } image = { workspace = true } +getrandom = "0.4" env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } # The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index ada44c0f7..25ada9c45 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -3,6 +3,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -20,6 +21,8 @@ const TIMEOUT: Duration = Duration::from_secs(60); /// example while the process has no file descriptors left, and retrying at once would keep a /// core busy until it recovers. const ACCEPT_RETRY: Duration = Duration::from_millis(100); +/// Longest first line a client may send to authenticate. +const MAX_AUTH_LINE: usize = 4096; /// Why a request's transport wait ended without a control reply. #[derive(Debug, PartialEq, Eq)] @@ -58,7 +61,82 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec) { target.extend(queue.drain(..count)); } +/// A fresh random token for one launch: 32 bytes, hex. +fn random_token() -> std::io::Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no secure random numbers: {e}")))?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +/// Write `token` to `path`, readable only by this user (the file is re-secured if it existed). +fn write_token(path: &Path, token: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + file.write_all(token.as_bytes()) +} + +/// Equality without an early exit on the first differing byte. +fn same_token(presented: &str, expected: &str) -> bool { + presented.len() == expected.len() && presented.bytes().zip(expected.bytes()).fold(0u8, |d, (a, b)| d | (a ^ b)) == 0 +} + +/// Read the client's first line, which must be `{"id", "method": "auth", "params": {"token"}}` +/// with the launch's token. It is read one byte at a time, so nothing past the line is consumed +/// before [`serve`] reads the rest. +fn authenticate(stream: &mut TcpStream, token: &str) -> std::io::Result<()> { + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + while line.len() <= MAX_AUTH_LINE && stream.read(&mut byte)? == 1 && byte[0] != b'\n' { + line.push(byte[0]); + } + let request = serde_json::from_slice::(&line).ok(); + let id = request.as_ref().and_then(|v| v.get("id").cloned()).unwrap_or(Value::Null); + let presented = request + .as_ref() + .filter(|v| v.get("method").and_then(Value::as_str) == Some("auth")) + .and_then(|v| v.get("params")?.get("token")?.as_str()); + if presented.is_some_and(|t| same_token(t, token)) { + writeln!(stream, "{}", json!({"id": id, "ok": true}))?; + return Ok(()); + } + writeln!(stream, "{}", json!({"id": id, "ok": false, "error": "control token missing or wrong"}))?; + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "control token refused")) +} + pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { + // Fail closed: without a token file the channel stays off rather than open to any local client. + let token = match random_token() { + Ok(token) => token, + Err(e) => { + log::error!("control server disabled: {e}"); + eprintln!("FigCraft control server disabled: {e}"); + return; + } + }; + let Some(path) = figcraft_mcp::token_path(port) else { + log::error!("control server disabled: no config folder for the token"); + eprintln!("FigCraft control server disabled: no config folder for the token"); + return; + }; + if let Err(e) = write_token(&path, &token) { + log::error!("control server disabled: can't write the token to {}: {e}", path.display()); + eprintln!("FigCraft control server disabled: can't write the token to {}: {e}", path.display()); + return; + } let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { @@ -67,11 +145,12 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { return; } }; - eprintln!("FigCraft control server listening on 127.0.0.1:{port}"); + eprintln!("FigCraft control server listening on 127.0.0.1:{port}; token in {}", path.display()); + let token: Arc = Arc::from(token); let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { let mut workers = Vec::new(); for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { - spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT); + spawn_connection(stream, &token, queue.clone(), ctx.clone(), &mut workers, TIMEOUT); } }); if let Err(e) = spawned { @@ -84,13 +163,20 @@ fn accepted(incoming: impl Iterator>, mut wait: imp incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) } -fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) { +fn spawn_connection(stream: TcpStream, token: &Arc, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) { workers.retain(|worker| !worker.is_finished()); if workers.len() >= MAX_CONNECTIONS { return; } + let token = token.clone(); match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || { - let _ = serve(stream, queue, ctx, timeout); + let mut stream = stream; + if stream.set_read_timeout(Some(timeout)).is_err() || stream.set_write_timeout(Some(timeout)).is_err() { + return; + } + if authenticate(&mut stream, &token).is_ok() { + let _ = serve(stream, queue, ctx, timeout); + } }) { Ok(worker) => workers.push(worker), Err(e) => log::error!("control connection thread: {e}"), diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index f29a77736..c0d3eb9d8 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -18,6 +18,10 @@ fn queue() -> Queue { Arc::new(Mutex::new(Vec::new())) } +fn token() -> Arc { + Arc::from("test-token") +} + fn context() -> CtxSlot { Arc::new(Mutex::new(None)) } @@ -198,12 +202,12 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { let mut clients = Vec::new(); for _ in 0..MAX_CONNECTIONS { let (client, server) = pair(); - spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5)); + spawn_connection(server, &token(), pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5)); clients.push(client); } assert_eq!(workers.len(), MAX_CONNECTIONS); let (mut extra, server) = pair(); - spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5)); + spawn_connection(server, &token(), pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5)); assert_eq!(extra.read(&mut [0]).unwrap(), 0); assert_eq!(workers.len(), MAX_CONNECTIONS); for client in clients { @@ -215,7 +219,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { std::thread::sleep(Duration::from_millis(1)); } let (mut client, server) = pair(); - spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5)); + spawn_connection(server, &token(), pending, ctx, &mut workers, Duration::from_secs(5)); assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); @@ -342,3 +346,38 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { worker.join().unwrap().unwrap(); } } + +#[test] +fn authenticate_accepts_only_the_launch_token_on_the_first_line() { + let cases = [ + (r#"{"id":1,"method":"auth","params":{"token":"test-token"}}"#, true), + (r#"{"id":1,"method":"auth","params":{"token":"wrong"}}"#, false), + (r#"{"id":1,"method":"engine.execute","params":{"token":"test-token"}}"#, false), + (r#"{"id":1,"method":"auth"}"#, false), + ("not json", false), + ]; + for (line, accepted) in cases { + let (mut client, mut server) = pair(); + client.write_all(format!("{line}\n").as_bytes()).unwrap(); + assert_eq!(authenticate(&mut server, "test-token").is_ok(), accepted, "{line}"); + assert_eq!(reply(&mut BufReader::new(client))["ok"], accepted, "{line}"); + } +} + +#[test] +fn tokens_are_fresh_hex_and_the_file_is_user_only() { + let token = random_token().unwrap(); + assert_eq!(token.len(), 64); + assert!(token.bytes().all(|b| b.is_ascii_hexdigit())); + assert_ne!(token, random_token().unwrap()); + let dir = std::env::temp_dir().join(format!("figcraft-token-test-{}", std::process::id())); + let path = dir.join("control-1.token"); + write_token(&path, &token).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), token); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + } + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index b83a2664e..2c0ae942d 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -28,13 +28,7 @@ pub struct Desktop { } pub fn config_dir() -> Option { - #[cfg(target_os = "macos")] - let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")); - #[cfg(target_os = "windows")] - let base = std::env::var_os("APPDATA").map(PathBuf::from); - #[cfg(all(unix, not(target_os = "macos")))] - let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))); - base.map(|b| b.join("FigCraft")) + figcraft_mcp::config_dir() } impl Desktop { diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index f2021cc6c..8e0e26739 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -79,21 +79,23 @@ pub fn decode_data(v: &Value) -> Result, String> { /// A running FigCraft app, reached through its loopback control port (`figcraft --control PORT`). pub struct Attached { addr: String, + token: String, conn: Option<(BufReader, TcpStream)>, next_id: u64, } impl Attached { - /// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening. - pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + /// Connect to `addr` (e.g. `127.0.0.1:7979`) with the app's control `token`, failing fast when + /// nothing is listening or the token is refused. + pub fn connect(addr: &str, token: &str) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), token: token.to_string(), conn: None, next_id: 1 }; r.reconnect()?; Ok(r) } /// Connect to `127.0.0.1:port`. - pub fn port(port: u16) -> std::io::Result { - Self::connect(&format!("127.0.0.1:{port}")) + pub fn port(port: u16, token: &str) -> std::io::Result { + Self::connect(&format!("127.0.0.1:{port}"), token) } pub fn addr(&self) -> &str { @@ -110,7 +112,21 @@ impl Attached { // The app answers within 60 s (its own timeout); leave headroom. s.set_read_timeout(Some(Duration::from_secs(90))).ok(); let read = s.try_clone()?; - self.conn = Some((BufReader::new(read), s)); + let mut reader = BufReader::new(read); + let mut writer = s; + // The app's first line must authenticate; it refuses and closes otherwise. + let auth = json!({"id": 0, "method": "auth", "params": {"token": self.token}}).to_string(); + writer.write_all(auth.as_bytes())?; + writer.write_all(b"\n")?; + let mut reply = String::new(); + if reader.read_line(&mut reply)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed during auth")); + } + let ok = serde_json::from_str::(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true); + if !ok { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the app refused the control token")); + } + self.conn = Some((reader, writer)); return Ok(()); } Err(e) => last = e, diff --git a/crates/mcp/src/control_token.rs b/crates/mcp/src/control_token.rs new file mode 100644 index 000000000..d283a3026 --- /dev/null +++ b/crates/mcp/src/control_token.rs @@ -0,0 +1,31 @@ +//! Where the desktop app keeps the token that guards its control channel, so `mcp --attach` can +//! find it. The app writes the file on each `--control` start; only the user can read it. + +use std::path::PathBuf; + +/// The FigCraft config folder: the one the app keeps its preferences in. +pub fn config_dir() -> Option { + #[cfg(target_os = "macos")] + let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")); + #[cfg(target_os = "windows")] + let base = std::env::var_os("APPDATA").map(PathBuf::from); + #[cfg(all(unix, not(target_os = "macos")))] + let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))); + base.map(|b| b.join("FigCraft")) +} + +/// The token file for the control server on `port`. +pub fn token_path(port: u16) -> Option { + config_dir().map(|d| d.join(format!("control-{port}.token"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_token_file_sits_in_the_config_folder_per_port() { + let path = token_path(7979).unwrap(); + assert!(path.ends_with("FigCraft/control-7979.token"), "{}", path.display()); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 8b850a16b..33c5ae1a3 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -14,6 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod backend; +mod control_token; mod headless; mod prompts; mod resources; @@ -24,6 +25,7 @@ mod tools; mod gradient_tests; pub use backend::{Attached, Backend, decode_data, page_children}; +pub use control_token::{config_dir, token_path}; pub use headless::Headless; pub use prompts::{PROMPTS, PromptArg, PromptDef}; pub use resources::{COMMAND_TEMPLATE, COMMANDS_URI, DOC_URI, NODE_TEMPLATE}; diff --git a/crates/mcp/tests/it/attached.rs b/crates/mcp/tests/it/attached.rs index f1343dd25..85de9659c 100644 --- a/crates/mcp/tests/it/attached.rs +++ b/crates/mcp/tests/it/attached.rs @@ -2,17 +2,33 @@ //! session, speaking the desktop app's JSON-lines protocol. use std::io::{BufRead, BufReader, Write}; -use std::net::TcpListener; +use std::net::{TcpListener, TcpStream}; use figcraft_mcp::{Attached, Backend, Headless, Server}; use serde_json::{Value, json}; +const TOKEN: &str = "test-token"; + +/// The app's first-line check: the client must authenticate with the launch token before anything else. +fn authenticate(stream: &TcpStream) -> bool { + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap_or(Value::Null); + let ok = request["method"] == "auth" && request["params"]["token"] == TOKEN; + let mut out = stream.try_clone().unwrap(); + writeln!(out, "{}", json!({"id": request["id"], "ok": ok})).unwrap(); + ok +} + fn fake_app() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { let mut h = Headless::new(); for stream in listener.incoming().flatten() { + if !authenticate(&stream) { + continue; + } let mut out = stream.try_clone().unwrap(); for line in BufReader::new(stream).lines() { let Ok(line) = line else { break }; @@ -40,7 +56,7 @@ fn call(s: &mut Server, tool: &str, args: Value) -> Value { #[test] fn attached_backend_forwards_everything() { let port = fake_app(); - let backend = Attached::port(port).unwrap(); + let backend = Attached::port(port, TOKEN).unwrap(); assert!(backend.describe().contains(&port.to_string())); let mut s = Server::new(Box::new(backend)); let r = call(&mut s, "create_node", json!({"type": "frame", "width": 64, "height": 32, "props": {"fill": "#FF0000"}})); @@ -67,7 +83,7 @@ fn attached_backend_forwards_everything() { #[test] fn attached_to_nothing_fails_cleanly() { let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); - assert!(Attached::port(port).is_err()); + assert!(Attached::port(port, TOKEN).is_err()); } #[test] @@ -80,6 +96,7 @@ fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() { for lose_reply in [true, false] { let (mut stream, _) = listener.accept().unwrap(); stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + assert!(authenticate(&stream)); let mut line = String::new(); BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); let request: Value = serde_json::from_str(&line).unwrap(); @@ -92,7 +109,7 @@ fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() { } methods }); - let mut backend = Attached::port(port).unwrap(); + let mut backend = Attached::port(port, TOKEN).unwrap(); let result = backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}})); assert!(result.is_err(), "a lost reply must not turn into a successful replay: {result:?}"); let error = result.unwrap_err(); @@ -115,6 +132,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() { for _ in 0..2 { let (mut stream, _) = listener.accept().unwrap(); stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + assert!(authenticate(&stream)); let mut line = String::new(); BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); let request: Value = serde_json::from_str(&line).unwrap(); @@ -127,7 +145,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() { } methods }); - let mut backend = Attached::port(port).unwrap(); + let mut backend = Attached::port(port, TOKEN).unwrap(); backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}})).unwrap(); closed_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); std::thread::sleep(std::time::Duration::from_millis(100)); @@ -140,9 +158,21 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() { #[test] fn reconnect_failure_reports_request_not_sent() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let mut backend = Attached::port(listener.local_addr().unwrap().port()).unwrap(); - drop(listener); + let port = listener.local_addr().unwrap().port(); + let app = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + assert!(authenticate(&stream)); + }); + let mut backend = Attached::port(port, TOKEN).unwrap(); + app.join().unwrap(); assert!(backend.call("document.inspect", json!({})).is_err()); let error = backend.call("document.inspect", json!({})).unwrap_err(); assert!(error.contains("request was not sent"), "{error}"); } + +#[test] +fn the_app_refuses_a_wrong_token() { + let port = fake_app(); + let error = Attached::port(port, "wrong").err().expect("a wrong token must be refused"); + assert!(error.to_string().contains("refused"), "{error}"); +} diff --git a/docs/cli.md b/docs/cli.md index 39863bea6..86436eebe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -211,10 +211,11 @@ the flags leave the output identical when nothing strays outside the roots. Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for machines. -### `mcp [--attach PORT] [--automation-read-root DIR]… [--automation-write-root DIR]…` +### `mcp [--attach PORT [--attach-token FILE]] [--automation-read-root DIR]… [--automation-write-root DIR]…` Serve MCP on stdio: headless by default, or attached to a running app started with -`figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing +`figcraft --control PORT`. The app's control token is read from its config folder (`control-PORT.token`); +`--attach-token FILE` names another path, such as a Flatpak's sandbox config folder. See [mcp.md](mcp.md). The automation roots (each repeatable; existing folders) confine the files the agent's calls read (inside a read or write root) and write (inside a write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)). They apply to the headless session only, and that is why `--attach` refuses them: an attached server diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 448a9979d..0d06b69b7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -10,6 +10,16 @@ cargo run -p figcraft -- --control 7979 [file.figcraft|file.svg|image] The app listens on `127.0.0.1:` only. Each connection carries **newline-delimited JSON**: one request object per line, one reply line per request, in order. +**Authentication.** Each launch generates a random token and writes it to `control-.token` in +the FigCraft config folder (`$XDG_CONFIG_HOME/FigCraft`, `~/Library/Application Support/FigCraft`, +or `%APPDATA%\FigCraft`), readable only by the user. A connection's first line must be +`{"id": 0, "method": "auth", "params": {"token": ""}}`; the app answers +`{"id": 0, "ok": true}` and serves the connection, or answers an error and closes it. Any other +first line, including a web page's request, is refused. Clients such as `figcraft-cli mcp --attach` +read the file (`--attach-token FILE` names another path, such as a Flatpak's sandbox config +folder at `~/.var/app/ai.storyteller.figcraft/config/FigCraft/`). A file the app can't write +disables the channel. + ```text request: {"id": 1, "method": "engine.execute", "params": {...}} reply: {"id": 1, "ok": true, "result": ...} diff --git a/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml index 579a5d648..6f5088424 100644 --- a/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml @@ -30,8 +30,10 @@ finish-args: # line, Open Recent, "Save" next to the original) work in the usual places. - --filesystem=xdg-pictures - --filesystem=xdg-documents - # Network: update checks reach GitHub. The optional --control server still listens on - # 127.0.0.1 in the sandbox. Home/host filesystem access is not granted. + # Network: update checks reach GitHub, and host agents reach the optional --control server on + # 127.0.0.1. Each --control launch requires a token in the app's config folder (the sandbox's + # ~/.var/app/ai.storyteller.figcraft/config/FigCraft/control-PORT.token); pass it to + # `figcraft-cli mcp --attach PORT --attach-token FILE`. Home/host filesystem access is not granted. - --share=network modules: - name: figcraft diff --git a/packaging/linux/flatpak/ai.storyteller.figcraft.yml b/packaging/linux/flatpak/ai.storyteller.figcraft.yml index 461176c96..5d368a4a6 100644 --- a/packaging/linux/flatpak/ai.storyteller.figcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.figcraft.yml @@ -35,8 +35,10 @@ finish-args: # line, Open Recent, "Save" next to the original) work in the usual places. - --filesystem=xdg-pictures - --filesystem=xdg-documents - # Network: update checks reach GitHub. The optional --control server still listens on - # 127.0.0.1 in the sandbox. Home/host filesystem access is not granted. + # Network: update checks reach GitHub, and host agents reach the optional --control server on + # 127.0.0.1. Each --control launch requires a token in the app's config folder (the sandbox's + # ~/.var/app/ai.storyteller.figcraft/config/FigCraft/control-PORT.token); pass it to + # `figcraft-cli mcp --attach PORT --attach-token FILE`. Home/host filesystem access is not granted. - --share=network build-options: append-path: /usr/lib/sdk/rust-stable/bin