The live MCP endpoint on 127.0.0.1 was a bypass of the collaboration admission model. Its per-instance token authenticated only the ping probe and shutdown, so document reads and writes were available to any local process, and nothing validated Origin or Host — a page in a browser on the same machine could reach it by DNS rebinding. During a session that is the shared document, not just this user's file. Every stateful call now requires the instance token, compared without an early exit. Host must be a numeric loopback literal on the bound port, and an Origin, when present, must match it; a request with no Origin still works, which is what real CLI clients send. OPTIONS, initialize, and ping stay tokenless so CLI discovery keeps working, and CollabGatePolicy is untouched — this sits in front of it. The `op` CLI did not send the token, so authenticating tool calls would have returned 401 for every `op` invocation against a live editor. The token was already in the port file next to the port; it is now resolved with the port and travels as a header. Ping and shutdown keep their existing tokenless wire contract. Bootstrap cache: reads now degrade like writes already did. An unreadable or corrupt cache leaves this start with no anti-rollback generation floor, which is the position an absent cache has always left it in, and which the threat model already accepts because deleting the file achieves the same thing with no more privilege than corrupting it. Refusing bought no security and cost the ability to collaborate at all. The tests state the price plainly: with no floor the lower-generation document is accepted, and `rollback_floor_armed` has to report it. Threat model: correct an overstatement. Peer admission requires the remote ticket's subject to equal the local account, so the product pairs only devices of one account today. A relay operator reconstructs which devices of an account sync and when — not a cross-account collaboration graph. Also records that the relay reads exactly one field out of the ticket it verifies, the expiry, which makes the identity disclosure gratuitous rather than load-bearing, and states what a minimized credential would and would not buy.
106 lines
3.6 KiB
Rust
106 lines
3.6 KiB
Rust
use std::path::Path;
|
|
|
|
use base64::Engine as _;
|
|
use serde_json::Value;
|
|
|
|
use crate::cli_error::CliError;
|
|
use crate::command_helpers::flag_value;
|
|
use crate::mcp_http_cli::{post, tool_call_body};
|
|
use crate::{Command, Flags};
|
|
|
|
pub(crate) fn map_export(flags: &Flags) -> Result<Command, CliError> {
|
|
let item_id = flag_value(flags, "item");
|
|
let selection = flags.contains_key("selection");
|
|
if item_id.is_some() && selection {
|
|
return Err(CliError::usage(
|
|
"--item and --selection cannot be used together",
|
|
));
|
|
}
|
|
let format_flag = flag_value(flags, "format");
|
|
let formats_flag = flag_value(flags, "formats");
|
|
if let (Some(format), Some(formats)) = (&format_flag, &formats_flag) {
|
|
if format != formats {
|
|
return Err(CliError::usage(
|
|
"--format and --formats cannot specify different values",
|
|
));
|
|
}
|
|
}
|
|
let format = format_flag.or(formats_flag).unwrap_or_else(|| "png".into());
|
|
if !matches!(format.as_str(), "png" | "jpeg" | "jpg" | "webp" | "pdf") {
|
|
return Err(CliError::Usage(format!(
|
|
"unsupported export format {format:?}"
|
|
)));
|
|
}
|
|
let output =
|
|
flag_value(flags, "output").ok_or_else(|| CliError::usage("--output is required"))?;
|
|
let scale = flag_value(flags, "scale");
|
|
if let Some(value) = &scale {
|
|
value
|
|
.parse::<f32>()
|
|
.map_err(|_| CliError::Usage(format!("--scale must be a number, got {value:?}")))?;
|
|
}
|
|
Ok(Command::Export {
|
|
item_id,
|
|
selection,
|
|
output,
|
|
format,
|
|
scale,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn run_export(
|
|
port: u16,
|
|
token: &str,
|
|
item_id: Option<&str>,
|
|
output: &str,
|
|
format: &str,
|
|
scale: Option<&str>,
|
|
) -> Result<String, CliError> {
|
|
let mut arguments = serde_json::Map::new();
|
|
if let Some(item_id) = item_id {
|
|
arguments.insert("itemId".into(), Value::String(item_id.into()));
|
|
}
|
|
arguments.insert("format".into(), Value::String(format.into()));
|
|
if let Some(scale) = scale {
|
|
let scale = scale
|
|
.parse::<f64>()
|
|
.map_err(|_| CliError::Usage(format!("--scale must be a number, got {scale:?}")))?;
|
|
arguments.insert("scale".into(), Value::from(scale));
|
|
}
|
|
let response = post(
|
|
port,
|
|
token,
|
|
&tool_call_body("export_item", &Value::Object(arguments).to_string()),
|
|
)?;
|
|
write_export_response(&response, Path::new(output))
|
|
}
|
|
|
|
pub(crate) fn write_export_response(response: &str, output: &Path) -> Result<String, CliError> {
|
|
let value: Value = serde_json::from_str(response).map_err(|error| {
|
|
CliError::Payload(format!("export_item returned invalid JSON: {error}"))
|
|
})?;
|
|
let encoded = value
|
|
.get("bytes_base64")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CliError::Payload("export_item response is missing bytes_base64".into()))?;
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.map_err(|error| {
|
|
CliError::Payload(format!("export_item returned invalid Base64: {error}"))
|
|
})?;
|
|
std::fs::write(output, bytes).map_err(|error| {
|
|
CliError::Io(format!(
|
|
"cannot write export to {}: {error}",
|
|
output.display()
|
|
))
|
|
})?;
|
|
|
|
Ok(serde_json::json!({
|
|
"output": output.to_string_lossy(),
|
|
"itemId": value.get("itemId").and_then(Value::as_str).unwrap_or(""),
|
|
"itemType": value.get("itemType").and_then(Value::as_str).unwrap_or(""),
|
|
"format": value.get("format").and_then(Value::as_str).unwrap_or(""),
|
|
})
|
|
.to_string())
|
|
}
|