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:
1 parent
d9237d004e
commit
fe3717f2e4
19 files changed
+465
-53
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cmd;
|
||||
pub mod control_auth;
|
||||
pub mod crash;
|
||||
pub mod frames;
|
||||
pub mod fuzz;
|
||||
|
||||
@@ -46,6 +46,21 @@ pub fn default_dir() -> Option<PathBuf> {
|
||||
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.
|
||||
/// 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) {
|
||||
|
||||
@@ -31,11 +31,22 @@ pub struct Remote {
|
||||
addr: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
/// The app's control token, sent with every request (docs/control-protocol.md).
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
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> {
|
||||
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()?;
|
||||
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()) {
|
||||
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) {
|
||||
Ok(r) => r,
|
||||
// One retry on a fresh connection (the app may have restarted).
|
||||
|
||||
+30
-2
@@ -544,13 +544,17 @@ fn a_bridged_request_carries_the_agents_folders() {
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut 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 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["command"], "file.new");
|
||||
assert_eq!(got["sent_token"], "0123456789abcdef0123456789abcdef");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
@@ -23,13 +23,33 @@ fn obj(props: Value, required: &[&str]) -> Value {
|
||||
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!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"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 \
|
||||
finds that command rather than every command whose docs mention it).",
|
||||
obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]),
|
||||
true,
|
||||
false,
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"execute",
|
||||
@@ -73,8 +92,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&["command"],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
Effect::Files,
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
@@ -95,8 +113,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&["commands"],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
Effect::Files,
|
||||
),
|
||||
tool(
|
||||
"inspect_design",
|
||||
@@ -112,8 +129,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
false,
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"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 \
|
||||
bodies, with totals.",
|
||||
obj(json!({"body": string("Body name (default: all bodies)")}), &[]),
|
||||
true,
|
||||
false,
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"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 \
|
||||
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"]),
|
||||
true,
|
||||
false,
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"set_parameter",
|
||||
@@ -148,8 +162,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&["name", "value"],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
Effect::Edits,
|
||||
),
|
||||
tool(
|
||||
"screenshot",
|
||||
@@ -171,8 +184,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
false,
|
||||
Effect::Files,
|
||||
),
|
||||
tool(
|
||||
"export",
|
||||
@@ -187,8 +199,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
Effect::Files,
|
||||
),
|
||||
tool(
|
||||
"crash_reports",
|
||||
@@ -205,18 +216,16 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
false,
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), false, false),
|
||||
tool("redo", "Redo", "Redo the last undone change.", 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!({}), &[]), Effect::Edits),
|
||||
tool(
|
||||
"new_design",
|
||||
"New design",
|
||||
"Start a new, empty design (unsaved changes to the current one are discarded).",
|
||||
obj(json!({"name": string("Design name (default Untitled)")}), &[]),
|
||||
false,
|
||||
true,
|
||||
Effect::Replaces,
|
||||
),
|
||||
tool(
|
||||
"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 \
|
||||
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"]),
|
||||
false,
|
||||
true,
|
||||
Effect::Files,
|
||||
),
|
||||
tool(
|
||||
"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)"}}),
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
Effect::Files,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user