OXYGENHUB
# 5d695d869fbf spectra / tests / cli_identity.rs
5.1 KiB · Rust · last changed The command line in oxy's style: ticks and verbs, key-value rows, sections, hints, spinners; plain when piped 2 days ago source blame history raw
1
//! The owner and agents from the command line (spec §8.1, §8.3), as the
2
//! built binary in a hermetic environment: `init` proves the owner's
3
//! identity, `agent add` refuses until the owner delegates, ownership is
4
//! shown, and the ledger verifies throughout.
5
6
use oxy_core::durable::{write_atomic, write_atomic_private};
7
use oxy_core::identity::{Delegate, Identity, SecretKey};
8
use spectra_core::keys::KeyPair;
9
use spectra_core::principals::DELEGATION_SCOPE;
10
use std::path::Path;
11
use std::process::{Command, Output};
12
13
fn hermetic(dir: &Path) -> Command {
14
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
15
    c.env("SPECTRA_DATA", dir.join("data"))
16
        .env("OXY_HOME", dir.join("owner"))
17
        .env("HOME", dir)
18
        .env("XDG_DATA_HOME", dir.join("xdg"))
19
        .env("NO_COLOR", "1");
20
    c
21
}
22
23
fn run(dir: &Path, args: &[&str]) -> Output {
24
    hermetic(dir).args(args).output().unwrap()
25
}
26
27
fn text(o: &Output) -> String {
28
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
29
}
30
31
/// `oxy identity delegate add IDENTITY --scope changes` in the owner's home.
32
fn delegate(owner_home: &Path, agent: &Identity) {
33
    let cur = KeyPair::load_home(owner_home).unwrap();
34
    let keys = owner_home.join("keys");
35
    let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap();
36
    let mut d = cur.identity.delegates.clone();
37
    d.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires: None });
38
    let next = cur.identity.successor(d, None, spectra_core::ids::now_ms());
39
    let k = next.hash();
40
    write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
41
    write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
42
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
43
}
44
45
fn verify_clean(dir: &Path) {
46
    let o = run(dir, &["admin", "verify", "--json"]);
47
    let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("{}", text(&o)));
48
    assert!(o.status.success() && v["findings"].as_array().unwrap().is_empty(), "{v}");
49
}
50
51
#[test]
52
fn init_then_an_owned_agent_end_to_end() {
53
    let dir = tempfile::tempdir().unwrap();
54
    let d = dir.path();
55
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "[email protected]").unwrap();
56
57
    let o = run(d, &["init", "--name", "laptop"]);
58
    assert!(o.status.success(), "{}", text(&o));
59
    assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o));
60
    assert!(!run(d, &["init"]).status.success(), "a second init is refused");
61
    verify_clean(d);
62
63
    let home = d.join("agents/claude-oxygen");
64
    let home_s = home.to_str().unwrap();
65
    let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--wait", "0"];
66
    let o = run(d, &args);
67
    assert!(!o.status.success(), "refused until delegated");
68
    assert!(text(&o).contains("oxy identity delegate add") && text(&o).contains("not delegated yet"), "{}", text(&o));
69
    let agent = KeyPair::load_home(&home).unwrap();
70
71
    delegate(&d.join("owner"), &agent.identity);
72
    let o = run(d, &args);
73
    assert!(o.status.success(), "{}", text(&o));
74
    assert!(text(&o).contains("Added claude-oxygen") && text(&o).contains("Andy McCall's agent"), "{}", text(&o));
75
    assert_eq!(KeyPair::load_home(&home).unwrap().key, agent.key, "the key made first is the key recorded");
76
77
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
78
    assert_eq!(list[0]["display"], "claude-oxygen · Andy McCall's agent");
79
    assert_eq!(list[0]["state"], "ok");
80
    verify_clean(d);
81
82
    assert!(run(d, &["agent", "disable", "claude-oxygen"]).status.success());
83
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
84
    assert!(list[0]["state"].as_str().unwrap().contains("disabled"), "{list}");
85
    verify_clean(d);
86
    assert!(run(d, &["admin", "check"]).status.success());
87
88
    // Piped, as here, output is plain like oxy's: no colour codes, no glyphs.
89
    let o = run(d, &["agent", "list"]);
90
    let t = text(&o);
91
    assert!(!t.contains('\x1b') && !t.contains('✔'), "{t}");
92
}
93
94
#[test]
95
fn an_owner_without_a_secret_key_cannot_init() {
96
    let dir = tempfile::tempdir().unwrap();
97
    let o = run(dir.path(), &["init"]);
98
    assert!(!o.status.success());
99
    assert!(text(&o).contains("the owner signs with their own identity"), "{}", text(&o));
100
}
101
102
#[tokio::test]
103
async fn a_second_writer_is_refused() {
104
    let dir = tempfile::tempdir().unwrap();
105
    let d = dir.path();
106
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap();
107
    assert!(run(d, &["init"]).status.success());
108
    let instance = KeyPair::instance(&d.join("data"), "x").unwrap();
109
    let held = spectra_core::ledger::Ledger::open(&d.join("data/spectra.db"), Some(instance)).await.unwrap();
110
    let o = run(d, &["agent", "disable", "nobody"]);
111
    assert!(text(&o).contains("another process is writing"), "{}", text(&o));
112
    assert!(run(d, &["admin", "verify"]).status.success(), "reading needs no lock");
113
    drop(held);
114
}