OXYGENHUB

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