OXYGENHUB
◆ v0.1.0 spectra / tests / client.rs
9.0 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
67bfa06a Andy McCall ↶ 1
//! The whole command line from a workstation (spec §8.6, spectra#21 and
2
//! #23): the daemon on one machine, the owner, their agents and every
3
//! command on another, with no ledger file on the workstation at all.
4
5
mod common;
6
98a0fb9f Andy McCall ↶ 7
use common::{add_agent, hermetic, run, text, Serving};
67bfa06a Andy McCall ↶ 8
use serde_json::{json, Value};
9
use spectra_core::keys::KeyPair;
10
use std::path::Path;
11
use std::process::Stdio;
12
use std::time::Duration;
13
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14
15
fn json_of(d: &Path, args: &[&str]) -> Value {
16
    let o = run(d, args);
17
    assert!(o.status.success(), "{args:?}: {}", text(&o));
18
    serde_json::from_slice(&o.stdout)
19
        .unwrap_or_else(|e| panic!("{args:?}: one JSON document ({e}): {}", String::from_utf8_lossy(&o.stdout)))
20
}
21
22
/// A server in `s`, claimed by the owner whose home is `c/owner`.
23
fn connected(s: &Path, c: &Path) -> (Serving, u16) {
24
    KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap();
25
    let owner = KeyPair::load_home(&c.join("owner")).unwrap();
26
    assert!(run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]).status.success());
27
    let (srv, port) = Serving::start_tls(s);
28
    let url = format!("https://127.0.0.1:{port}");
98a0fb9f Andy McCall ↶ 29
    let o = run(c, &["connect", &url, "--yes"]);
67bfa06a Andy McCall ↶ 30
    assert!(o.status.success(), "{}", text(&o));
31
    (srv, port)
32
}
33
34
#[tokio::test]
35
async fn every_command_works_from_another_machine() {
36
    let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
37
    let (s, c) = (sd.path(), cd.path());
38
    let (srv, _port) = connected(s, c);
39
40
    std::fs::create_dir_all(c.join("oxygen")).unwrap();
41
    let o = run(c, &["project", "add", c.join("oxygen").to_str().unwrap()]);
42
    assert!(o.status.success(), "{}", text(&o));
43
    add_agent(c, "claude-oxygen", &[]);
44
    add_agent(c, "spare", &[]);
45
46
    let id = json_of(c, &["identity", "show", "--json"]);
47
    assert_eq!(id["instance"]["name"], "earth");
48
    assert_eq!(id["instance"]["delegated"], true, "{id}");
49
    let agents = json_of(c, &["agent", "list", "--json"]);
50
    let claude = agents.as_array().unwrap().iter().find(|a| a["name"] == "claude-oxygen").unwrap();
51
    assert_eq!(claude["state"], "ok", "{claude}");
52
    assert_eq!(claude["display"], "claude-oxygen · Andy McCall's agent");
53
    let projects = json_of(c, &["project", "list", "--json"]);
54
    assert_eq!(projects[0]["name"], "oxygen");
55
    assert!(projects[0]["agents"].as_array().unwrap().contains(&json!("claude-oxygen")));
56
    let o = run(c, &["agent", "disable", "spare"]);
57
    assert!(o.status.success(), "{}", text(&o));
58
59
    assert_eq!(json_of(c, &["say", "--json", "-c", "general", "the release is tomorrow"])["channel"], "#general");
60
    assert!(json_of(c, &["inbox", "--json"]).is_array());
61
    assert_eq!(json_of(c, &["search", "--json", "release"]).as_array().unwrap().len(), 1);
62
    let brief: Value = serde_json::from_slice(&run(c, &["api", "GET", "/api/v1/brief"]).stdout).unwrap();
63
    assert_eq!(brief["actor"], "human");
64
65
    // An agent's shim on the workstation talks to the server.
66
    let mut cmd = tokio::process::Command::from(hermetic(c));
67
    let home = c.join("agents/claude-oxygen");
68
    cmd.args(["mcp", "--agent", "claude-oxygen", "--home", home.to_str().unwrap()])
69
        .stdin(Stdio::piped())
70
        .stdout(Stdio::piped())
71
        .stderr(Stdio::piped())
72
        .kill_on_drop(true);
73
    let mut shim = cmd.spawn().unwrap();
74
    let mut stdin = shim.stdin.take().unwrap();
75
    let mut lines = BufReader::new(shim.stdout.take().unwrap()).lines();
76
    for v in [
77
        json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "t", "version": "1" } } }),
78
        json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "brief", "arguments": {} } }),
79
    ] {
80
        stdin.write_all(format!("{v}\n").as_bytes()).await.unwrap();
81
    }
82
    stdin.flush().await.unwrap();
83
    let mut brief = Value::Null;
84
    for _ in 0..2 {
85
        let line = tokio::time::timeout(Duration::from_secs(20), lines.next_line()).await.unwrap().unwrap().unwrap();
86
        let v: Value = serde_json::from_str(&line).unwrap();
87
        if v["id"] == 2 {
88
            brief = v;
89
        }
90
    }
91
    assert!(brief["result"]["content"][0]["text"].as_str().unwrap().contains("You are claude-oxygen · Andy McCall's agent"), "{brief}");
92
    drop(shim);
93
94
    // The ledger stays on the server: verify asks it; check and rebuild refuse here.
95
    let v = json_of(c, &["admin", "verify", "--json"]);
96
    assert!(v["findings"].as_array().unwrap().is_empty(), "{v}");
97
    let o = run(c, &["admin", "check"]);
98
    assert!(!o.status.success() && text(&o).contains("runs on the server"), "{}", text(&o));
99
    assert!(!c.join("data").exists(), "the workstation has no data directory");
100
    srv.stop();
101
102
    let o = run(s, &["admin", "verify"]);
103
    assert!(o.status.success(), "{}", text(&o));
104
}
01320eef Andy McCall ↶ 105
106
/// One HTTP/1.1 exchange with the proxy: status, head, body.
107
fn http(port: u16, method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> (u16, String, String) {
108
    use std::io::{Read, Write};
109
    let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
110
    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
111
    let mut req = format!("{method} {path} HTTP/1.1\r\nconnection: close\r\ncontent-length: {}\r\n", body.len());
112
    if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("host")) {
113
        req.push_str(&format!("host: 127.0.0.1:{port}\r\n"));
114
    }
115
    for (k, v) in headers {
116
        req.push_str(&format!("{k}: {v}\r\n"));
117
    }
118
    req.push_str("\r\n");
119
    req.push_str(body);
120
    s.write_all(req.as_bytes()).unwrap();
121
    let mut out = Vec::new();
122
    let _ = s.read_to_end(&mut out);
123
    let text = String::from_utf8_lossy(&out).into_owned();
124
    let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
125
    let status = head.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
126
    (status, head.to_string(), body.to_string())
127
}
128
129
#[tokio::test]
130
async fn radar_through_the_loopback_proxy() {
131
    use std::io::BufRead;
132
    let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
133
    let (s, c) = (sd.path(), cd.path());
134
    let (srv, _port) = connected(s, c);
135
136
    let mut open = hermetic(c).args(["open", "--no-browser", "--port", "0"]).stdout(Stdio::piped()).spawn().unwrap();
137
    let out = open.stdout.take().unwrap();
138
    let (tx, rx) = std::sync::mpsc::channel();
139
    std::thread::spawn(move || {
140
        for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {
141
            if let Some(u) = line.split_whitespace().find(|w| w.contains("/signin?code=")) {
142
                let _ = tx.send(u.to_string());
143
            }
144
        }
145
    });
146
    let link = rx.recv_timeout(Duration::from_secs(10)).expect("spectra open printed a link");
147
    let rest = link.strip_prefix("http://127.0.0.1:").expect("a loopback link");
148
    let (port, path) = rest.split_once('/').unwrap();
149
    let port: u16 = port.parse().unwrap();
150
151
    // Signing in through the proxy sets the session cookie.
152
    let (status, head, _) = http(port, "GET", &format!("/{path}"), &[], "");
153
    assert_eq!(status, 303, "{head}");
154
    let cookie = head
155
        .lines()
156
        .find_map(|l| {
157
            l.to_ascii_lowercase()
158
                .starts_with("set-cookie:")
159
                .then(|| l.split_once(':').unwrap().1.trim().split(';').next().unwrap().to_string())
160
        })
161
        .expect("a session cookie");
162
163
    // Live and a channel load; the channel's form carries the CSRF token.
164
    let (status, _, live) = http(port, "GET", "/", &[("cookie", &cookie)], "");
165
    assert_eq!(status, 200);
166
    assert!(live.contains("Spectra"), "{live}");
167
    let (status, _, page) = http(port, "GET", "/c/general", &[("cookie", &cookie)], "");
168
    assert_eq!(status, 200);
169
    let csrf = page.split("name=\"_csrf\" value=\"").nth(1).and_then(|r| r.split('"').next()).expect("a CSRF token").to_string();
170
171
    // A post from the proxy's own page goes through, as the owner.
172
    let origin = format!("http://127.0.0.1:{port}");
173
    let form = format!("_csrf={csrf}&body=hello+from+radar+over+the+proxy");
174
    let hdrs = [("cookie", cookie.as_str()), ("origin", origin.as_str()), ("content-type", "application/x-www-form-urlencoded")];
175
    let (status, head, _) = http(port, "POST", "/c/general", &hdrs, &form);
176
    assert_eq!(status, 303, "{head}");
177
    let hits = json_of(c, &["search", "--json", "proxy"]);
178
    assert_eq!(hits.as_array().unwrap().len(), 1, "{hits}");
179
    assert_eq!(hits[0]["author"], "human");
180
181
    // Another site cannot use the proxy: not by Origin, nor by Host.
182
    let evil = [("cookie", cookie.as_str()), ("origin", "http://evil.example"), ("content-type", "application/x-www-form-urlencoded")];
183
    assert_eq!(http(port, "POST", "/c/general", &evil, &form).0, 403);
184
    assert_eq!(http(port, "GET", "/", &[("host", "evil.example"), ("cookie", &cookie)], "").0, 403);
185
186
    open.kill().ok();
187
    open.wait().ok();
188
    srv.stop();
189
    let o = run(s, &["admin", "verify"]);
190
    assert!(o.status.success(), "{}", text(&o));
191
}