Skip to main content

caboodle/
projection.rs

1use std::{fs, path::Path};
2
3use anyhow::{bail, Context, Result};
4use serde::Serialize;
5
6use crate::model::{CrewMode, CrewOwner, CrewPolicy, CrewRouting, Plan};
7
8#[derive(Serialize)]
9struct SharedPolicy<'a> {
10    identity_source: crate::model::IdentitySource,
11    model: &'a Option<String>,
12    tools: Vec<&'static str>,
13}
14
15impl<'a> From<&'a CrewPolicy> for SharedPolicy<'a> {
16    fn from(policy: &'a CrewPolicy) -> Self {
17        Self {
18            identity_source: policy.identity_source,
19            model: &policy.model,
20            tools: policy.tools.iter().map(|tool| tool.as_str()).collect(),
21        }
22    }
23}
24
25#[derive(Serialize)]
26struct ShantytownProjection<'a> {
27    schema: &'static str,
28    shared: SharedPolicy<'a>,
29    settings_owner: &'static str,
30    durable_owner: CrewOwner,
31    routing: Option<CrewRouting>,
32    hooks: &'static str,
33    filesystem: &'static str,
34}
35
36#[derive(Serialize)]
37struct CreelProjection<'a> {
38    schema: &'static str,
39    shared: SharedPolicy<'a>,
40    settings_owner: &'static str,
41    burst_owner: CrewOwner,
42    routing: Option<CrewRouting>,
43    credential_policy: &'static str,
44    browser_permissions: &'static str,
45}
46
47pub fn write(plan: &Plan, output: &Path) -> Result<Vec<String>> {
48    plan.validate()?;
49    let crew = plan
50        .crew
51        .as_ref()
52        .context("settings projection requires a crew profile")?;
53    fs::create_dir_all(output)
54        .with_context(|| format!("create projection directory {}", output.display()))?;
55    let mut written = Vec::new();
56
57    if matches!(crew.mode, CrewMode::Shantytown | CrewMode::Both) {
58        let projection = ShantytownProjection {
59            schema: "caboodle.shantytown-settings/v1",
60            shared: (&crew.policy).into(),
61            settings_owner: "shantytown",
62            durable_owner: crew.durable_owner.context("missing durable owner")?,
63            routing: crew.routing,
64            hooks: "adapter-emitted",
65            filesystem: "host-workspace",
66        };
67        write_json(output, "shantytown.settings.json", &projection)?;
68        written.push("shantytown.settings.json".to_owned());
69    }
70
71    if matches!(crew.mode, CrewMode::Creel | CrewMode::Both) {
72        let projection = CreelProjection {
73            schema: "caboodle.creel-settings/v1",
74            shared: (&crew.policy).into(),
75            settings_owner: "creel",
76            burst_owner: crew.burst_owner.context("missing burst owner")?,
77            routing: crew.routing,
78            credential_policy: "browser-byo-write-only",
79            browser_permissions: "operator-granted",
80        };
81        write_json(output, "creel.settings.json", &projection)?;
82        written.push("creel.settings.json".to_owned());
83    }
84
85    if crew.mode == CrewMode::Standalone {
86        bail!("standalone crew mode has no harness settings to project");
87    }
88    Ok(written)
89}
90
91fn write_json<T: Serialize>(output: &Path, name: &str, value: &T) -> Result<()> {
92    let path = output.join(name);
93    let body = serde_json::to_vec_pretty(value).context("serialize settings projection")?;
94    fs::write(&path, body).with_context(|| format!("write projection {}", path.display()))
95}
96
97/// Delegate both crew discovery and registration to the authority that owns them.
98/// Reading plan.crew here would fork the rig's roster and miss non-crew installs.
99pub fn register(root: Option<&Path>, agent: Option<&str>, registry: &str) -> Result<()> {
100    use std::process::{Command, Stdio};
101    let mut command = Command::new("st");
102    if let Some(root) = root {
103        command.arg("--root").arg(root);
104    }
105    command.args(["--registry", registry, "ops", "provision", "--json"]);
106    if let Some(agent) = agent {
107        command.arg("--").arg(agent);
108    }
109    let output = command
110        .stderr(Stdio::inherit())
111        .output()
112        .context("run shantytown registration; install st with ops provision support first")?;
113    if !output.status.success() {
114        bail!("shantytown registration failed ({}); inspect its refusal above; no registration success claimed", output.status);
115    }
116    // A successful process is not a registration proof: demand the owner's
117    // read-back receipt, and never echo a malformed response that could contain secrets.
118    let receipt: serde_json::Value = serde_json::from_slice(&output.stdout)
119        .context("shantytown returned no valid registration receipt")?;
120    let agents = receipt
121        .get("agents")
122        .and_then(|v| v.as_array())
123        .context("shantytown registration receipt has no agents")?;
124    if receipt["version"] != 1 || receipt["owner"] != "shantytown" || agents.is_empty() {
125        bail!("shantytown registration receipt has an unsupported version, owner, or empty crew");
126    }
127    let mut verified = Vec::new();
128    for row in agents {
129        let name = row
130            .get("name")
131            .and_then(|v| v.as_str())
132            .filter(|v| !v.is_empty())
133            .context("registration receipt has no agent name")?;
134        let servers = row
135            .get("servers")
136            .and_then(|v| v.as_array())
137            .filter(|v| !v.is_empty())
138            .context("registration receipt has no MCP servers")?;
139        if servers
140            .iter()
141            .any(|v| v.as_str().map_or(true, str::is_empty))
142        {
143            bail!("registration receipt contains an invalid MCP server name");
144        }
145        verified.push((name, servers.len()));
146    }
147    for (name, count) in verified {
148        println!("registered: {name} ({count} MCP servers; owner: shantytown)");
149    }
150    println!("Existing sessions load registration on their next start.");
151    Ok(())
152}