fix: control port needs a per-launch token; MCP tool annotations tell the whole truth (#205) (#901)

solidcraft --control PORT writes a random per-launch token to a user-only file (<data>/control/PORT.token; 0700/0600 on Unix, icacls on Windows; SOLIDCRAFT_CONTROL_TOKEN overrides) and refuses any connection whose first request lacks it. mcp --connect, the demo and packaging smoke clients send it. MCP execute/batch/open/save/export/screenshot are annotated destructive and open-world, new_design destructive. Docs: control-protocol.md, mcp.md, AGENTS.md, pending release note in releasing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 18:51:19 -04:00
1 parent d9237d004e
commit fe3717f2e4
19 files changed
+465 -53

No files matched your search

+4 -1
View File
@@ -122,7 +122,10 @@ before the draft is verified.
## Running and looking at the app ## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel). - `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`. Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`): - Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}` - `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}` - `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG. - `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
+4 -1
View File
@@ -99,7 +99,10 @@ before the draft is verified.
## Running and looking at the app ## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel). - `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`. Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`): - Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}` - `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}` - `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG. - `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
Generated
+1
View File
@@ -3286,6 +3286,7 @@ dependencies = [
"eframe", "eframe",
"egui", "egui",
"embed-resource", "embed-resource",
"getrandom 0.3.4",
"hidapi", "hidapi",
"log", "log",
"quick-xml", "quick-xml",
+3 -3
View File
@@ -9,7 +9,7 @@
//! solidcraft-cli commands the command registry as JSON //! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script //! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json //! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] [--allow-path DIR]... [--allow-any-path] //! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! MCP server on stdio (docs/mcp.md) //! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences //! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file) //! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
@@ -44,7 +44,7 @@ const USAGE: &str = "usage:
solidcraft-cli recipe <recipe.json> solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json] solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE] solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT] [--allow-path DIR]... [--allow-any-path] solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]] solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences solidcraft-cli licences
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
@@ -333,7 +333,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
} }
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) { let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()), (Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect(addr).map_err(|e| { (Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect_with_token(addr, flag(args, "--token")).map_err(|e| {
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT"); let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
format!( format!(
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \ "cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
+1
View File
@@ -18,6 +18,7 @@ egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
rfd = { workspace = true } rfd = { workspace = true }
getrandom = "0.3"
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true } quick-xml = { workspace = true }
+119 -5
View File
@@ -9,18 +9,39 @@ use std::time::Duration;
use serde_json::{Value, json}; use serde_json::{Value, json};
use solidcraft_engine::MAX_LINE; use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth;
use solidcraft_ui_egui::ControlRequest; use solidcraft_ui_egui::ControlRequest;
/// Connections served at once. The channel has no authentication beyond being bound to /// Connections served at once, so a flood must not be able to spawn threads without end.
/// loopback, so a flood must not be able to spawn threads without end.
const MAX_CONNECTIONS: usize = 16; const MAX_CONNECTIONS: usize = 16;
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever. /// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
const MAX_BAD_LINES: usize = 8; const MAX_BAD_LINES: usize = 8;
static OPEN: AtomicUsize = AtomicUsize::new(0); static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token file this launch wrote, removed when the app ends.
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).ok()?;
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// Remove this launch's token file (at exit).
pub fn remove_token_file() {
if let Some(p) = TOKEN_FILE.get() {
let _ = std::fs::remove_file(p);
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> { pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>(); let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
let Some(token) = control_auth::env_token().or_else(new_token) else {
eprintln!("solidcraft: control server not started: no random token available");
return rx;
};
let listener = match TcpListener::bind(("127.0.0.1", port)) { let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
@@ -28,7 +49,23 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
return rx; return rx;
} }
}; };
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}"); match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
let _ = TOKEN_FILE.set(path);
}
Err(e) => eprintln!(
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
clients need SOLIDCRAFT_CONTROL_TOKEN",
path.display()
),
},
None => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
}
}
let token = std::sync::Arc::new(token);
std::thread::spawn(move || { std::thread::spawn(move || {
for stream in listener.incoming().flatten() { for stream in listener.incoming().flatten() {
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS { if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
@@ -39,8 +76,9 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
} }
let tx = tx.clone(); let tx = tx.clone();
let ctx = ctx.clone(); let ctx = ctx.clone();
let token = token.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
serve(stream, tx, ctx); serve(stream, &token, tx, ctx);
OPEN.fetch_sub(1, Ordering::Relaxed); OPEN.fetch_sub(1, Ordering::Relaxed);
}); });
} }
@@ -80,11 +118,17 @@ fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>
} }
} }
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) { /// The reply to a connection that didn't show the token; the connection is then closed.
fn unauthorized() -> Value {
json!({"ok": false, "error": "unauthorized: send the control token as \"token\" (see docs/control-protocol.md)"})
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return }; let Ok(read) = stream.try_clone() else { return };
let mut out = stream; let mut out = stream;
let mut reader = BufReader::new(read); let mut reader = BufReader::new(read);
let mut bad = 0usize; let mut bad = 0usize;
let mut authenticated = false;
loop { loop {
let line = match read_line(&mut reader, MAX_LINE) { let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(), Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
@@ -99,6 +143,16 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
} }
let reply = match serde_json::from_str::<Value>(&line) { let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => { Ok(msg) => {
// The first request must carry the token; later ones on the same connection may.
if !authenticated {
match msg.get("token").and_then(Value::as_str) {
Some(t) if control_auth::tokens_match(t, token) => authenticated = true,
_ => {
let _ = writeln!(out, "{}", unauthorized());
break;
}
}
}
let id = msg.get("id").cloned().unwrap_or(Value::Null); let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({})); let params = msg.get("params").cloned().unwrap_or(json!({}));
@@ -113,6 +167,10 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
} }
r r
} }
Err(_) if !authenticated => {
let _ = writeln!(out, "{}", unauthorized());
break;
}
Err(e) => { Err(e) => {
bad += 1; bad += 1;
json!({"ok": false, "error": format!("bad JSON: {e}")}) json!({"ok": false, "error": format!("bad JSON: {e}")})
@@ -131,6 +189,62 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
mod tests { mod tests {
use super::*; use super::*;
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
/// need not be repeated on that connection.
#[test]
fn requests_run_only_after_the_token() {
use std::io::Read;
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = ran.clone();
std::thread::spawn(move || {
for req in rx {
seen.lock().unwrap().push(req.method.clone());
let _ = req.reply.send(json!({"ok": true, "result": null}));
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
}
});
let session = |lines: &[String]| {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
for l in lines {
let _ = writeln!(s, "{l}");
}
let _ = s.shutdown(std::net::Shutdown::Write);
let mut text = String::new();
let _ = s.read_to_string(&mut text);
text.lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect::<Vec<_>>()
};
for first in [
json!({"id": 1, "method": "app.quit", "params": {"force": true}}).to_string(),
json!({"id": 1, "method": "app.quit", "token": "0123456789abcdef0123456789abcdee"}).to_string(),
json!({"id": 1, "method": "app.quit", "token": "0123"}).to_string(),
json!({"id": 1, "method": "app.quit", "token": 7}).to_string(),
"not json".to_string(),
] {
let replies = session(&[first.clone(), json!({"id": 2, "method": "app.quit", "token": token}).to_string()]);
assert_eq!(replies.len(), 1, "{first}: closed after refusing: {replies:?}");
assert!(replies[0]["error"].as_str().is_some_and(|e| e.starts_with("unauthorized")), "{first}: {replies:?}");
}
assert!(ran.lock().unwrap().is_empty(), "nothing ran without the token");
let replies = session(&[
json!({"id": 1, "method": "ui.inspect", "token": token}).to_string(),
json!({"id": 2, "method": "engine.commands"}).to_string(),
]);
assert_eq!(replies.len(), 2, "{replies:?}");
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
assert_eq!(*ran.lock().unwrap(), ["ui.inspect", "engine.commands"]);
}
/// A request longer than the cap is refused instead of allocated. /// A request longer than the cap is refused instead of allocated.
#[test] #[test]
fn lines_past_the_cap_are_refused() { fn lines_past_the_cap_are_refused() {
+4 -2
View File
@@ -286,7 +286,7 @@ fn main() -> eframe::Result {
b.with_x11(); b.with_x11();
})); }));
} }
eframe::run_native( let result = eframe::run_native(
"SolidCraft", "SolidCraft",
options, options,
Box::new(move |cc| { Box::new(move |cc| {
@@ -344,7 +344,9 @@ fn main() -> eframe::Result {
space_mouse, space_mouse,
})) }))
}), }),
) );
control_server::remove_token_file();
result
} }
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop /// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
+131
View File
@@ -0,0 +1,131 @@
//! The control channel's per-launch token (docs/control-protocol.md). `solidcraft --control PORT`
//! makes a random token, writes it to a file only the user can read
//! ([`token_path`]) and refuses every connection whose first request doesn't carry it, so other
//! users on the same machine (loopback is shared, on Windows across remote-desktop sessions too)
//! can't drive the app. Clients find it with [`client_token`].
use std::io::{Error, ErrorKind, Result, Write};
use std::path::{Path, PathBuf};
/// Shortest token accepted from `SOLIDCRAFT_CONTROL_TOKEN` (hex characters).
pub const MIN_TOKEN_LEN: usize = 32;
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
/// (`SOLIDCRAFT_CONTROL_DIR` replaces the folder).
pub fn token_path(port: u16) -> Option<PathBuf> {
let dir = match solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty()) {
Some(d) => PathBuf::from(d),
None => crate::recovery::data_dir()?.join("control"),
};
Some(dir.join(format!("{port}.token")))
}
/// A token set on purpose with `SOLIDCRAFT_CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token() -> Option<String> {
solidcraft_geom::env::var("CONTROL_TOKEN").map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
}
/// The token a client sends to the app on `port`: `explicit` (`--token`), else
/// `SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file.
pub fn client_token(port: u16, explicit: Option<&str>) -> Option<String> {
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
return Some(t.to_string());
}
if let Some(t) = env_token() {
return Some(t);
}
// Read directly, not through `vfs`: the app's own file, outside any agent's folders.
let text = std::fs::read_to_string(token_path(port)?).ok().filter(|t| t.len() <= 4096)?;
Some(text.trim().to_string()).filter(|t| !t.is_empty())
}
/// `a == b`, taking the same time wherever they differ.
pub fn tokens_match(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
/// entry for the user (`icacls`) before the token goes in. A file left by an earlier launch is
/// replaced.
pub fn write_token_file(path: &Path, token: &str) -> Result<()> {
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
std::fs::create_dir_all(dir)?;
let _ = std::fs::remove_file(path);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
#[cfg(windows)]
restrict_to_user(path)?;
f.write_all(token.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()
}
/// Windows: drop inherited entries and grant only the current user full access.
#[cfg(windows)]
fn restrict_to_user(path: &Path) -> Result<()> {
use std::os::windows::process::CommandExt;
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
let status = std::process::Command::new("icacls")
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(0x0800_0000)
.status()?;
if status.success() { Ok(()) } else { Err(Error::other(format!("icacls failed to restrict {}", path.display()))) }
}
/// Removes the token file when the app ends.
pub struct TokenFile(pub PathBuf);
impl Drop for TokenFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_compare_whole() {
assert!(tokens_match("abc123", "abc123"));
assert!(!tokens_match("abc123", "abc124"));
assert!(!tokens_match("abc123", "abc12"));
assert!(!tokens_match("", "a"));
}
/// The token file is the user's alone, and a client reads it back.
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("control/7878.token");
std::fs::create_dir_all(dir.join("control")).unwrap();
std::fs::write(&path, "stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
drop(TokenFile(path.clone()));
assert!(!path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod cmd; pub mod cmd;
pub mod control_auth;
pub mod crash; pub mod crash;
pub mod frames; pub mod frames;
pub mod fuzz; pub mod fuzz;
+15
View File
@@ -46,6 +46,21 @@ pub fn default_dir() -> Option<PathBuf> {
Some(dir) Some(dir)
} }
/// SolidCraft's per-user data folder (the recovery folder's parent unless
/// `SOLIDCRAFT_RECOVERY_DIR` moves that): `%LOCALAPPDATA%\SolidCraft`,
/// `~/Library/Application Support/SolidCraft`, or `$XDG_DATA_HOME/solidcraft`
/// (`~/.local/share/solidcraft`).
pub fn data_dir() -> Option<PathBuf> {
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
if cfg!(windows) {
Some(env("LOCALAPPDATA")?.join("SolidCraft"))
} else if cfg!(target_os = "macos") {
Some(env("HOME")?.join("Library/Application Support/SolidCraft"))
} else {
Some(env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?.join("solidcraft"))
}
}
/// The recovery folder under the per-user data `root`, and the folder earlier releases used. /// The recovery folder under the per-user data `root`, and the folder earlier releases used.
/// Windows and macOS name the app's folder in title case, other systems in lower case. /// Windows and macOS name the app's folder in title case, other systems in lower case.
fn dirs_under(root: &Path, title_case: bool) -> (PathBuf, PathBuf) { fn dirs_under(root: &Path, title_case: bool) -> (PathBuf, PathBuf) {
+17 -2
View File
@@ -31,11 +31,22 @@ pub struct Remote {
addr: String, addr: String,
conn: Option<(BufReader<TcpStream>, TcpStream)>, conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64, next_id: u64,
/// The app's control token, sent with every request (docs/control-protocol.md).
token: Option<String>,
} }
impl Remote { impl Remote {
/// Connect to the app at `addr`, with its control token found the usual way
/// (`SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file for that port).
pub fn connect(addr: &str) -> std::io::Result<Self> { 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, None)
}
/// [`Remote::connect`] with the token given (`--token`); `None` finds it the usual way.
pub fn connect_with_token(addr: &str, token: Option<&str>) -> std::io::Result<Self> {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
let token = solidcraft_engine::control_auth::client_token(port, token);
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, token };
r.reconnect()?; r.reconnect()?;
Ok(r) Ok(r)
} }
@@ -95,7 +106,11 @@ impl Backend for Remote {
if let (Some(scope), Some(o)) = (solidcraft_engine::io::vfs::current_scope(), params.as_object_mut()) { if let (Some(scope), Some(o)) = (solidcraft_engine::io::vfs::current_scope(), params.as_object_mut()) {
o.insert("allow_paths".into(), json!(scope.roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>())); o.insert("allow_paths".into(), json!(scope.roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
} }
let line = json!({"id": id, "method": method, "params": params}).to_string(); let mut msg = json!({"id": id, "method": method, "params": params});
if let Some(t) = &self.token {
msg["token"] = json!(t);
}
let line = msg.to_string();
let reply = match self.roundtrip(&line) { let reply = match self.roundtrip(&line) {
Ok(r) => r, Ok(r) => r,
// One retry on a fresh connection (the app may have restarted). // One retry on a fresh connection (the app may have restarted).
+30 -2
View File
@@ -544,13 +544,17 @@ fn a_bridged_request_carries_the_agents_folders() {
let mut line = String::new(); let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap(); let req: Value = serde_json::from_str(&line).unwrap();
writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": req["params"]})).unwrap(); let mut result = req["params"].clone();
result["sent_token"] = req["token"].clone();
writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": result})).unwrap();
}); });
let mut b = crate::Remote::connect(&addr).unwrap(); // The app's control token goes with the request too (#205).
let mut b = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap();
let dir = std::fs::canonicalize(std::env::temp_dir()).unwrap(); let dir = std::fs::canonicalize(std::env::temp_dir()).unwrap();
let got = scoped(Some(PathScope::new([dir.clone()])), || crate::Backend::call(&mut b, "engine.execute", json!({"command": "file.new"}))).unwrap(); let got = scoped(Some(PathScope::new([dir.clone()])), || crate::Backend::call(&mut b, "engine.execute", json!({"command": "file.new"}))).unwrap();
assert_eq!(got["allow_paths"], json!([dir.to_string_lossy()]), "{got}"); assert_eq!(got["allow_paths"], json!([dir.to_string_lossy()]), "{got}");
assert_eq!(got["command"], "file.new"); assert_eq!(got["command"], "file.new");
assert_eq!(got["sent_token"], "0123456789abcdef0123456789abcdef");
echo.join().unwrap(); echo.join().unwrap();
} }
@@ -862,3 +866,27 @@ fn instructions_name_only_real_tools() {
assert!(tools.iter().any(|t| t == name), "the instructions name `{name}`, which is not a tool"); assert!(tools.iter().any(|t| t == name), "the instructions name `{name}`, which is not a tool");
} }
} }
/// Tool annotations never understate what a tool may do (#205): `screenshot` writes a file when
/// given a `path`, and `execute`, `batch`, `save`, `export` and `open` can overwrite or delete
/// files, so none of them is read-only and all are destructive and reach the file system.
#[test]
fn tool_annotations_tell_the_whole_truth() {
let tools = crate::tool_definitions();
let hints = |name: &str| {
let t = tools.iter().find(|t| t["name"] == name).unwrap_or_else(|| panic!("no tool {name}"));
let a = &t["annotations"];
(a["readOnlyHint"].as_bool().unwrap(), a["destructiveHint"].as_bool().unwrap(), a["openWorldHint"].as_bool().unwrap())
};
for name in ["execute", "batch", "save", "export", "open", "screenshot"] {
assert_eq!(hints(name), (false, true, true), "{name}");
}
for name in ["list_commands", "inspect_design", "measure", "body_topology", "crash_reports"] {
assert_eq!(hints(name), (true, false, false), "{name}");
}
assert_eq!(hints("new_design"), (false, true, false));
for name in ["set_parameter", "undo", "redo"] {
assert_eq!(hints(name), (false, false, false), "{name}");
}
assert!(tools.iter().all(|t| t["annotations"]["readOnlyHint"].is_boolean()), "every tool is annotated");
}
+37 -30
View File
@@ -23,13 +23,33 @@ fn obj(props: Value, required: &[&str]) -> Value {
o o
} }
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool, destructive: bool) -> Value { /// What a tool may do, for the client's approval prompts (MCP tool annotations). Clients that
/// auto-approve on these hints must not be told less than the truth (#205).
#[derive(Clone, Copy)]
enum Effect {
/// Only looks.
ReadOnly,
/// Changes the design in memory, undoably.
Edits,
/// Replaces the design in memory (unsaved changes are lost).
Replaces,
/// May read, write, overwrite or delete files on disk (any command, a file path).
Files,
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, effect: Effect) -> Value {
let (read_only, destructive, open_world) = match effect {
Effect::ReadOnly => (true, false, false),
Effect::Edits => (false, false, false),
Effect::Replaces => (false, true, false),
Effect::Files => (false, true, true),
};
json!({ json!({
"name": name, "name": name,
"title": title, "title": title,
"description": desc, "description": desc,
"inputSchema": schema, "inputSchema": schema,
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": false}, "annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": open_world},
}) })
} }
@@ -49,8 +69,7 @@ pub fn tool_definitions() -> Vec<Value> {
substring `filter` over id, label, tab and panel (not the parameter docs, so a command id \ substring `filter` over id, label, tab and panel (not the parameter docs, so a command id \
finds that command rather than every command whose docs mention it).", finds that command rather than every command whose docs mention it).",
obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]), obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]),
true, Effect::ReadOnly,
false,
), ),
tool( tool(
"execute", "execute",
@@ -73,8 +92,7 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&["command"], &["command"],
), ),
false, Effect::Files,
false,
), ),
tool( tool(
"batch", "batch",
@@ -95,8 +113,7 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&["commands"], &["commands"],
), ),
false, Effect::Files,
false,
), ),
tool( tool(
"inspect_design", "inspect_design",
@@ -112,8 +129,7 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&[], &[],
), ),
true, Effect::ReadOnly,
false,
), ),
tool( tool(
"measure", "measure",
@@ -121,8 +137,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Volume (mm³), surface area (mm²), centre of mass, bounding box and face/edge/vertex counts of one body or all \ "Volume (mm³), surface area (mm²), centre of mass, bounding box and face/edge/vertex counts of one body or all \
bodies, with totals.", bodies, with totals.",
obj(json!({"body": string("Body name (default: all bodies)")}), &[]), obj(json!({"body": string("Body name (default: all bodies)")}), &[]),
true, Effect::ReadOnly,
false,
), ),
tool( tool(
"body_topology", "body_topology",
@@ -130,8 +145,7 @@ pub fn tool_definitions() -> Vec<Value> {
"The edges (index, midpoint, length, endpoints) and faces (index, area, centroid, plane normal) of a body. Use \ "The edges (index, midpoint, length, endpoints) and faces (index, area, centroid, plane normal) of a body. Use \
an edge midpoint as the [x,y,z] point that picks the edge for fillet and chamfer.", an edge midpoint as the [x,y,z] point that picks the edge for fillet and chamfer.",
obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]), obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]),
true, Effect::ReadOnly,
false,
), ),
tool( tool(
"set_parameter", "set_parameter",
@@ -148,8 +162,7 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&["name", "value"], &["name", "value"],
), ),
false, Effect::Edits,
false,
), ),
tool( tool(
"screenshot", "screenshot",
@@ -171,8 +184,7 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&[], &[],
), ),
true, Effect::Files,
false,
), ),
tool( tool(
"export", "export",
@@ -187,8 +199,7 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&["path"], &["path"],
), ),
false, Effect::Files,
false,
), ),
tool( tool(
"crash_reports", "crash_reports",
@@ -205,18 +216,16 @@ pub fn tool_definitions() -> Vec<Value> {
}), }),
&[], &[],
), ),
true, Effect::ReadOnly,
false,
), ),
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), false, false), tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits),
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), false, false), tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits),
tool( tool(
"new_design", "new_design",
"New design", "New design",
"Start a new, empty design (unsaved changes to the current one are discarded).", "Start a new, empty design (unsaved changes to the current one are discarded).",
obj(json!({"name": string("Design name (default Untitled)")}), &[]), obj(json!({"name": string("Design name (default Untitled)")}), &[]),
false, Effect::Replaces,
true,
), ),
tool( tool(
"open", "open",
@@ -225,8 +234,7 @@ pub fn tool_definitions() -> Vec<Value> {
Import base feature) or a 3MF/STL mesh (.3mf/.stl: mesh bodies). Replaces the current design; insert into it \ Import base feature) or a 3MF/STL mesh (.3mf/.stl: mesh bodies). Replaces the current design; insert into it \
instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands.", instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands.",
obj(json!({"path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file")}), &["path"]), obj(json!({"path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file")}), &["path"]),
false, Effect::Files,
true,
), ),
tool( tool(
"save", "save",
@@ -236,8 +244,7 @@ pub fn tool_definitions() -> Vec<Value> {
json!({"path": string("Path to save to (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}), json!({"path": string("Path to save to (default: the current file)"), "format": {"type": "string", "enum": ["container", "json"], "description": "Design encoding (default: JSON for .json, container otherwise)"}}),
&[], &[],
), ),
false, Effect::Files,
false,
), ),
] ]
} }
+39 -3
View File
@@ -6,6 +6,42 @@ line `{"id": …, "ok": true, "result": …}` or `{"id": …, "ok": false, "erro
handled on the UI thread between frames; pointer and keyboard input is injected as real egui handled on the UI thread between frames; pointer and keyboard input is injected as real egui
events, one pointer event per frame. events, one pointer event per frame.
### Token
Every connection must authenticate. At start the app makes a random 256-bit token for this
launch and writes it, as one line of hex, to a file only the user can read:
| System | Token file |
|---|---|
| Linux | `$XDG_DATA_HOME/solidcraft/control/PORT.token` (`~/.local/share/solidcraft/control/PORT.token`) |
| macOS | `~/Library/Application Support/SolidCraft/control/PORT.token` |
| Windows | `%LOCALAPPDATA%\SolidCraft\control\PORT.token` |
On Unix the folder is `0700` and the file `0600`; on Windows the file's inherited permissions are
replaced by a single entry for the user. `SOLIDCRAFT_CONTROL_DIR` moves the folder. The file is
removed when the app exits, and the next launch writes a new token. The token file's path is
printed to stderr at start-up.
The first request on a connection must carry the token as a top-level `"token"` member
(`{"id": 1, "method": "ui.inspect", "params": {}, "token": "…"}`); later requests on the same
connection may leave it out. A first line without the right token is answered with
`{"ok": false, "error": "unauthorized: …"}` and the connection is closed, without running
anything. Tokens are compared in constant time.
`solidcraft-cli mcp --connect 127.0.0.1:PORT` reads the token file for that port by itself
(`--token T` or `SOLIDCRAFT_CONTROL_TOKEN` give one explicitly). For a test harness, or a
sandboxed package whose data folder the client can't see, start the app with
`SOLIDCRAFT_CONTROL_TOKEN` set to at least 32 hex characters (for example
`openssl rand -hex 32`) and the app uses that token instead of making one; the file is still
written. A one-line client in Python:
```python
token = (Path.home() / ".local/share/solidcraft/control/7878.token").read_text().strip()
sock.sendall((json.dumps({"id": 1, "method": "ui.inspect", "params": {}, "token": token}) + "\n").encode())
```
### Limits
The channel is untrusted input. A request line may be at most 16 MiB; a longer one is answered The channel is untrusted input. A request line may be at most 16 MiB; a longer one is answered
with `{"ok": false, "error": "request too large"}` and the connection is closed, as are with `{"ok": false, "error": "request too large"}` and the connection is closed, as are
connections that send eight lines that are not JSON. At most 16 connections are served at once connections that send eight lines that are not JSON. At most 16 connections are served at once
@@ -19,9 +55,9 @@ reads, writes or deletes (command paths such as `file.export`'s `path` or `doc.r
`dir`, `ui.render`, `ui.screenshot`) must then lie inside one of those folders after `..` and `dir`, `ui.render`, `ui.screenshot`) must then lie inside one of those folders after `..` and
symlinks are resolved, or the request fails with `… outside the folders this agent may use …`. symlinks are resolved, or the request fails with `… outside the folders this agent may use …`.
The app adds its own recovery folder and, when it lies safely inside the home folder and not in The app adds its own recovery folder and, when it lies safely inside the home folder and not in
a hidden folder, the open design's folder (whose hidden files stay out of reach). The bridged MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Without a hidden folder, the open design's folder (whose hidden files stay out of reach). The bridged
`allow_paths` file paths are not limited, and the channel itself has no authentication beyond MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Without
listening on loopback only. `allow_paths` file paths are not limited: a client holding the token can do what the user can.
## Engine ## Engine
+14
View File
@@ -19,6 +19,11 @@ solidcraft --control 7878 &
solidcraft-cli mcp --connect 127.0.0.1:7878 solidcraft-cli mcp --connect 127.0.0.1:7878
``` ```
Bridged, the server finds the app's control token in the token file the app wrote for that port
([control-protocol.md](control-protocol.md#token)); `--token T` or `SOLIDCRAFT_CONTROL_TOKEN` give
it explicitly when the app runs in a sandbox whose data folder the server can't see (prefer the
variable: command-line arguments are visible to other users in the process list).
stdout carries only protocol messages; errors go to stderr. Bridged screenshots are written to a stdout carries only protocol messages; errors go to stderr. Bridged screenshots are written to a
temporary file by the app and read back by the server, so both must run on the same machine. temporary file by the app and read back by the server, so both must run on the same machine.
@@ -67,6 +72,15 @@ folder of the design open in it, under the same rules, so give absolute paths: a
resolves against the app's working folder, not the server's. The app's own resolves against the app's working folder, not the server's. The app's own
file dialogs are never limited. file dialogs are never limited.
### Tool annotations
Each tool says what it may do, for clients that ask before running tools (or auto-approve on
these hints). `list_commands`, `inspect_design`, `measure`, `body_topology` and `crash_reports`
are read-only. `set_parameter`, `undo` and `redo` change the design undoably. `new_design`
replaces it (destructive). `execute`, `batch`, `open`, `save`, `export` and `screenshot` (whose
`path` writes a file) can read, write, overwrite or delete files: they are not read-only,
`destructiveHint` and `openWorldHint` are true.
### Client configuration ### Client configuration
Claude Code (project `.mcp.json`, or `claude mcp add solidcraft -- solidcraft-cli mcp`; a setup Claude Code (project `.mcp.json`, or `claude mcp add solidcraft -- solidcraft-cli mcp`; a setup
+16
View File
@@ -98,6 +98,22 @@ new commits were added. An existing tag or published release is never overwritte
version after publication. Do not push the version tag yourself: publishing the draft creates version after publication. Do not push the version tag yourself: publishing the draft creates
it at the draft's target commit. it at the draft's target commit.
## Notes for the next release
Items to carry into the next release's notes (step 3 below), then remove from here:
- **Security (agents and the control channel, [#205](https://github.com/bherbruck/solidcraft/issues/205)).**
Upgrading is recommended for anyone who drives SolidCraft from an AI agent or runs it with
`--control` on a shared machine. MCP tool calls now reach files only in the open design's
folder (inside home, without its hidden files), SolidCraft's recovery folder and folders
given with `--allow-path`; `..`, symlinks that lead out and Windows UNC, device, stream and
short-name paths are refused ([#578](https://github.com/bherbruck/solidcraft/pull/578), thanks
@kravens). `doc.recovery_discard` deletes only real recovery entries. The control port now
needs a per-launch token from a file only the user can read, so other local users can't drive
the app; `solidcraft-cli mcp --connect` finds it by itself, other clients must send it (see
docs/control-protocol.md). MCP tool annotations no longer understate side effects. Reported by
@CalvinEthos.
## Verify and publish ## Verify and publish
When the task authorizes shipping a release, agents should complete these steps without When the task authorizes shipping a release, agents should complete these steps without
+20 -1
View File
@@ -19,6 +19,7 @@ import socket
import subprocess import subprocess
import sys import sys
import time import time
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@@ -63,10 +64,28 @@ class Mcp:
self.p.wait(timeout=10) self.p.wait(timeout=10)
def control_token(port):
"""The app's control token: SOLIDCRAFT_CONTROL_TOKEN, else the token file the app wrote for
this port (docs/control-protocol.md)."""
if os.environ.get("SOLIDCRAFT_CONTROL_TOKEN"):
return os.environ["SOLIDCRAFT_CONTROL_TOKEN"].strip()
if os.environ.get("SOLIDCRAFT_CONTROL_DIR"):
folder = Path(os.environ["SOLIDCRAFT_CONTROL_DIR"])
elif sys.platform == "win32":
folder = Path(os.environ["LOCALAPPDATA"]) / "SolidCraft" / "control"
elif sys.platform == "darwin":
folder = Path.home() / "Library/Application Support/SolidCraft/control"
else:
data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")
folder = Path(data) / "solidcraft" / "control"
return (folder / f"{port}.token").read_text().strip()
def control(port, method, params=None): def control(port, method, params=None):
"""One request on the app's control channel (camera moves).""" """One request on the app's control channel (camera moves)."""
with socket.create_connection(("127.0.0.1", port)) as s: with socket.create_connection(("127.0.0.1", port)) as s:
s.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode()) msg = {"id": 1, "method": method, "params": params or {}, "token": control_token(port)}
s.sendall((json.dumps(msg) + "\n").encode())
buf = b"" buf = b""
while not buf.endswith(b"\n"): while not buf.endswith(b"\n"):
chunk = s.recv(1 << 16) chunk = s.recv(1 << 16)
+4 -1
View File
@@ -28,6 +28,9 @@ work=$(mktemp -d)
export XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" XDG_CACHE_HOME="$work/cache" export XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" XDG_CACHE_HOME="$work/cache"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
unset WGPU_BACKEND WGPU_BACKENDS unset WGPU_BACKEND WGPU_BACKENDS
# The control channel's token (docs/control-protocol.md), set here so this client knows it.
SOLIDCRAFT_CONTROL_TOKEN=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
export SOLIDCRAFT_CONTROL_TOKEN
solidcraft --control 18837 >"$work/app.log" 2>&1 & solidcraft --control 18837 >"$work/app.log" 2>&1 &
app=$! app=$!
cleanup() { cleanup() {
@@ -44,7 +47,7 @@ for ((attempt=0; attempt<300; attempt++)); do
sleep .1 sleep .1
done done
test "$ready" = 1 test "$ready" = 1
for request in '{"id":1,"method":"ui.inspect","params":{}}' '{"id":2,"method":"engine.execute","params":{"command":"solid.box","params":{"length":30,"width":20,"height":10}}}' '{"id":3,"method":"document.inspect","params":{"measure":true}}'; do for request in "{\"id\":1,\"method\":\"ui.inspect\",\"params\":{},\"token\":\"$SOLIDCRAFT_CONTROL_TOKEN\"}" '{"id":2,"method":"engine.execute","params":{"command":"solid.box","params":{"length":30,"width":20,"height":10}}}' '{"id":3,"method":"document.inspect","params":{"measure":true}}'; do
printf '%s\n' "$request" >&3 printf '%s\n' "$request" >&3
IFS= read -r -t 30 reply <&3 IFS= read -r -t 30 reply <&3
printf '%s\n' "$reply" printf '%s\n' "$reply"
+5 -2
View File
@@ -17,6 +17,7 @@ import signal
import socket import socket
import subprocess import subprocess
import sys import sys
import secrets
import tempfile import tempfile
import time import time
@@ -26,8 +27,10 @@ flatpak_smoke = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(flatpak_smoke) _spec.loader.exec_module(flatpak_smoke)
PORTS = {"x11": 18839, "wayland": 18840} PORTS = {"x11": 18839, "wayland": 18840}
# The control channel's token (docs/control-protocol.md), given to the app so this client knows it.
TOKEN = secrets.token_hex(32)
REQUESTS = [ REQUESTS = [
{"id": 1, "method": "ui.inspect", "params": {}}, {"id": 1, "method": "ui.inspect", "params": {}, "token": TOKEN},
{"id": 2, "method": "engine.execute", {"id": 2, "method": "engine.execute",
"params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}}, "params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}},
{"id": 3, "method": "document.inspect", "params": {"measure": True}}, {"id": 3, "method": "document.inspect", "params": {"measure": True}},
@@ -67,7 +70,7 @@ def run_backend(backend, out):
runtime = Path(f"/run/user/{os.getuid()}") runtime = Path(f"/run/user/{os.getuid()}")
if not runtime.is_dir(): if not runtime.is_dir():
raise RuntimeError(f"{runtime} is missing: snaps expect the login session's runtime directory") raise RuntimeError(f"{runtime} is missing: snaps expect the login session's runtime directory")
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime)) env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), SOLIDCRAFT_CONTROL_TOKEN=TOKEN)
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "SOLIDCRAFT_WAYLAND", "SOLVECRAFT_WAYLAND"): for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "SOLIDCRAFT_WAYLAND", "SOLVECRAFT_WAYLAND"):
env.pop(name, None) env.pop(name, None)
# The snap can write to the (non-hidden) home folder, not to the host's /tmp. # The snap can write to the (non-hidden) home folder, not to the host's /tmp.