Require a per-launch token on the --control channel

The control server listens on 127.0.0.1, which host processes (and, in a Flatpak with network
access, the host network) can reach. Each --control launch now generates a random token, writes it
to control-<port>.token in the FigCraft config folder (user-readable only) and requires the first
line of every connection to authenticate with it. figcraft-cli mcp --attach reads the file and
authenticates on every (re)connect; --attach-token FILE names another path, such as a Flatpak's
sandbox config folder. The app disables the channel if it can't write the token.
This commit is contained in:
Dylan M. Taylor committed 2026-10-28 13:45:12 -04:00
1 parent 32e8fd0b29
commit c59b50da54
14 files changed
+267 -39

No files matched your search

Generated
+2
View File
@@ -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",
+17 -6
View File
@@ -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 <script.json|script.jsonl> [--in <file>] [--out <file>]
(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 <folder> --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<ExitCode> {
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<ExitCode> {
.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<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
fn mcp(attach: Option<&str>, attach_token: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
let addr = if p.contains(':') {
@@ -276,7 +279,15 @@ fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>)
} else {
format!("127.0.0.1:{}", p.parse::<u16>().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)
}
+2
View File
@@ -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
+90 -4
View File
@@ -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<ControlRequest>) {
target.extend(queue.drain(..count));
}
/// A fresh random token for one launch: 32 bytes, hex.
fn random_token() -> std::io::Result<String> {
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::<Value>(&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<str> = 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<T>(incoming: impl Iterator<Item = std::io::Result<T>>, 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<std::thread::JoinHandle<()>>, timeout: Duration) {
fn spawn_connection(stream: TcpStream, token: &Arc<str>, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, 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}"),
+42 -3
View File
@@ -18,6 +18,10 @@ fn queue() -> Queue {
Arc::new(Mutex::new(Vec::new()))
}
fn token() -> Arc<str> {
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();
}
+1 -7
View File
@@ -28,13 +28,7 @@ pub struct Desktop {
}
pub fn config_dir() -> Option<PathBuf> {
#[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 {
+22 -6
View File
@@ -79,21 +79,23 @@ pub fn decode_data(v: &Value) -> Result<Vec<u8>, 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>, 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<Self> {
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<Self> {
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> {
Self::connect(&format!("127.0.0.1:{port}"))
pub fn port(port: u16, token: &str) -> std::io::Result<Self> {
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::<Value>(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,
+31
View File
@@ -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<PathBuf> {
#[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<PathBuf> {
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());
}
}
+2
View File
@@ -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};
+37 -7
View File
@@ -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}");
}
+3 -2
View File
@@ -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
+10
View File
@@ -10,6 +10,16 @@ cargo run -p figcraft -- --control 7979 [file.figcraft|file.svg|image]
The app listens on `127.0.0.1:<port>` 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-<port>.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": "<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": ...}
@@ -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
@@ -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