mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 03:23:20 +00:00
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:
14 files changed
+267
-39
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
Reference in new issue
Block a user