OXYGENHUB

Coverage / src/cli/agent.rs 89.7%

252 of 281 lines run, 41 of 94 functions · run #32 on main · run missed

1
//! `spectra agent`: agents as their owner's delegates (spec §8.1).
2
//!
3
//! `add` makes the agent's key pair in its own home, asks the owner to
4
//! confirm, signs the owner's successor that delegates to it, and records
5
//! both. There is no agent without an owner.
6
7
use super::identity::owner_home;
8
use super::{call, marks, ui, Ctx};
9
use anyhow::{bail, Context, Result};
10
use spectra_core::ids::now_ms;
11
use spectra_core::keys::{home_identity, KeyPair};
12
use spectra_core::ledger::Ledger;
13
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES};
14
use std::path::PathBuf;
15
16
pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"];
17
18
pub struct Add {
19
    pub name: String,
20
    pub kind: String,
21
    pub project: String,
22
    pub home: PathBuf,
23
    pub uid: Option<u32>,
24
    pub learn_uid: bool,
25
    pub expires: Option<String>,
26
    pub yes: bool,
27
    pub owner_home: Option<PathBuf>,
28
}
29
30
/// `30m`, `8h`, `7d` from now, in ms; `never` is none.
313×
pub fn expiry(s: &str) -> Result<Option<u64>> {
323×
    let s = s.trim();
333×
    if s == "never" {
341×
        return Ok(None);
352×
    }
364×
    let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
372×
    let n: u64 = n.parse().with_context(|| format!("{s} is not a length of time (30m, 8h, 7d)"))?;
382×
    let ms = match unit {
392×
        "s" => n * 1000,
402×
        "m" => n * 60_000,
412×
        "h" => n * 3_600_000,
420×
        "d" | "" => n * 86_400_000,
430×
        _ => bail!("{s} is not a length of time (30m, 8h, 7d)"),
44
    };
452×
    Ok(Some(now_ms() + ms))
463×
}
47
48
/// Ask the owner at the terminal; `yes` answers for scripts.
4931×
pub fn confirm(question: &str, yes: bool) -> Result<()> {
50
    use std::io::IsTerminal;
5131×
    if yes {
5229×
        return Ok(());
532×
    }
542×
    if !std::io::stdin().is_terminal() {
552×
        bail!("{question}: not asked, since this is not a terminal (--yes answers for a script)");
560×
    }
570×
    print!("{} {} {} ", ui::glyph("?", "?"), question, ui::dim("[y/N]"));
58
    use std::io::Write;
590×
    std::io::stdout().flush().ok();
600×
    let mut line = String::new();
610×
    std::io::stdin().read_line(&mut line)?;
620×
    if !matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
630×
        bail!("not delegated: nothing was changed");
640×
    }
650×
    Ok(())
6631×
}
67
6829×
pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> {
6929×
    if !valid_name(&a.name) {
700×
        bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64");
7129×
    }
7229×
    if !KINDS.contains(&a.kind.as_str()) {
730×
        bail!("--kind is one of {}", KINDS.join(", "));
7429×
    }
7529×
    let home = owner_home(ctx, a.owner_home.clone());
7629×
    let (exists, known) = match ctx.owner_client(&home).await? {
773×
        Some(c) => {
783×
            let agents = call(&c, "GET", "/api/v1/agents", None).await?;
793×
            let projects = call(&c, "GET", "/api/v1/projects", None).await?;
806×
            let has = |v: &serde_json::Value, n: &str| v.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == n));
813×
            (has(&agents, &a.name), has(&projects, &a.project))
82
        }
83
        None => {
8426×
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
8526×
            let p = Principals::load(&ledger).await?;
8626×
            let known: Option<String> =
8726×
                sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?;
8826×
            (p.agent_key(&a.name).is_some(), known.is_some())
89
        }
90
    };
9129×
    if exists {
920×
        bail!("an agent named {} already exists (spectra agent list)", a.name);
9329×
    }
9429×
    if !known {
951×
        bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project);
9628×
    }
97
    // The agent's key, made once in its own home and reused if `add` is run again.
9828×
    let agent = if a.home.join("keys").exists() {
991×
        KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))?
100
    } else {
10127×
        let pair = KeyPair::create_home(&a.home, &a.name, "")?;
10227×
        println!("{} {}  {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display()));
10327×
        pair
104
    };
10528×
    let expires = a.expires.as_deref().map(expiry).transpose()?.flatten();
10628×
    let current = home_identity(&home)?;
10728×
    if current.delegate(&agent.key).map(|d| d.expires) != Some(expires) {
10828×
        let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into());
10928×
        println!("{}", ui::kv("agent", format!("{}{}{}", ui::subject(&a.name), ui::sep(), ui::hash(agent.key.short(12)))));
11028×
        println!("{}", ui::kv("acts for", format!("{}{}{until}", ui::author(&current.name), ui::sep())));
11128×
        confirm(&format!("Let {} act for you", a.name), a.yes)?;
11227×
        spectra_core::keys::delegate(&home, agent.key, expires)?;
1130×
    }
11427×
    let w = ctx.owner_writer(&home).await?;
11527×
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
11627×
    let owner = KeyPair::load_home(&home)?;
11727×
    let mut p = identity_payload(&agent.identity);
11827×
    p["name"] = a.name.clone().into();
11927×
    p["kind"] = a.kind.clone().into();
12027×
    p["project"] = a.project.clone().into();
12127×
    p["home"] = a.home.display().to_string().into();
12227×
    p["owner"] = owner.key.to_hex().into();
12327×
    p["owner_name"] = owner.identity.name.clone().into();
12427×
    p["scopes"] = DEFAULT_SCOPES.into();
12527×
    if let Some(u) = a.uid {
1262×
        p["uid"] = u.into();
12725×
    }
12827×
    p["learn_uid"] = a.learn_uid.into();
12927×
    let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?;
13027×
    p["delegation"] = serde_json::json!({ "expires": d.expires });
13127×
    events.push(signed_event(&owner, "human", "agent.added", p));
13227×
    w.append(events).await?;
133
    // Label the home, so `spectra mcp --auto` finds this agent from its folder.
13427×
    let folder = match (w.client(), w.ledger()) {
1353×
        (Some(c), _) => call(c, "GET", "/api/v1/projects", None).await.ok().and_then(|v| {
1363×
            v.as_array().and_then(|l| l.iter().find(|p| p["name"] == a.project.as_str()).and_then(|p| p["path"].as_str().map(String::from)))
1373×
        }),
13824×
        (None, Some(l)) => sqlx::query_scalar::<_, Option<String>>("SELECT path FROM projects WHERE name = ?")
13924×
            .bind(&a.project)
14024×
            .fetch_optional(l.pool())
14124×
            .await
14224×
            .ok()
14324×
            .flatten()
14424×
            .flatten(),
1450×
        _ => None,
146
    };
14727×
    super::auto::write_label(&a.home, &a.name, &a.kind, &a.project, folder.as_deref())?;
14827×
    println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name)));
14927×
    println!("{}", ui::kv("key", ui::hash(agent.key.to_hex())));
15027×
    let until = d.expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into());
15127×
    println!("{}", ui::kv("delegated", until));
152
    // With Spectra on another machine the shim cannot look the home up.
15327×
    let shim = if ctx.remote()?.is_some() {
1542×
        format!("spectra mcp --agent {} --home {}", a.name, a.home.display())
155
    } else {
15625×
        format!("spectra mcp --agent {}", a.name)
157
    };
15827×
    println!("  {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(shim));
15927×
    Ok(0)
16029×
}
161
162
/// An agent as `agent list` reads it, with its presence.
163
#[derive(sqlx::FromRow)]
164
struct AgentRow {
165
    name: String,
166
    key: String,
167
    kind: String,
168
    project: String,
169
    disabled_ms: Option<i64>,
170
    presence: Option<String>,
171
    status: Option<String>,
172
}
173
1746×
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
1756×
    let out = match ctx.owner_client(&owner_home(ctx, None)).await? {
1762×
        Some(c) => call(&c, "GET", "/api/v1/agents", None).await?.as_array().cloned().unwrap_or_default(),
1774×
        None => list_local(ctx).await?,
178
    };
1796×
    print_list(out, json)
1806×
}
181
1824×
async fn list_local(ctx: &Ctx) -> Result<Vec<serde_json::Value>> {
1834×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
1844×
    let p = Principals::load(&ledger).await?;
1854×
    let rows: Vec<AgentRow> = sqlx::query_as(
1864×
        "SELECT a.name, a.key, a.kind, a.project, a.disabled_ms, p.state AS presence, p.status_text AS status FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name",
1874×
    )
1884×
    .fetch_all(ledger.pool())
1894×
    .await?;
1904×
    let owner = p.owner();
1914×
    let now = now_ms();
1924×
    let mut out = Vec::new();
1934×
    for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows {
1944×
        let k = spectra_core::identity::KeyId::from_hex(&key).context("a stored key")?;
1954×
        let state = match p.principal(&k, now) {
1962×
            Ok(_) => "ok".to_string(),
1972×
            Err(why) => why,
198
        };
1994×
        let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires);
2004×
        let who = match p.principal(&k, now) {
2012×
            Ok(pr @ Principal::Agent { .. }) => pr.display(),
2022×
            _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")),
203
        };
2044×
        let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) };
2054×
        out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "presence": presence, "status": status, "delegation_expires": expires, "disabled": disabled.is_some() }));
206
    }
2074×
    Ok(out)
2084×
}
209
2106×
fn print_list(out: Vec<serde_json::Value>, json: bool) -> Result<i32> {
2116×
    if json {
2125×
        println!("{}", serde_json::Value::Array(out));
2135×
        return Ok(0);
2141×
    }
2151×
    if out.is_empty() {
2160×
        println!("{} {}", ui::note(), ui::dim("no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
2171×
    }
2181×
    for a in out {
2191×
        let state = a["state"].as_str().unwrap_or("");
2201×
        let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default();
2211×
        let display = a["display"].as_str().unwrap_or("");
2221×
        let (name, owner) = display.split_once(" · ").unwrap_or((display, ""));
2231×
        println!(
224
            "{} {} {}{}{}",
2251×
            marks::health(state),
2261×
            marks::presence(a["presence"].as_str().unwrap_or("offline")),
2271×
            ui::subject(name),
2281×
            ui::sep(),
2291×
            ui::author(owner)
230
        );
2311×
        if let Some(st) = a["status"].as_str() {
2320×
            println!("  {} {}", ui::note(), st);
2331×
        }
2341×
        println!(
235
            "  {}",
2361×
            ui::dim(format!(
237
                "{}{}{}{}{}{until}",
2381×
                a["kind"].as_str().unwrap_or(""),
2391×
                ui::sep(),
2401×
                a["project"].as_str().unwrap_or(""),
2411×
                ui::sep(),
2421×
                &a["key"].as_str().unwrap_or("")[..12]
243
            ))
244
        );
2451×
        if state != "ok" {
2461×
            println!("  {} {}", ui::caution(), ui::warn(state));
2471×
        }
248
    }
2491×
    Ok(0)
2506×
}
251
2523×
pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> {
2533×
    let home = owner_home(ctx, owner_home_flag);
2543×
    let w = ctx.owner_writer(&home).await?;
2552×
    let known = match (w.ledger(), w.client()) {
2562×
        (_, Some(c)) => call(c, "GET", "/api/v1/agents", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == name)),
2571×
        (Some(l), None) => Principals::load(l).await?.agent_key(name).is_some(),
2580×
        (None, None) => false,
259
    };
2602×
    if !known {
2610×
        bail!("no agent named {name}");
2622×
    }
263
    // Stop delegating to its key too, where the owner's identity names it.
2642×
    if let Some(key) = agent_key(&w, name).await? {
2652×
        if home_identity(&home)?.delegate(&key).is_some() {
2662×
            spectra_core::keys::undelegate(&home, key)?;
2670×
        }
2680×
    }
2692×
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
2702×
    let owner = KeyPair::load_home(&home)?;
2712×
    events.push(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name })));
2722×
    w.append(events).await?;
2732×
    println!("{} {}", ui::done("Disabled"), ui::subject(name));
2742×
    println!("  {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures"));
2752×
    Ok(0)
2763×
}
277
278
/// An agent's key by name, from the daemon or the ledger here.
2795×
async fn agent_key(w: &super::Writer, name: &str) -> Result<Option<spectra_core::identity::KeyId>> {
2805×
    Ok(match (w.client(), w.ledger()) {
2813×
        (Some(c), _) => call(c, "GET", "/api/v1/agents", None).await?.as_array().and_then(|l| {
2824×
            l.iter().find(|x| x["name"] == name).and_then(|x| x["key"].as_str().and_then(spectra_core::identity::KeyId::from_hex))
2833×
        }),
2842×
        (None, Some(l)) => Principals::load(l).await?.agent_key(name),
2850×
        (None, None) => None,
286
    })
2875×
}
288
289
/// `spectra agent allow NAME --channel CHANNEL [--remove]`: the owner lets
290
/// an agent post in another project's channel (spec §8.4).
2913×
pub async fn allow(ctx: &Ctx, name: &str, channel: &str, remove: bool, owner_home_flag: Option<PathBuf>) -> Result<i32> {
2923×
    let channel = channel.trim_start_matches('#');
2933×
    let scope = format!("channel:{channel}");
2943×
    if !spectra_core::principals::grantable(&scope) {
2951×
        bail!("{channel} is not a channel name");
2962×
    }
2972×
    let home = owner_home(ctx, owner_home_flag);
2982×
    let w = ctx.owner_writer(&home).await?;
2992×
    agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?;
3002×
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
3012×
    let owner = KeyPair::load_home(&home)?;
3022×
    events.push(signed_event(&owner, "human", "agent.scoped", serde_json::json!({ "name": name, "scope": scope, "grant": !remove })));
3032×
    w.append(events).await?;
3042×
    if remove {
3051×
        println!("{} {} {}", ui::done("Removed"), ui::subject(name), ui::dim(format!("may no longer post in #{channel}")));
3061×
    } else {
3071×
        println!("{} {} {}", ui::done("Allowed"), ui::subject(name), ui::dim(format!("may post in #{channel}")));
3081×
    }
3092×
    Ok(0)
3103×
}
311
312
/// `spectra agent renew NAME --expires 8h`: the owner's successor with a
313
/// new expiry for the agent's key, recorded.
3141×
pub async fn renew(ctx: &Ctx, name: &str, expires: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> {
3151×
    let home = owner_home(ctx, owner_home_flag);
3161×
    let w = ctx.owner_writer(&home).await?;
3171×
    let key = agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?;
3181×
    let expires = expiry(expires)?;
3191×
    spectra_core::keys::delegate(&home, key, expires)?;
3201×
    let events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
3211×
    w.append(events).await?;
3221×
    let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into());
3231×
    println!("{} {}{}{}", ui::done("Renewed"), ui::subject(name), ui::sep(), until);
3241×
    Ok(0)
3251×
}
326
32760×
pub fn valid_name(n: &str) -> bool {
32860×
    !n.is_empty()
32960×
        && n.len() <= 64
33060×
        && n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
331485×
        && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
33260×
}