spectra status, probe and bridge: what the VS Code extension drives (#31) c7161ec1a607
status says what is set up, probe what a server is without trusting it, and bridge signs as the owner for a program that cannot, on loopback, behind a one-run token, refusing web pages, ending with its input. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key dbbfae53ca08
- When
- · 2026-11-03 17:01 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
c7161ec1a607d445a7eeebc53cc41c1546bec3ce99c7a5494a2736efe0b8fbc4- Depends on
- 09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c 98a0fb9f0948 b044d90930d9 b576d05cf862 e071e91a14ef eca5af92279b
- Depended on by
- 862f0db224aa f4b3e9369e28 58e1ce30ae58
- In bookmarks
- main
- Edits
- 18 atoms, 3 tree operations, 10 files
Browse the tree at this change
added crates/spectra-mcp/src/bridge.rs +108 −0
| 1 | + | //! The bridge (spec §18.1): a loopback proxy that signs as the owner, for a |
|
| 2 | + | //! program that cannot sign itself (the VS Code extension). It answers only |
|
| 3 | + | //! requests that carry its per-run token, from its own loopback host and |
|
| 4 | + | //! with no `Origin`, so no web page can use it, and it signs each with the |
|
| 5 | + | //! owner's key before forwarding it to the daemon, wherever that is. |
|
| 6 | + | ||
| 7 | + | use crate::client::{sign_headers, Client}; |
|
| 8 | + | use axum::body::{Body, Bytes}; |
|
| 9 | + | use axum::extract::{Request, State}; |
|
| 10 | + | use axum::http::{header, StatusCode}; |
|
| 11 | + | use axum::response::{IntoResponse, Response}; |
|
| 12 | + | use http_body_util::{BodyExt, Full}; |
|
| 13 | + | use std::sync::Arc; |
|
| 14 | + | ||
| 15 | + | struct Bridge { |
|
| 16 | + | upstream: Client, |
|
| 17 | + | token: String, |
|
| 18 | + | hosts: Vec<String>, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// The bridge's routes, forwarding to `upstream` (which signs) for the |
|
| 22 | + | /// holder of `token`, at `127.0.0.1:port`. |
|
| 23 | + | pub fn router(upstream: Client, token: String, port: u16) -> axum::Router { |
|
| 24 | + | let b = Arc::new(Bridge { upstream, token, hosts: vec![format!("127.0.0.1:{port}"), format!("localhost:{port}")] }); |
|
| 25 | + | axum::Router::new().fallback(forward).with_state(b) |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | /// A fresh random token. |
|
| 29 | + | pub fn token() -> String { |
|
| 30 | + | let mut b = [0u8; 32]; |
|
| 31 | + | rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut b); |
|
| 32 | + | hex::encode(b) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | fn refuse(status: StatusCode, why: &str) -> Response { |
|
| 36 | + | (status, axum::Json(serde_json::json!({ "title": why }))).into_response() |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | async fn forward(State(b): State<Arc<Bridge>>, req: Request) -> Response { |
|
| 40 | + | let h = req.headers(); |
|
| 41 | + | let host = h.get(header::HOST).and_then(|v| v.to_str().ok()).unwrap_or(""); |
|
| 42 | + | if !b.hosts.iter().any(|x| x == host) || h.contains_key(header::ORIGIN) { |
|
| 43 | + | return refuse(StatusCode::FORBIDDEN, "the bridge serves the program that started it, not web pages"); |
|
| 44 | + | } |
|
| 45 | + | let given = h.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).unwrap_or(""); |
|
| 46 | + | if !subtle_eq(given.as_bytes(), b.token.as_bytes()) { |
|
| 47 | + | return refuse(StatusCode::UNAUTHORIZED, "the bridge's token is missing or wrong"); |
|
| 48 | + | } |
|
| 49 | + | let (parts, body) = req.into_parts(); |
|
| 50 | + | let body: Bytes = match body.collect().await { |
|
| 51 | + | Ok(x) => x.to_bytes(), |
|
| 52 | + | Err(_) => return refuse(StatusCode::BAD_REQUEST, "an unreadable body"), |
|
| 53 | + | }; |
|
| 54 | + | let path = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/").to_string(); |
|
| 55 | + | let method = parts.method.as_str().to_string(); |
|
| 56 | + | let mut up = hyper::Request::builder() |
|
| 57 | + | .method(parts.method.clone()) |
|
| 58 | + | .uri(&path) |
|
| 59 | + | .header(header::HOST, "spectra") |
|
| 60 | + | .header(header::CONNECTION, "close"); |
|
| 61 | + | for k in [header::CONTENT_TYPE, header::ACCEPT, header::IF_NONE_MATCH] { |
|
| 62 | + | if let Some(v) = parts.headers.get(&k) { |
|
| 63 | + | up = up.header(k, v); |
|
| 64 | + | } |
|
| 65 | + | } |
|
| 66 | + | if let Some(pair) = b.upstream.signer() { |
|
| 67 | + | for (k, v) in sign_headers(pair, &format!("{method} {path}"), &body) { |
|
| 68 | + | up = up.header(k, v); |
|
| 69 | + | } |
|
| 70 | + | } |
|
| 71 | + | let up = match up.body(Full::new(body)) { |
|
| 72 | + | Ok(r) => r, |
|
| 73 | + | Err(_) => return refuse(StatusCode::BAD_REQUEST, "not a request the bridge can pass on"), |
|
| 74 | + | }; |
|
| 75 | + | match b.upstream.forward(up).await { |
|
| 76 | + | Ok(resp) => { |
|
| 77 | + | let (parts, incoming) = resp.into_parts(); |
|
| 78 | + | let mut out = Response::new(Body::new(incoming)); |
|
| 79 | + | *out.status_mut() = parts.status; |
|
| 80 | + | for (k, v) in parts.headers.iter() { |
|
| 81 | + | if !matches!(k.as_str(), "connection" | "transfer-encoding" | "keep-alive") { |
|
| 82 | + | out.headers_mut().append(k, v.clone()); |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | out |
|
| 86 | + | } |
|
| 87 | + | Err(e) => refuse(StatusCode::BAD_GATEWAY, &format!("Spectra did not answer: {e:#}")), |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | /// Compare in time that does not depend on where the first difference is. |
|
| 92 | + | fn subtle_eq(a: &[u8], b: &[u8]) -> bool { |
|
| 93 | + | if a.len() != b.len() { |
|
| 94 | + | return false; |
|
| 95 | + | } |
|
| 96 | + | a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | #[cfg(test)] |
|
| 100 | + | mod tests { |
|
| 101 | + | #[test] |
|
| 102 | + | fn tokens_are_fresh_and_compared_whole() { |
|
| 103 | + | assert_ne!(super::token(), super::token()); |
|
| 104 | + | assert!(super::subtle_eq(b"abc", b"abc")); |
|
| 105 | + | assert!(!super::subtle_eq(b"abc", b"abd")); |
|
| 106 | + | assert!(!super::subtle_eq(b"abc", b"ab")); |
|
| 107 | + | } |
|
| 108 | + | } |
added src/cli/setup.rs +131 −0
| 1 | + | //! Commands a program uses to set Spectra up and to talk to it (spec |
|
| 2 | + | //! §18.1): `spectra status` says what is set up, `spectra probe` what a |
|
| 3 | + | //! server is before trusting it, and `spectra bridge` signs for a program |
|
| 4 | + | //! that cannot (the VS Code extension). |
|
| 5 | + | ||
| 6 | + | use super::identity::owner_home; |
|
| 7 | + | use super::{ui, Ctx}; |
|
| 8 | + | use anyhow::{bail, Context, Result}; |
|
| 9 | + | use serde_json::{json, Value}; |
|
| 10 | + | use spectra_mcp::client::{host_port, Client, Endpoint}; |
|
| 11 | + | use spectra_mcp::tls::Pinned; |
|
| 12 | + | use std::path::PathBuf; |
|
| 13 | + | ||
| 14 | + | /// `spectra status [--json]`: the identity, the server or local Spectra. |
|
| 15 | + | pub async fn status(ctx: &Ctx, as_json: bool) -> Result<i32> { |
|
| 16 | + | let home = owner_home(ctx, None); |
|
| 17 | + | let identity = spectra_core::keys::KeyPair::load_home(&home).ok(); |
|
| 18 | + | let remote = ctx.remote()?; |
|
| 19 | + | let initialised = ctx.data.join("spectra.db").exists(); |
|
| 20 | + | let serving = initialised && Client::alive(&ctx.socket()).await; |
|
| 21 | + | let reachable = match &remote { |
|
| 22 | + | Some(c) => Client::at(c.endpoint()?, None).send("GET", "/api/v1/health", &[], vec![]).await.is_ok_and(|r| r.status < 500), |
|
| 23 | + | None => serving, |
|
| 24 | + | }; |
|
| 25 | + | let v = json!({ |
|
| 26 | + | "version": env!("SPECTRA_VERSION_LONG"), |
|
| 27 | + | "home": home.display().to_string(), |
|
| 28 | + | "identity": identity.as_ref().map(|p| json!({ "name": p.identity.name, "key": p.key.to_hex() })), |
|
| 29 | + | "server": remote.as_ref().map(|c| json!({ "url": c.server, "instance": c.instance })), |
|
| 30 | + | "local": { "data": ctx.data.display().to_string(), "initialised": initialised, "serving": serving }, |
|
| 31 | + | "reachable": reachable, |
|
| 32 | + | }); |
|
| 33 | + | if as_json { |
|
| 34 | + | println!("{v}"); |
|
| 35 | + | return Ok(0); |
|
| 36 | + | } |
|
| 37 | + | println!("{}", ui::kv("version", env!("SPECTRA_VERSION_LONG"))); |
|
| 38 | + | match &identity { |
|
| 39 | + | Some(p) => println!("{}", ui::kv("you", format!("{}{}{}", ui::author(&p.identity.name), ui::sep(), ui::hash(p.key.short(12))))), |
|
| 40 | + | None => println!("{}", ui::kv("you", ui::warn("no identity yet: spectra identity create --name NAME"))), |
|
| 41 | + | } |
|
| 42 | + | match &remote { |
|
| 43 | + | Some(c) => println!("{}", ui::kv("spectra", format!("{}{}", ui::path(&c.server), if reachable { "" } else { " (not answering)" }))), |
|
| 44 | + | None if initialised => println!( |
|
| 45 | + | "{}", |
|
| 46 | + | ui::kv("spectra", format!("{}{}", ui::path(ctx.data.display()), if serving { " (serving)" } else { " (not serving)" })) |
|
| 47 | + | ), |
|
| 48 | + | None => println!("{}", ui::kv("spectra", ui::warn("none yet: spectra init here, or spectra connect https://HOST:7350"))), |
|
| 49 | + | } |
|
| 50 | + | Ok(0) |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// `spectra probe URL [--json]`: who a server says it is, checked against |
|
| 54 | + | /// its certificate, without trusting it. |
|
| 55 | + | pub async fn probe(url: &str, as_json: bool) -> Result<i32> { |
|
| 56 | + | let addr = host_port(url)?; |
|
| 57 | + | let seen = Pinned::new(None); |
|
| 58 | + | let c = Client::at(Endpoint::Tls { addr: addr.clone(), config: seen.client_config()? }, None); |
|
| 59 | + | let r = c.send("GET", "/api/v1/instance", &[], vec![]).await.with_context(|| format!("reaching https://{addr}"))?; |
|
| 60 | + | if r.status != 200 { |
|
| 61 | + | bail!("https://{addr} answered {} to /api/v1/instance: is it a Spectra?", r.status); |
|
| 62 | + | } |
|
| 63 | + | let answer = r.json()?; |
|
| 64 | + | let (_, pin) = seen.seen().context("the server showed no certificate")?; |
|
| 65 | + | let checked = spectra_mcp::instance::check(&answer, &pin)?; |
|
| 66 | + | let mine = spectra_core::keys::KeyPair::load_home(&spectra_core::keys::default_home()).ok(); |
|
| 67 | + | let waiting = answer.get("named_owner").and_then(Value::as_str).map(String::from); |
|
| 68 | + | let v = json!({ |
|
| 69 | + | "url": format!("https://{addr}"), |
|
| 70 | + | "name": checked.name, |
|
| 71 | + | "instance": checked.instance.hash().to_hex(), |
|
| 72 | + | "owner": checked.owner, |
|
| 73 | + | "waiting_for": waiting, |
|
| 74 | + | "yours": mine.as_ref().is_some_and(|m| { |
|
| 75 | + | let me = m.key.to_hex(); |
|
| 76 | + | checked.owner.as_deref() == Some(me.as_str()) || waiting.as_deref() == Some(me.as_str()) |
|
| 77 | + | || [checked.owner.as_deref(), waiting.as_deref()].into_iter().flatten() |
|
| 78 | + | .any(|k| spectra_core::identity::KeyId::from_hex(k).is_some_and(|k| spectra_core::keys::identity_in(&spectra_core::keys::default_home(), &k).is_some())) |
|
| 79 | + | }), |
|
| 80 | + | "tls_spki": hex::encode(pin), |
|
| 81 | + | }); |
|
| 82 | + | if as_json { |
|
| 83 | + | println!("{v}"); |
|
| 84 | + | } else { |
|
| 85 | + | println!("{}", ui::kv("server", ui::path(v["url"].as_str().unwrap_or("")))); |
|
| 86 | + | println!("{}", ui::kv("name", v["name"].as_str().unwrap_or(""))); |
|
| 87 | + | println!("{}", ui::kv("instance", ui::hash(v["instance"].as_str().unwrap_or("")))); |
|
| 88 | + | let whose = match (v["owner"].as_str(), v["waiting_for"].as_str()) { |
|
| 89 | + | (Some(o), _) => format!("owned by {}", &o[..12.min(o.len())]), |
|
| 90 | + | (None, Some(w)) => format!("waiting to be claimed by {}", &w[..12.min(w.len())]), |
|
| 91 | + | _ => "no owner".into(), |
|
| 92 | + | }; |
|
| 93 | + | println!("{}", ui::kv("owner", whose)); |
|
| 94 | + | } |
|
| 95 | + | Ok(0) |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /// `spectra bridge [--port 0]`: sign as the owner for the program that |
|
| 99 | + | /// started it; prints `{ "url", "token" }` and ends with standard input. |
|
| 100 | + | pub async fn bridge(ctx: &Ctx, port: u16, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 101 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 102 | + | let client = |
|
| 103 | + | ctx.owner_client(&home).await?.context("no Spectra to bridge to: spectra init here, or spectra connect https://HOST:7350")?; |
|
| 104 | + | let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.with_context(|| format!("binding 127.0.0.1:{port}"))?; |
|
| 105 | + | let port = listener.local_addr()?.port(); |
|
| 106 | + | let token = spectra_mcp::bridge::token(); |
|
| 107 | + | println!("{}", json!({ "url": format!("http://127.0.0.1:{port}"), "token": token })); |
|
| 108 | + | use std::io::Write; |
|
| 109 | + | std::io::stdout().flush().ok(); |
|
| 110 | + | let app = spectra_mcp::bridge::router(client, token, port); |
|
| 111 | + | axum::serve(listener, app) |
|
| 112 | + | .with_graceful_shutdown(async { |
|
| 113 | + | // Ends when the program that started it closes our input, or on Ctrl-C. |
|
| 114 | + | let stdin = async { |
|
| 115 | + | let mut buf = [0u8; 256]; |
|
| 116 | + | let mut input = tokio::io::stdin(); |
|
| 117 | + | loop { |
|
| 118 | + | match tokio::io::AsyncReadExt::read(&mut input, &mut buf).await { |
|
| 119 | + | Ok(0) | Err(_) => return, |
|
| 120 | + | Ok(_) => {} |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | }; |
|
| 124 | + | tokio::select! { |
|
| 125 | + | _ = stdin => {} |
|
| 126 | + | _ = tokio::signal::ctrl_c() => {} |
|
| 127 | + | } |
|
| 128 | + | }) |
|
| 129 | + | .await?; |
|
| 130 | + | Ok(0) |
|
| 131 | + | } |
added tests/bridge.rs +87 −0
| 1 | + | //! What the VS Code extension drives (spec §18.1): `spectra status` and |
|
| 2 | + | //! `spectra probe` say what is set up and what a server is, and `spectra |
|
| 3 | + | //! bridge` signs for the extension, refusing anyone without its token and |
|
| 4 | + | //! any web page. |
|
| 5 | + | ||
| 6 | + | mod common; |
|
| 7 | + | ||
| 8 | + | use common::{add_agent, hermetic, run, setup, text, Serving}; |
|
| 9 | + | use serde_json::Value; |
|
| 10 | + | use std::io::{BufRead, BufReader, Read, Write}; |
|
| 11 | + | use std::process::Stdio; |
|
| 12 | + | ||
| 13 | + | fn http(url: &str, method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> (u16, String) { |
|
| 14 | + | let hp = url.strip_prefix("http://").unwrap(); |
|
| 15 | + | let mut s = std::net::TcpStream::connect(hp).unwrap(); |
|
| 16 | + | s.set_read_timeout(Some(std::time::Duration::from_secs(10))).unwrap(); |
|
| 17 | + | let mut req = format!("{method} {path} HTTP/1.1\r\nconnection: close\r\ncontent-length: {}\r\n", body.len()); |
|
| 18 | + | if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("host")) { |
|
| 19 | + | req.push_str(&format!("host: {hp}\r\n")); |
|
| 20 | + | } |
|
| 21 | + | for (k, v) in headers { |
|
| 22 | + | req.push_str(&format!("{k}: {v}\r\n")); |
|
| 23 | + | } |
|
| 24 | + | req.push_str("\r\n"); |
|
| 25 | + | req.push_str(body); |
|
| 26 | + | s.write_all(req.as_bytes()).unwrap(); |
|
| 27 | + | let mut out = String::new(); |
|
| 28 | + | let _ = s.read_to_string(&mut out); |
|
| 29 | + | let status = out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0); |
|
| 30 | + | (status, out.split_once("\r\n\r\n").map(|x| x.1.to_string()).unwrap_or_default()) |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | #[test] |
|
| 34 | + | fn status_says_what_is_set_up() { |
|
| 35 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 36 | + | let d = dir.path(); |
|
| 37 | + | let v: Value = serde_json::from_slice(&run(d, &["status", "--json"]).stdout).unwrap(); |
|
| 38 | + | assert!(v["identity"].is_null() && v["server"].is_null() && v["local"]["initialised"] == false, "{v}"); |
|
| 39 | + | let made: Value = serde_json::from_slice(&run(d, &["identity", "create", "--name", "Andy McCall", "--json"]).stdout).unwrap(); |
|
| 40 | + | assert_eq!(made["key"].as_str().unwrap().len(), 64); |
|
| 41 | + | assert!(run(d, &["init"]).status.success()); |
|
| 42 | + | let v: Value = serde_json::from_slice(&run(d, &["status", "--json"]).stdout).unwrap(); |
|
| 43 | + | assert_eq!(v["identity"]["name"], "Andy McCall", "init made a successor (it delegates to the instance), so the key moved on"); |
|
| 44 | + | assert_ne!(v["identity"]["key"], made["key"]); |
|
| 45 | + | assert_eq!(v["local"]["initialised"], true); |
|
| 46 | + | assert_eq!(v["reachable"], false, "not serving"); |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | #[tokio::test] |
|
| 50 | + | async fn the_bridge_signs_for_its_holder_only() { |
|
| 51 | + | let dir = setup(); |
|
| 52 | + | let d = dir.path(); |
|
| 53 | + | add_agent(d, "claude-oxygen", &[]); |
|
| 54 | + | let srv = Serving::start(d); |
|
| 55 | + | let mut child = hermetic(d).args(["bridge"]).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap(); |
|
| 56 | + | let mut line = String::new(); |
|
| 57 | + | BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap(); |
|
| 58 | + | let hello: Value = serde_json::from_str(&line).unwrap(); |
|
| 59 | + | let (url, token) = (hello["url"].as_str().unwrap().to_string(), hello["token"].as_str().unwrap().to_string()); |
|
| 60 | + | let bearer = format!("Bearer {token}"); |
|
| 61 | + | ||
| 62 | + | // Signed as the owner, so the API answers as for the human. |
|
| 63 | + | let (status, body) = http(&url, "GET", "/api/v1/brief", &[("authorization", &bearer)], ""); |
|
| 64 | + | assert_eq!(status, 200, "{body}"); |
|
| 65 | + | assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["actor"], "human"); |
|
| 66 | + | let (status, body) = http( |
|
| 67 | + | &url, |
|
| 68 | + | "PUT", |
|
| 69 | + | "/api/v1/briefs", |
|
| 70 | + | &[("authorization", &bearer), ("content-type", "application/json")], |
|
| 71 | + | r#"{"text":"From the editor."}"#, |
|
| 72 | + | ); |
|
| 73 | + | assert_eq!(status, 200, "{body}"); |
|
| 74 | + | assert!(text(&run(d, &["brief", "show"])).contains("From the editor.")); |
|
| 75 | + | ||
| 76 | + | // No token, a wrong one, a web page's Origin, or another Host: refused. |
|
| 77 | + | assert_eq!(http(&url, "GET", "/api/v1/brief", &[], "").0, 401); |
|
| 78 | + | assert_eq!(http(&url, "GET", "/api/v1/brief", &[("authorization", "Bearer nope")], "").0, 401); |
|
| 79 | + | assert_eq!(http(&url, "GET", "/api/v1/brief", &[("authorization", &bearer), ("origin", "http://evil.example")], "").0, 403); |
|
| 80 | + | assert_eq!(http(&url, "GET", "/api/v1/brief", &[("authorization", &bearer), ("host", "evil.example")], "").0, 403); |
|
| 81 | + | ||
| 82 | + | // It ends when its input closes. |
|
| 83 | + | drop(child.stdin.take()); |
|
| 84 | + | let status = tokio::task::spawn_blocking(move || child.wait()).await.unwrap().unwrap(); |
|
| 85 | + | assert!(status.success()); |
|
| 86 | + | srv.stop(); |
|
| 87 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: `spectra status`, `spectra probe` and `spectra bridge` (#31).** `status [--json]` says what is set up here: your identity, and the Spectra you use and whether it answers. `probe URL [--json]` says what a server is (its name, instance key and owner, or the identity it waits for) without trusting it. `bridge` serves a loopback address that signs requests as you, for a program that cannot sign itself: it prints its address and a one-run token, refuses anything without the token or from a web page, passes streams through, and ends when its input closes. `identity create --json` prints the new key. This is how the VS Code extension sets Spectra up and talks to it. |
|
| 5 | 6 | - **Added: briefs (#29).** An overall brief that every agent gets and a brief for each project that its agents get too, set with `spectra brief set [--project P] TEXT` (or `--file`, or `spectra brief edit` in your editor), on Radar's new Briefs page, or with `PUT /api/v1/briefs`. Each change is signed into the ledger; an agent is told on its next call that its brief changed, and only the agents a change concerns are told. A project's channel shows its brief. Briefs replace the house-rules file (`rules/site.md`), which is no longer read. |
|
| 6 | 7 | - **Added: Radar's Constellation (#30).** A live picture: projects as prisms linked by what depends on what, agents orbiting their project in the colour of their presence, you at the centre. Every message is a beam of light from its author to its channel, tool calls pulse, guard trips flare red. Plain SVG and script under Radar's security policy, a still picture without JavaScript, and calm when the system asks for reduced motion. |
|
| 7 | 8 | ## 0.1.0 (2026-11-03): Talk |
|
| … | 28 unchanged lines |
modified crates/spectra-mcp/src/client.rs +5 −0
| … | 80 unchanged lines |
||
| 81 | 81 | &self.endpoint |
|
| 82 | 82 | } |
|
| 83 | 83 | ||
| 84 | + | /// The key this client signs with, if any. |
|
| 85 | + | pub fn signer(&self) -> Option<&KeyPair> { |
|
| 86 | + | self.signer.as_ref() |
|
| 87 | + | } |
|
| 88 | + | ||
| 84 | 89 | async fn io(&self) -> Result<Box<dyn Io>> { |
|
| 85 | 90 | Ok(match &self.endpoint { |
|
| 86 | 91 | Endpoint::Unix(socket) => Box::new( |
|
| … | 135 unchanged lines |
modified crates/spectra-mcp/src/lib.rs +1 −0
| … | 2 unchanged lines |
||
| 3 | 3 | //! every request with their own key (spec §8.5). |
|
| 4 | 4 | ||
| 5 | 5 | pub mod api; |
|
| 6 | + | pub mod bridge; |
|
| 6 | 7 | pub mod client; |
|
| 7 | 8 | pub mod http; |
|
| 8 | 9 | pub mod instance; |
|
| … | 16 unchanged lines |
modified docs/plan.md +1 −1
| … | 58 unchanged lines |
||
| 59 | 59 | ||
| 60 | 60 | - [x] #29 Briefs — done 2026-11-03: brief.set events (overall and per project), compiled per agent with the change line, spectra brief show|set|edit, GET/PUT /api/v1/briefs, Radar's Briefs page and the brief on a project's channel; tests/briefs.rs. |
|
| 61 | 61 | - [x] #30 Constellation — done 2026-11-03: /constellation with a still SVG and constellation.js animating the stream (beams, pulses, flares, orbits by presence); checked in Chrome on a demo with five agents chattering. |
|
| 62 | - | - [ ] #31 spectra bridge and spectra probe, and --json for setup. |
|
| 62 | + | - [x] #31 bridge, probe, status — done 2026-11-03: spectra-mcp::bridge (token, loopback Host, no Origin, ends with stdin), spectra status --json, spectra probe --json, identity create --json; tests/bridge.rs and a probe check in tests/connect.rs. |
|
| 63 | 63 | - [ ] spectra-vscode#6 0.1 Just works: the walkthrough, the bridge client, chat, briefs. |
|
| 64 | 64 | - [ ] #32 Acceptance: a new project set up and chatting from VS Code alone. |
|
| 65 | 65 | ||
| … | 10 unchanged lines |
modified src/cli/identity.rs +5 −1
| … | 214 unchanged lines |
||
| 215 | 215 | } |
|
| 216 | 216 | ||
| 217 | 217 | /// `spectra identity create`: the owner's own key pair (spec §8.0). |
|
| 218 | - | pub fn create(name: &str, contact: &str, home: Option<PathBuf>) -> Result<i32> { |
|
| 218 | + | pub fn create(name: &str, contact: &str, home: Option<PathBuf>, as_json: bool) -> Result<i32> { |
|
| 219 | 219 | let home = home.unwrap_or_else(spectra_core::keys::default_home); |
|
| 220 | 220 | let pair = KeyPair::create_home(&home, name.trim(), contact.trim())?; |
|
| 221 | + | if as_json { |
|
| 222 | + | println!("{}", serde_json::json!({ "name": pair.identity.name, "key": pair.key.to_hex(), "home": home.display().to_string() })); |
|
| 223 | + | return Ok(0); |
|
| 224 | + | } |
|
| 221 | 225 | println!("{} {} {}", ui::done("Created"), ui::subject("your identity"), ui::path(home.display())); |
|
| 222 | 226 | println!("{}", ui::kv("name", ui::author(&pair.identity.name))); |
|
| 223 | 227 | println!("{}", ui::kv("key", ui::hash(pair.key.to_hex()))); |
|
| … | 63 unchanged lines |
modified src/cli/mod.rs +27 −1
| … | 13 unchanged lines |
||
| 14 | 14 | pub mod open; |
|
| 15 | 15 | pub mod project; |
|
| 16 | 16 | pub mod serve; |
|
| 17 | + | pub mod setup; |
|
| 17 | 18 | pub mod ui; |
|
| 18 | 19 | ||
| 19 | 20 | use anyhow::{bail, Context, Result}; |
|
| … | 51 unchanged lines |
||
| 71 | 72 | #[arg(long)] |
|
| 72 | 73 | owner_home: Option<PathBuf>, |
|
| 73 | 74 | }, |
|
| 75 | + | /// What is set up here: your identity, and the Spectra you use |
|
| 76 | + | Status { |
|
| 77 | + | #[arg(long)] |
|
| 78 | + | json: bool, |
|
| 79 | + | }, |
|
| 80 | + | /// What a server is (its name and instance key), without trusting it |
|
| 81 | + | Probe { |
|
| 82 | + | /// The server, https://HOST[:PORT] |
|
| 83 | + | url: String, |
|
| 84 | + | #[arg(long)] |
|
| 85 | + | json: bool, |
|
| 86 | + | }, |
|
| 87 | + | /// Sign as you for a program that cannot (the VS Code extension): a |
|
| 88 | + | /// loopback proxy that ends when its input closes |
|
| 89 | + | Bridge { |
|
| 90 | + | #[arg(long, default_value_t = 0)] |
|
| 91 | + | port: u16, |
|
| 92 | + | #[arg(long)] |
|
| 93 | + | owner_home: Option<PathBuf>, |
|
| 94 | + | }, |
|
| 74 | 95 | /// What the agents are told: the overall brief and each project's |
|
| 75 | 96 | Brief { |
|
| 76 | 97 | #[command(subcommand)] |
|
| … | 122 unchanged lines |
||
| 199 | 220 | /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra) |
|
| 200 | 221 | #[arg(long)] |
|
| 201 | 222 | home: Option<PathBuf>, |
|
| 223 | + | #[arg(long)] |
|
| 224 | + | json: bool, |
|
| 202 | 225 | }, |
|
| 203 | 226 | /// The owner and the instance as the ledger knows them |
|
| 204 | 227 | Show { |
|
| … | 361 unchanged lines |
||
| 566 | 589 | let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) }; |
|
| 567 | 590 | match cli.command { |
|
| 568 | 591 | Command::Version { json } => version(json), |
|
| 592 | + | Command::Status { json } => runtime()?.block_on(setup::status(&ctx, json)), |
|
| 593 | + | Command::Probe { url, json } => runtime()?.block_on(setup::probe(&url, json)), |
|
| 594 | + | Command::Bridge { port, owner_home } => runtime()?.block_on(setup::bridge(&ctx, port, owner_home)), |
|
| 569 | 595 | Command::Brief { command } => runtime()?.block_on(async { |
|
| 570 | 596 | match command { |
|
| 571 | 597 | BriefCmd::Show { project, json } => brief::show(&ctx, project, json).await, |
|
| … | 11 unchanged lines |
||
| 583 | 609 | Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), |
|
| 584 | 610 | Command::Identity { command } => runtime()?.block_on(async { |
|
| 585 | 611 | match command { |
|
| 586 | - | IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home), |
|
| 612 | + | IdentityCmd::Create { name, contact, home, json } => identity::create(&name, &contact, home, json), |
|
| 587 | 613 | IdentityCmd::Show { json } => identity::show(&ctx, json).await, |
|
| 588 | 614 | IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, |
|
| 589 | 615 | } |
|
| … | 68 unchanged lines |
modified tests/connect.rs +6 −0
| … | 48 unchanged lines |
||
| 49 | 49 | // a new KeyId ... |
|
| 50 | 50 | let instance = KeyPair::instance(&s.join("data"), "earth").unwrap(); |
|
| 51 | 51 | let before = KeyPair::load_home(&c.join("owner")).unwrap(); |
|
| 52 | + | // `probe` says what the server is, without trusting it. |
|
| 53 | + | let p: serde_json::Value = serde_json::from_slice(&run(c, &["probe", &url, "--json"]).stdout).unwrap(); |
|
| 54 | + | assert_eq!(p["instance"], instance.key.to_hex()); |
|
| 55 | + | assert_eq!(p["waiting_for"], before.key.to_hex()); |
|
| 56 | + | assert_eq!(p["yours"], true, "{p}"); |
|
| 57 | + | assert!(!c.join("owner/client.toml").exists(), "probing trusts nothing"); |
|
| 52 | 58 | // ... and the server is trusted, pinned, and claimed as theirs. |
|
| 53 | 59 | let o = run(c, &["connect", &url, "--expect", &instance.key.short(16)]); |
|
| 54 | 60 | assert!(o.status.success(), "{}", text(&o)); |
|
| … | 47 unchanged lines |