collective

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

diff --git a/README.md b/README.md--- a/README.md+++ b/README.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 diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -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 |diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs--- a/apps/figcraft-cli/src/main.rs+++ b/apps/figcraft-cli/src/main.rs@@ -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 | --versiondiff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs--- a/apps/figcraft/src/control_server.rs+++ b/apps/figcraft/src/control_server.rs@@ -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}")?;     }diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs--- a/apps/figcraft/src/control_server_tests.rs+++ b/apps/figcraft/src/control_server_tests.rs@@ -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));diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs--- a/apps/figcraft/src/main.rs+++ b/apps/figcraft/src/main.rs@@ -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()diff --git a/crates/collab-net/src/control_auth.rs b/crates/collab-net/src/control_auth.rsnew file mode 100644--- /dev/null+++ b/crates/collab-net/src/control_auth.rs@@ -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"));+    }+}diff --git a/crates/collab-net/src/lib.rs b/crates/collab-net/src/lib.rs--- a/crates/collab-net/src/lib.rs+++ b/crates/collab-net/src/lib.rs@@ -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"))]diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs--- a/crates/mcp/src/backend.rs+++ b/crates/mcp/src/backend.rs@@ -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)diff --git a/crates/mcp/tests/attached.rs b/crates/mcp/tests/attached.rs--- a/crates/mcp/tests/attached.rs+++ b/crates/mcp/tests/attached.rs@@ -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();diff --git a/docs/cli.md b/docs/cli.md--- a/docs/cli.md+++ b/docs/cli.md@@ -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).diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -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" ``` diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -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 ```