| 67bfa06a Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
} |