Browse / github.com/storytold/figcraft / patch
Authenticate the local control channel
Control channel (--control) accepts commands without authentication Split out of #468. Checked against `main` (`8482c3e`). `apps/figcraft/src/control_server.rs` (around 65–118) listens on loopback and executes any newline-delimited JSON it receives. A line that isn't valid JSON gets an error response, but the connection stays open. Any local process can drive the app while `--control` is on, and a web page sending a request to the loopback port may be able to as well. It's opt-in, so the impact is limited, but there's no key at all. Same class elsewhere: storytold/gridcraft#777, storytold/deckcraft#263, storytold/vectorcraft#2100. WordCraft fixed it with a p
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Skyfuller1303 (imported from pull request #500, open)
- Base
8482c3e85bc5f2a430e2f3be2826674872f6d084- Size
- +383 −91 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2b8cf09736ccad0494dd255c
How it went
No other operator has reported using it yet.
Diff
raw · README.md ROADMAP.md apps/figcraft-cli/src/main.rs apps/figcraft/src/control_server.rs apps/figcraft/src/control_server_tests.rs apps/figcraft/src/main.rs crates/collab-net/src/control_auth.rs crates/collab-net/src/lib.rs crates/mcp/src/backend.rs crates/mcp/tests/attached.rs docs/cli.md docs/control-protocol.md docs/mcp.md
@@ -218,7 +218,7 @@ figcraft-cli mcp figcraft-cli mcp --attach 47800 ``` -The desktop app also offers a loopback-only control channel (`figcraft --control <port>`) for inspecting UI state, driving tools with pointer and key events, and taking screenshots. See [`docs/control-protocol.md`](docs/control-protocol.md), [`docs/mcp.md`](docs/mcp.md) and [`docs/cli.md`](docs/cli.md).+The desktop app also offers a token-authenticated loopback-only control channel (`figcraft --control <port>`, requiring `FIGCRAFT_CONTROL_TOKEN`) for inspecting UI state, driving tools with pointer and key events, and taking screenshots. See [`docs/control-protocol.md`](docs/control-protocol.md), [`docs/mcp.md`](docs/mcp.md) and [`docs/cli.md`](docs/cli.md). ## Under the hood @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (authenticated local control requests (#480); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-15** · Local control authentication (M0.9, #480): enabling the loopback control port now requires `FIGCRAFT_CONTROL_TOKEN`. Every request carries that token and is checked before entering the UI queue; missing or wrong tokens and malformed JSON close the connection. Attached MCP uses the same token, sends it only to loopback destinations, and retains the existing no-replay behavior. The frame, worker, queue and timeout bounds from #213 remain in place. Headless MCP needs no token. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.@@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---|+| 2026-10-15 | minor | Local control authentication (#480): mandatory environment token, checks before dispatch, close on malformed JSON, and authenticated loopback-only Attached MCP. Existing resource limits and no-replay behavior retained; percentages and estimates unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |@@ -23,7 +23,7 @@ USAGE: figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME] figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>] figcraft-cli commands [--json] [--filter TEXT]- figcraft-cli mcp [--attach PORT]+ figcraft-cli mcp [--attach PORT] (FIGCRAFT_CONTROL_TOKEN required for --attach) 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@@ -1,4 +1,4 @@-//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI+//! Localhost JSON-lines control server: each line `{"id", "method", "params", "token"}` is authenticated then queued for the UI //! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it. use std::io::{BufRead, BufReader, Read, Write};@@ -26,7 +26,7 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) { target.extend(queue.drain(..count)); } -pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {+pub fn start(port: u16, token: figcraft_collab_net::control_auth::ControlToken, queue: Queue, ctx: CtxSlot) { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => {@@ -39,7 +39,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { let mut workers = Vec::new(); for stream in listener.incoming().flatten() {- spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);+ spawn_connection(stream, token.clone(), queue.clone(), ctx.clone(), &mut workers, TIMEOUT); } }); if let Err(e) = spawned {@@ -47,20 +47,33 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { } } -fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {+fn spawn_connection(+ stream: TcpStream,+ token: figcraft_collab_net::control_auth::ControlToken,+ queue: Queue,+ ctx: CtxSlot,+ workers: &mut Vec<std::thread::JoinHandle<()>>,+ timeout: Duration,+) { workers.retain(|worker| !worker.is_finished()); if workers.len() >= MAX_CONNECTIONS { return; } match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {- let _ = serve(stream, queue, ctx, timeout);+ let _ = serve(stream, token, queue, ctx, timeout); }) { Ok(worker) => workers.push(worker), Err(e) => log::error!("control connection thread: {e}"), } } -fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {+fn serve(+ mut out: TcpStream,+ token: figcraft_collab_net::control_auth::ControlToken,+ queue: Queue,+ ctx: CtxSlot,+ timeout: Duration,+) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; let mut reader = BufReader::new(out.try_clone()?);@@ -77,41 +90,50 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?; break; }- let Ok(line) = std::str::from_utf8(&line) else { break };+ let Ok(line) = std::str::from_utf8(&line) else {+ writeln!(out, "{}", json!({"id": null, "ok": false, "error": "bad JSON"}))?;+ break;+ }; if line.trim().is_empty() { continue; }- let reply = match serde_json::from_str::<Value>(line) {- Ok(v) => {- let id = v.get("id").cloned().unwrap_or(Value::Null);- let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();- let params = v.get("params").cloned().unwrap_or(json!({}));- let (tx, rx) = std::sync::mpsc::channel();- let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));- let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);- pending.retain(|request| !request.is_cancelled());- if pending.len() >= MAX_QUEUED_REQUESTS {- drop(pending);- writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;- continue;- }- pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });- drop(pending);- if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {- c.request_repaint();- }- match rx.recv_timeout(timeout) {- Ok(r) => match r.result {- Ok(res) => json!({"id": r.id, "ok": true, "result": res}),- Err(e) => json!({"id": r.id, "ok": false, "error": e}),- },- Err(_) => {- cancelled.store(true, std::sync::atomic::Ordering::Relaxed);- json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})- }- }+ let v = match serde_json::from_str::<Value>(line) {+ Ok(v) => v,+ Err(_) => {+ writeln!(out, "{}", json!({"id": null, "ok": false, "error": "bad JSON"}))?;+ break;+ }+ };+ if !v.get("token").and_then(Value::as_str).is_some_and(|presented| token.matches(presented)) {+ writeln!(out, "{}", json!({"id": null, "ok": false, "error": "unauthorized"}))?;+ break;+ }+ let id = v.get("id").cloned().unwrap_or(Value::Null);+ let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();+ let params = v.get("params").cloned().unwrap_or(json!({}));+ let (tx, rx) = std::sync::mpsc::channel();+ let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));+ let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+ pending.retain(|request| !request.is_cancelled());+ if pending.len() >= MAX_QUEUED_REQUESTS {+ drop(pending);+ writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;+ continue;+ }+ pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });+ drop(pending);+ if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {+ c.request_repaint();+ }+ let reply = match rx.recv_timeout(timeout) {+ Ok(r) => match r.result {+ Ok(res) => json!({"id": r.id, "ok": true, "result": res}),+ Err(e) => json!({"id": r.id, "ok": false, "error": e}),+ },+ Err(_) => {+ cancelled.store(true, std::sync::atomic::Ordering::Relaxed);+ json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}) }- Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}), }; writeln!(out, "{reply}")?; }@@ -14,6 +14,10 @@ fn pair() -> (TcpStream, TcpStream) { (client, listener.accept().unwrap().0) } +fn token() -> figcraft_collab_net::control_auth::ControlToken {+ figcraft_collab_net::control_auth::ControlToken::new("test_control_token_with_32_chars").unwrap()+}+ fn queue() -> Queue { Arc::new(Mutex::new(Vec::new())) }@@ -37,7 +41,7 @@ fn rejects_an_oversized_unterminated_frame_without_dispatch() { let (mut client, server) = pair(); let pending = queue(); let q = pending.clone();- let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5))); // Generate bytes as they are written; no giant input string and no terminating newline. std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap(); let mut reader = BufReader::new(client);@@ -52,10 +56,9 @@ fn valid_frames_keep_their_order_ids_and_parameters() { let (mut client, server) = pair(); let pending = queue(); let q = pending.clone();- let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));- client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"},\"token\":\"test_control_token_with_32_chars\"}\r\n").unwrap(); let mut reader = BufReader::new(client);- assert_eq!(reply(&mut reader)["ok"], false); let start = Instant::now(); let r = loop { if let Some(r) = pending.lock().unwrap().pop() {@@ -69,10 +72,119 @@ fn valid_frames_keep_their_order_ids_and_parameters() { assert_eq!(r.params, json!({"command": "shape.rectangle"})); r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap(); assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));+ reader.get_mut().write_all(b"{\"id\":2,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let start = Instant::now();+ let r = loop {+ if let Some(r) = pending.lock().unwrap().pop() {+ break r;+ }+ assert!(start.elapsed() < Duration::from_secs(5));+ std::thread::sleep(Duration::from_millis(1));+ };+ r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Err("ordinary command failure".into()) }).unwrap();+ assert_eq!(reply(&mut reader), json!({"id": 2, "ok": false, "error": "ordinary command failure"}));+ reader.get_mut().write_all(b"{\"id\":3,\"method\":\"ui.inspect\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let start = Instant::now();+ let r = loop {+ if let Some(r) = pending.lock().unwrap().pop() {+ break r;+ }+ assert!(start.elapsed() < Duration::from_secs(5));+ std::thread::sleep(Duration::from_millis(1));+ };+ r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"stillConnected": true})) }).unwrap();+ assert_eq!(reply(&mut reader), json!({"id": 3, "ok": true, "result": {"stillConnected": true}})); reader.get_ref().shutdown(Shutdown::Write).unwrap(); worker.join().unwrap().unwrap(); } +#[test]+fn missing_or_wrong_token_never_dispatches_and_closes() {+ for request in [+ br#"{"id":1,"method":"engine.execute"}"#.as_slice(),+ br#"{"id":1,"method":"engine.execute","token":"wrong_control_token_with_32_chars"}"#.as_slice(),+ ] {+ let (mut client, server) = pair();+ let pending = queue();+ let q = pending.clone();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(request).unwrap();+ client.write_all(b"\n{\"id\":2,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let mut reader = BufReader::new(client);+ assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "unauthorized"}));+ assert_eq!(reader.read(&mut [0]).unwrap(), 0);+ assert!(pending.lock().unwrap().is_empty());+ worker.join().unwrap().unwrap();+ }+}++#[test]+fn a_missing_token_after_an_authenticated_request_is_rejected() {+ let (mut client, server) = pair();+ let pending = queue();+ let q = pending.clone();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(b"{\"id\":1,\"method\":\"ui.inspect\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let mut reader = BufReader::new(client);+ let start = Instant::now();+ let r = loop {+ if let Some(r) = pending.lock().unwrap().pop() {+ break r;+ }+ assert!(start.elapsed() < Duration::from_secs(5));+ std::thread::sleep(Duration::from_millis(1));+ };+ r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();+ assert_eq!(reply(&mut reader)["ok"], true);+ reader.get_mut().write_all(b"{\"id\":2,\"method\":\"engine.execute\"}\n").unwrap();+ assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "unauthorized"}));+ assert_eq!(reader.read(&mut [0]).unwrap(), 0);+ assert!(pending.lock().unwrap().is_empty());+ worker.join().unwrap().unwrap();+}++#[test]+fn malformed_http_preamble_closes_before_later_json() {+ let (mut client, server) = pair();+ let pending = queue();+ let q = pending.clone();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(b"POST / HTTP/1.1\r\n\r\n{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let mut reader = BufReader::new(client);+ assert_eq!(reply(&mut reader)["ok"], false);+ assert_eq!(reader.read(&mut [0]).unwrap(), 0);+ assert!(pending.lock().unwrap().is_empty());+ worker.join().unwrap().unwrap();+}++#[test]+fn invalid_utf8_replies_then_closes_without_dispatch() {+ let (mut client, server) = pair();+ let pending = queue();+ let q = pending.clone();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(b"\xff\n{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let mut reader = BufReader::new(client);+ assert_eq!(reply(&mut reader)["error"], "bad JSON");+ assert_eq!(reader.read(&mut [0]).unwrap(), 0);+ assert!(pending.lock().unwrap().is_empty());+ worker.join().unwrap().unwrap();+}++#[test]+fn malformed_json_replies_then_closes() {+ let (mut client, server) = pair();+ let pending = queue();+ let q = pending.clone();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(b"{bad JSON}\n{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap();+ let mut reader = BufReader::new(client);+ assert_eq!(reply(&mut reader)["error"], "bad JSON");+ assert_eq!(reader.read(&mut [0]).unwrap(), 0);+ assert!(pending.lock().unwrap().is_empty());+ worker.join().unwrap().unwrap();+}+ #[test] fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { let (mut client, server) = pair();@@ -86,8 +198,8 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { drain_queue(&pending, &mut ui_queue); assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS); let q = pending.clone();- let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));- client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ client.write_all(b"{\"id\":1,\"method\":\"engine.execute\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap(); let mut reader = BufReader::new(client); assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"})); assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);@@ -106,7 +218,7 @@ fn idle_and_partial_frame_connections_expire() { let (done, finished) = mpsc::channel(); let pending = queue(); let q = pending.clone();- let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));+ let worker = std::thread::spawn(move || done.send(serve(server, token(), q, context(), Duration::from_millis(50)))); client.write_all(bytes).unwrap(); let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err(); assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));@@ -123,12 +235,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 {@@ -140,7 +252,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);@@ -154,8 +266,8 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() { let pending = queue(); let q = pending.clone(); let (done, finished) = mpsc::channel();- let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));- client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();+ let worker = std::thread::spawn(move || done.send(serve(server, token(), q, context(), Duration::from_millis(100))));+ client.write_all(b"{\"id\":1,\"method\":\"document.inspect\",\"token\":\"test_control_token_with_32_chars\"}\n").unwrap(); let start = Instant::now(); let r = loop { if let Some(r) = pending.lock().unwrap().pop() {@@ -175,8 +287,8 @@ fn a_limit_sized_frame_is_accepted_at_eof() { let (mut client, server) = pair(); let pending = queue(); let q = pending.clone();- let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));- let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";+ let worker = std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5)));+ let message = b"{\"id\":1,\"method\":\"ui.inspect\",\"token\":\"test_control_token_with_32_chars\"}"; client.write_all(message).unwrap(); std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap(); client.shutdown(Shutdown::Write).unwrap();@@ -209,8 +321,8 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { for id in 0..MAX_QUEUED_REQUESTS { let (mut client, server) = pair(); let q = pending.clone();- workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));- writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();+ workers.push(std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(1))));+ writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}, "token": "test_control_token_with_32_chars"})).unwrap(); clients.push(BufReader::new(client)); } let start = Instant::now();@@ -235,8 +347,8 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { })); let (mut client, server) = pair(); let q = pending.clone();- workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));- client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();+ workers.push(std::thread::spawn(move || serve(server, token(), q, context(), Duration::from_secs(5))));+ client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0},\"token\":\"test_control_token_with_32_chars\"}\n").unwrap(); let start = Instant::now(); while !pending.lock().unwrap().iter().any(|r| r.id == 100) { assert!(start.elapsed() < Duration::from_secs(5));@@ -3,8 +3,8 @@ //! Usage: `figcraft [--control <port>] [files…] [figcraft://join?…]` (an invite link joins that //! live session; the app registers itself as the opener of such links: `collab.registerLinks`). //!-//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:-//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`+//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a token-authenticated localhost JSON-lines control server; it requires `FIGCRAFT_CONTROL_TOKEN` in the process environment:+//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}},"token":"<FIGCRAFT_CONTROL_TOKEN>"}` //! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]@@ -107,6 +107,16 @@ fn main() -> eframe::Result { } let args = args::parse(&args); let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));+ let control_token = match port {+ Some(_) => match figcraft_collab_net::control_auth::ControlToken::from_env() {+ Ok(token) => Some(token),+ Err(e) => {+ eprintln!("FigCraft control server not started: {e}");+ std::process::exit(2);+ }+ },+ None => None,+ }; // System fonts: index them in the background, then wake the UI (an idle window draws no frames) // to lay text out again with them. let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));@@ -164,8 +174,8 @@ fn main() -> eframe::Result { figcraft_ui_egui::live::open_invite(&mut fig, link); } let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));- if let Some(p) = port {- control_server::start(p, queue.clone(), ctx_slot.clone());+ if let (Some(p), Some(token)) = (port, control_token) {+ control_server::start(p, token, queue.clone(), ctx_slot.clone()); } let icon = services::window_icon(); let mut viewport = egui::ViewportBuilder::default()new file mode 100644@@ -0,0 +1,90 @@+//! Native authentication for the local desktop control channel.+use std::ffi::OsString;+use std::fmt;+use std::io;++use figcraft_collab::same_secret;++/// A validated control-channel token. Its contents are intentionally absent from Debug output.+#[derive(Clone, PartialEq, Eq)]+pub struct ControlToken(String);++impl fmt::Debug for ControlToken {+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+ f.write_str("ControlToken([REDACTED])")+ }+}++impl ControlToken {+ /// Read and validate the process-scoped control token.+ pub fn from_env() -> io::Result<Self> {+ let value = std::env::var_os("FIGCRAFT_CONTROL_TOKEN");+ Self::from_os_value(value)+ }++ /// Validate an explicit Unicode token.+ pub fn new(value: &str) -> io::Result<Self> {+ validate(value)?;+ Ok(Self(value.to_owned()))+ }++ fn from_os_value(value: Option<OsString>) -> io::Result<Self> {+ let value = value+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "FIGCRAFT_CONTROL_TOKEN is required when the control listener is enabled"))?;+ let value = value.into_string().map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "FIGCRAFT_CONTROL_TOKEN must be Unicode"))?;+ Self::new(&value)+ }++ /// Compare a presented token with this token using the shared secret comparison helper.+ pub fn matches(&self, presented: &str) -> bool {+ same_secret(&self.0, presented)+ }++ /// Return the value for an authenticated protocol request.+ pub fn as_str(&self) -> &str {+ &self.0+ }+}++fn validate(value: &str) -> io::Result<()> {+ if !(22..=128).contains(&value.len()) || !value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {+ return Err(io::Error::new(io::ErrorKind::InvalidInput, "FIGCRAFT_CONTROL_TOKEN must be 22 to 128 URL-safe ASCII characters"));+ }+ Ok(())+}++#[cfg(test)]+mod tests {+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]+ use super::*;++ #[test]+ fn rejects_missing_empty_and_non_unicode_environment_values() {+ assert!(ControlToken::from_os_value(None).is_err());+ assert!(ControlToken::from_os_value(Some(OsString::new())).is_err());+ #[cfg(unix)]+ {+ use std::os::unix::ffi::OsStringExt;+ assert!(ControlToken::from_os_value(Some(OsString::from_vec(vec![0xff]))).is_err());+ }+ #[cfg(windows)]+ {+ use std::os::windows::ffi::OsStringExt;+ assert!(ControlToken::from_os_value(Some(OsString::from_wide(&[0xd800]))).is_err());+ }+ }++ #[test]+ fn accepts_valid_tokens_and_rejects_invalid_tokens() {+ assert!(ControlToken::new("a".repeat(22).as_str()).is_ok());+ assert!(ControlToken::new("a".repeat(128).as_str()).is_ok());+ let invalid = [String::new(), "a".repeat(21), "a".repeat(129), "contains space 1234567890".into(), "unicode-é-123456789012".into()];+ for value in invalid {+ assert!(ControlToken::new(&value).is_err());+ }+ let token = ControlToken::new("a_secure_token_with_32_chars").unwrap();+ assert!(token.matches("a_secure_token_with_32_chars"));+ assert!(!token.matches("another_secure_token_with_32"));+ assert!(!format!("{token:?}").contains("secure"));+ }+}@@ -22,6 +22,8 @@ pub mod limits; mod sessions; pub mod throttle; +#[cfg(not(target_arch = "wasm32"))]+pub mod control_auth; #[cfg(not(target_arch = "wasm32"))] pub mod guest; #[cfg(not(target_arch = "wasm32"))]@@ -93,6 +93,7 @@ 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: figcraft_collab_net::control_auth::ControlToken, conn: Option<(BufReader<TcpStream>, TcpStream)>, next_id: u64, }@@ -100,12 +101,17 @@ pub struct Attached { 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 };+ Self::connect_with_token(addr, figcraft_collab_net::control_auth::ControlToken::from_env()?)+ }++ /// Connect with an explicit validated token.+ pub fn connect_with_token(addr: &str, token: figcraft_collab_net::control_auth::ControlToken) -> std::io::Result<Self> {+ let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 }; r.reconnect()?; Ok(r) } - /// Connect to `127.0.0.1:port`.+ /// Connect to 127.0.0.1:port. pub fn port(port: u16) -> std::io::Result<Self> { Self::connect(&format!("127.0.0.1:{port}")) }@@ -117,7 +123,14 @@ impl Attached { fn reconnect(&mut self) -> std::io::Result<()> { self.conn = None; let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));- for sa in self.addr.to_socket_addrs()? {+ let addresses: Vec<_> = self.addr.to_socket_addrs()?.collect();+ if addresses.is_empty() {+ return Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)));+ }+ if addresses.iter().any(|sa| !sa.ip().is_loopback()) {+ return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "attached control connections require loopback addresses"));+ }+ for sa in addresses { match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { Ok(s) => { s.set_nodelay(true).ok();@@ -165,7 +178,7 @@ impl Backend for Attached { fn call(&mut self, method: &str, params: Value) -> Result<Value, String> { let id = self.next_id; self.next_id += 1;- let line = json!({"id": id, "method": method, "params": params}).to_string();+ let line = json!({"id": id, "method": method, "params": params, "token": self.token.as_str()}).to_string(); if self.conn.is_none() || self.stale() { self.reconnect().map_err(|e| { format!("FigCraft app at {} is not reachable: {e} (request was not sent; start it with `figcraft --control PORT`)", self.addr)@@ -8,6 +8,14 @@ use std::net::TcpListener; use figcraft_mcp::{Attached, Backend, Headless, Server}; use serde_json::{Value, json}; +fn token() -> figcraft_collab_net::control_auth::ControlToken {+ figcraft_collab_net::control_auth::ControlToken::new("test_control_token_with_32_chars").unwrap()+}++fn attach(port: u16) -> Attached {+ Attached::connect_with_token(&format!("127.0.0.1:{port}"), token()).unwrap()+}+ fn fake_app() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port();@@ -18,6 +26,7 @@ fn fake_app() -> u16 { for line in BufReader::new(stream).lines() { let Ok(line) = line else { break }; let v: Value = serde_json::from_str(&line).unwrap_or(Value::Null);+ assert_eq!(v["token"], "test_control_token_with_32_chars"); let method = v["method"].as_str().unwrap_or(""); let reply = match h.call(method, v["params"].clone()) { Ok(r) => json!({"id": v["id"], "ok": true, "result": r}),@@ -41,7 +50,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 = attach(port); 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"}}));@@ -65,10 +74,21 @@ fn attached_backend_forwards_everything() { assert_eq!(err["isError"], true); } +#[test]+fn remote_destinations_are_rejected_before_connecting() {+ let result = Attached::connect_with_token("192.0.2.1:7979", token());+ let error = match result {+ Ok(_) => panic!("remote destination unexpectedly accepted"),+ Err(error) => error,+ };+ assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);+ assert!(error.to_string().contains("loopback"));+}+ #[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::connect_with_token(&format!("127.0.0.1:{port}"), token()).is_err()); } #[test]@@ -84,6 +104,7 @@ fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() { 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();+ assert_eq!(request["token"], "test_control_token_with_32_chars"); let method = request["method"].as_str().unwrap(); methods.push(method.to_string()); let result = h.call(method, request["params"].clone()).unwrap();@@ -93,7 +114,7 @@ fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() { } methods });- let mut backend = Attached::port(port).unwrap();+ let mut backend = attach(port); 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();@@ -119,6 +140,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() { 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();+ assert_eq!(request["token"], "test_control_token_with_32_chars"); let method = request["method"].as_str().unwrap(); methods.push(method.to_string()); let result = h.call(method, request["params"].clone()).unwrap();@@ -128,7 +150,7 @@ fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() { } methods });- let mut backend = Attached::port(port).unwrap();+ let mut backend = attach(port); 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));@@ -141,7 +163,7 @@ 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();+ let mut backend = attach(listener.local_addr().unwrap().port()); drop(listener); assert!(backend.call("document.inspect", json!({})).is_err()); let error = backend.call("document.inspect", json!({})).unwrap_err();@@ -131,7 +131,7 @@ machines. ### `mcp [--attach PORT]` Serve MCP on stdio: headless by default, or attached to a running app started with-`figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish+`figcraft --control PORT`. Attached mode requires the same `FIGCRAFT_CONTROL_TOKEN` in the app and CLI process environments; headless mode remains token-free. See [mcp.md](mcp.md) for setup. The headless session can host, join and publish live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`, `collab.manageFile` for the owner token and `collab.report`) and use plugin directories (`plugin.directory.*`); the one-shot `run` can't (no network link).@@ -3,22 +3,29 @@ The desktop app can be driven by other programs (tests, scripts, `figcraft-cli mcp --attach`, sibling Crafting Apps) through a loopback socket: -```sh+```bash+export FIGCRAFT_CONTROL_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" 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.+The app listens on `127.0.0.1:<port>` only. Starting it with `--control` or `FIGCRAFT_CONTROL_PORT` requires `FIGCRAFT_CONTROL_TOKEN`. The quick start creates a fresh 256-bit token; the app and every attached client must inherit that same value. The token must contain 22–128 URL-safe ASCII characters; the app does not log its value. Do not put it on a command line or use `setx`. Headless MCP mode does not use this token. It protects the listener from local processes without the secret; same-user malware that can read process environments can read it too.++Each connection carries **newline-delimited JSON**:+one request object per line, one reply line per request, in order. The top-level `token` must be present and valid on every request. A missing or wrong token receives a generic `unauthorized` reply and closes the connection; malformed nonempty JSON also closes it after the error reply.++```bash+printf '{"id":1,"method":"ui.inspect","params":{},"token":"%s"}\n' "$FIGCRAFT_CONTROL_TOKEN"+``` ```text-request: {"id": 1, "method": "engine.execute", "params": {...}}+request: {"id": 1, "method": "engine.execute", "params": {...}, "token": "<FIGCRAFT_CONTROL_TOKEN>"} reply: {"id": 1, "ok": true, "result": ...} {"id": 1, "ok": false, "error": "message"} ``` Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is-not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The implementation is in+not JSON gets `{"id": null, "ok": false, "error": "bad JSON"}`. The implementation is in `crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket). A request line is limited to **32 MiB before the newline**, including whitespace. The reader stops@@ -54,10 +61,10 @@ calls never open dialogs: dialog requests are dropped. Returns the command's res commands return `{"id": N}`). ```json-{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} {"id":1,"ok":true,"result":{"id":2}}-{"id":2,"method":"engine.execute","params":{"command":"node.set","params":{"ids":[2],"props":{"fill":"#FF6B6B","cornerRadius":8}}}}-{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":2,"method":"engine.execute","params":{"command":"node.set","params":{"ids":[2],"props":{"fill":"#FF6B6B","cornerRadius":8}}}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}} ``` ### `engine.commands {}`@@ -144,8 +151,8 @@ tools, a press on a frame's or section's name label (as the canvas last drew it, frame's top edge) selects and drags that frame. ```json-{"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}-{"id":5,"method":"ui.pointer","params":{"kind":"drag","x":200,"y":200,"to":[300,260],"shift":true}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":5,"method":"ui.pointer","params":{"kind":"drag","x":200,"y":200,"to":[300,260],"shift":true}} ``` ### `ui.key {key, shift?, alt?, cmd?, ctrl?}`@@ -181,9 +188,9 @@ arrive in the same frame, so send a press and its release (or a double-click's t separate calls. Returns `{queued}`. ```json-{"id":6,"method":"ui.input","params":{"events":[{"move":[1000,315]}]}}-{"id":7,"method":"ui.input","params":{"events":[{"down":[1000,315]}]}}-{"id":8,"method":"ui.input","params":{"events":[{"up":[1000,315]}]}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":6,"method":"ui.input","params":{"events":[{"move":[1000,315]}]}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":7,"method":"ui.input","params":{"events":[{"down":[1000,315]}]}}+{"token":"<FIGCRAFT_CONTROL_TOKEN>", "id":8,"method":"ui.input","params":{"events":[{"up":[1000,315]}]}} ``` ### `ui.menu.invoke {id}`@@ -273,13 +280,17 @@ Close the app (every window, without asking about unsaved changes). ## Example session (shell) -```sh+The app and client inherit the token from this Bash shell. Once the desktop window is ready, continue with the socket commands.++```bash+export FIGCRAFT_CONTROL_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" port=7979-figcraft --control $port &+figcraft --control "$port" &+# Wait until the desktop window is ready before continuing. exec 3<>/dev/tcp/127.0.0.1/$port-echo '{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"preset":"iPhone 16","x":0,"y":0}}}' >&3+printf '{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"preset":"iPhone 16","x":0,"y":0}},"token":"%s"}\n' "$FIGCRAFT_CONTROL_TOKEN" >&3 read -r reply <&3; echo "$reply"-echo '{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/figcraft.png"}}' >&3+printf '{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/figcraft.png"},"token":"%s"}\n' "$FIGCRAFT_CONTROL_TOKEN" >&3 read -r reply <&3; echo "$reply" ``` @@ -17,7 +17,15 @@ The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI: | `figcraft-cli mcp` | **Headless**: an in-process engine session with one untitled document. No window; renders with the CPU renderer. | | `figcraft-cli mcp --attach <port>` | **Attached**: drives a running FigCraft app through its control channel (`figcraft --control <port>`). The user sees every change live. | -`--attach` also takes `host:port`. The control channel only listens on 127.0.0.1.+`--attach` also takes `host:port`. The app listens on 127.0.0.1 only. Attached clients resolve the target once per connection and reject before dialing if any resolved address is non-loopback; remote hosts and DNS names with any remote result are refused.++Attached mode now requires `FIGCRAFT_CONTROL_TOKEN` in both the desktop app and `figcraft-cli` process environments. Set one fresh, random token in the launching shell before starting either process; use at least 128 random bits. For example, PowerShell:++```powershell+$env:FIGCRAFT_CONTROL_TOKEN = python -c "import secrets; print(secrets.token_urlsafe(32))"+```++Keep this variable in that shell's process environment; do not use `setx` or put the token on a command line. The accepted value is 22–128 URL-safe ASCII characters. Headless `figcraft-cli mcp` needs no token. The token prevents access by local processes without it, but same-user malware that can inspect process environments can read it too. Attached calls are never automatically replayed after a transport failure: the app may have applied the request before its reply was lost. The error reports that the operation may have@@ -48,7 +56,7 @@ The Linux AppImage bundles the CLI: `FigCraft.AppImage cli <args>` runs `figcraf ```sh # headless claude mcp add figcraft -- /path/to/figcraft-cli mcp-# drive the running app (start it with: figcraft --control 7979)+# drive the running app (both processes inherit FIGCRAFT_CONTROL_TOKEN; start app with: figcraft --control 7979) claude mcp add figcraft-app -- /path/to/figcraft-cli mcp --attach 7979 ```