OXYGENHUB
# 8cfb9269bb14 spectra / tests / serve.rs
6.7 KiB · Rust · last changed The 0.1 tools: brief, rules, agents, status, say, read, inbox, wait with the idle guard, ack, search, redact; every call on the record (#8) 2 days ago source blame history raw
1
//! `spectra serve` end to end (spec §8.5, §15.1): the real binary, a client
2
//! signing requests over the Unix socket, both MCP revisions, and every
3
//! refusal the spec promises. Hermetic: temporary homes, data and port 0.
4
5
mod common;
6
7
use common::{add_agent, run, setup, text, Serving};
8
use serde_json::json;
9
use spectra_core::keys::KeyPair;
10
use spectra_mcp::client::{rpc_result, Client};
11
12
fn call(id: u64, name: &str, args: serde_json::Value) -> serde_json::Value {
13
    json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args,
14
        "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } })
15
}
16
17
#[tokio::test]
18
async fn an_agent_discovers_lists_and_calls_tools_over_the_socket() {
19
    let dir = setup();
20
    let d = dir.path();
21
    let agent = add_agent(d, "claude-oxygen", &[]);
22
    let srv = Serving::start(d);
23
    let c = Client::new(&srv.socket, Some(agent.clone()));
24
25
    let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {} })).await.unwrap();
26
    assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
27
    let v = r.json().unwrap();
28
    assert_eq!(v["result"]["serverInfo"]["name"], "spectra");
29
    assert_eq!(v["result"]["resultType"], "complete");
30
    assert!(v["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2025-11-25")));
31
32
    let v = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })).await.unwrap().json().unwrap();
33
    assert!(v["result"]["tools"].as_array().unwrap().iter().any(|t| t["name"] == "agents"));
34
    assert_eq!(v["result"]["ttlMs"], 3_600_000);
35
    assert!(v["result"]["_meta"]["spectra/session"].is_string(), "{v}");
36
37
    let r = c.mcp("2026-07-28", &call(3, "agents", json!({}))).await.unwrap();
38
    let v = rpc_result(&r).unwrap();
39
    assert_eq!(v["result"]["isError"], false, "{v}");
40
    assert_eq!(v["result"]["structuredContent"]["agents"][0]["owner"], "Andy McCall");
41
    assert!(v["result"]["content"][0]["text"].as_str().unwrap().contains("claude-oxygen · Andy McCall's agent"));
42
43
    // The legacy revisions begin with initialize.
44
    let v = c.mcp("2025-11-25", &json!({ "jsonrpc": "2.0", "id": 4, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "t", "version": "1" } } })).await.unwrap().json().unwrap();
45
    assert_eq!(v["result"]["protocolVersion"], "2025-11-25");
46
    assert!(v["result"]["instructions"].as_str().unwrap().contains("Call `brief` first"));
47
    assert_eq!(c.mcp("2025-11-25", &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })).await.unwrap().status, 202);
48
    srv.stop();
49
}
50
51
#[tokio::test]
52
async fn every_refusal_the_spec_promises() {
53
    let dir = setup();
54
    let d = dir.path();
55
    let agent = add_agent(d, "claude-oxygen", &[]);
56
    let uid = unsafe { libc::getuid() };
57
    let other_uid = (uid + 1).to_string();
58
    let bound = add_agent(d, "bound", &["--uid", &other_uid]);
59
    let srv = Serving::start(d);
60
    let list = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } });
61
62
    let unsigned = Client::new(&srv.socket, None).mcp("2026-07-28", &list).await.unwrap();
63
    assert_eq!(unsigned.status, 401);
64
    let stranger = Client::new(&srv.socket, Some(KeyPair::generate("stranger", ""))).mcp("2026-07-28", &list).await.unwrap();
65
    assert_eq!(stranger.status, 401);
66
    assert!(String::from_utf8_lossy(&stranger.body).contains("unknown key"));
67
68
    let c = Client::new(&srv.socket, Some(agent));
69
    let r = c
70
        .send(
71
            "POST",
72
            "/mcp",
73
            &[("mcp-protocol-version", "2026-07-28".into()), ("mcp-method", "tools/call".into())],
74
            serde_json::to_vec(&list).unwrap(),
75
        )
76
        .await
77
        .unwrap();
78
    assert_eq!((r.status, r.json().unwrap()["error"]["code"].as_i64()), (400, Some(-32020)), "the Mcp-Method header must match the body");
79
    let r = c.send("POST", "/mcp", &[("mcp-protocol-version", "1999-01-01".into())], serde_json::to_vec(&list).unwrap()).await.unwrap();
80
    assert_eq!((r.status, r.json().unwrap()["error"]["code"].as_i64()), (400, Some(-32022)));
81
    let r = c
82
        .send(
83
            "POST",
84
            "/mcp",
85
            &[("origin", "http://evil.example".into()), ("mcp-protocol-version", "2026-07-28".into()), ("mcp-method", "tools/list".into())],
86
            serde_json::to_vec(&list).unwrap(),
87
        )
88
        .await
89
        .unwrap();
90
    assert_eq!(r.status, 403, "a page from another origin");
91
    let r = c.send("GET", "/mcp", &[], vec![]).await.unwrap();
92
    assert_eq!(r.status, 405);
93
94
    let wrong = Client::new(&srv.socket, Some(bound)).mcp("2026-07-28", &list).await.unwrap();
95
    assert_eq!(wrong.status, 401);
96
    assert!(String::from_utf8_lossy(&wrong.body).contains(&format!("bound to uid {other_uid}")));
97
    srv.stop();
98
99
    let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap();
100
    assert!(v["findings"].as_array().unwrap().is_empty(), "{v}");
101
    assert_eq!(v["uncovered"], 0, "the stop wrote a checkpoint");
102
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
103
    let logged: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE kind = 'guard.impersonation'").fetch_one(&pool).await.unwrap();
104
    assert_eq!(logged, 1, "the wrong-uid attempt is in the ledger");
105
}
106
107
#[tokio::test]
108
async fn owner_commands_go_through_the_running_daemon_and_learn_uid_binds() {
109
    let dir = setup();
110
    let d = dir.path();
111
    let srv = Serving::start(d);
112
    // While serve holds the ledger, the CLI's writes go through it.
113
    let learner = add_agent(d, "learner", &["--learn-uid"]);
114
    std::fs::create_dir_all(d.join("hub")).unwrap();
115
    let o = run(d, &["project", "add", d.join("hub").to_str().unwrap()]);
116
    assert!(o.status.success(), "{}", text(&o));
117
    let o = run(d, &["agent", "list", "--json"]);
118
    assert!(text(&o).contains("learner"), "{}", text(&o));
119
120
    let list = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} });
121
    assert_eq!(Client::new(&srv.socket, Some(learner)).mcp("2025-11-25", &list).await.unwrap().status, 200);
122
    srv.stop();
123
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
124
    let uid: Option<i64> = sqlx::query_scalar("SELECT uid FROM agents WHERE name = 'learner'").fetch_one(&pool).await.unwrap();
125
    assert_eq!(uid, Some(unsafe { libc::getuid() } as i64), "bound to the uid of its first signed connection");
126
    let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap();
127
    assert!(v["findings"].as_array().unwrap().is_empty(), "{v}");
128
}