diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll.rs index f738b6f51d0..857281fe70d 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll.rs @@ -1,8 +1,10 @@ //! Enrolling a developer profile with the Sandbox manager. The owner signs //! a kind 24210 `enroll` request carrying the profile `profile.py` //! validates, sends it on the community's live socket, and waits for the -//! `done` reply. Tokens are never saved or logged, and leave the form once -//! it is sent. +//! `done` reply. Once a profile is on file, the request carries only what +//! changes, and the manager merges it; the `profile` request says what is on +//! file without any value. Tokens are never saved or logged, and leave the +//! form once it is sent. use super::signing::{self, GitHubIdentity}; use buzz_identity::Identity; @@ -75,8 +77,15 @@ impl Field { } } + /// The field filling the models entry `key`. + pub fn of_model(key: &str) -> Option { + Field::ALL + .into_iter() + .find(|field| field.model() == Some(key)) + } + /// The models entry it fills, for the optional model keys. - fn model(self) -> Option<&'static str> { + pub fn model(self) -> Option<&'static str> { match self { Field::OpenaiApiKey => Some("openai_api_key"), Field::CodexAccessToken => Some("codex_access_token"), @@ -117,6 +126,156 @@ impl Values { } } +/// What the manager holds for this developer: names and flags, never a +/// credential. The login, name and email are not secret. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct Summary { + pub version_id: String, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub git: GitSummary, + #[serde(default)] + pub github: GitHubSummary, + #[serde(default)] + pub via: ViaSummary, + /// The stored models entries, by key. + #[serde(default)] + pub models: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct GitSummary { + #[serde(default)] + pub name: String, + #[serde(default)] + pub email: String, + #[serde(default)] + pub signing_key: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct GitHubSummary { + #[serde(default)] + pub login: String, + #[serde(default)] + pub token: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct ViaSummary { + #[serde(default)] + pub profile: bool, + #[serde(default)] + pub op_token: bool, +} + +impl Summary { + /// Whether `field` has a stored value. + pub fn holds(&self, field: Field) -> bool { + match field { + Field::GithubToken => self.github.token, + Field::GitEmail => !self.git.email.is_empty(), + Field::ViaProfile => self.via.profile, + Field::OpToken => self.via.op_token, + _ => field + .model() + .is_some_and(|key| self.models.iter().any(|stored| stored == key)), + } + } + + /// The stored model credentials, as fields, in form order. + pub fn model_fields(&self) -> Vec { + Field::ALL + .into_iter() + .filter(|field| field.model().is_some() && self.holds(*field)) + .collect() + } + + /// One line naming what is on file. + pub fn text(&self) -> String { + let mut parts = Vec::new(); + if !self.github.login.is_empty() { + parts.push(format!("GitHub @{}", self.github.login)); + } + if !self.git.email.is_empty() { + parts.push(format!("Git email {}", self.git.email)); + } + for (held, name) in [ + (self.git.signing_key, "signing key"), + (self.via.profile, "via profile"), + (self.via.op_token, "1Password token"), + ] { + if held { + parts.push(name.to_owned()); + } + } + parts.extend( + self.model_fields() + .iter() + .map(|field| field.label().to_owned()), + ); + let updated = self + .updated_at + .as_deref() + .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok()) + .map(|at| { + format!( + " Updated {}.", + at.with_timezone(&chrono::Utc).format("%Y-%m-%d %H:%M UTC") + ) + }) + .unwrap_or_default(); + format!("On file: {}.{updated}", parts.join(" · ")) + } +} + +/// What the form knows about the stored profile. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OnFile { + Loading, + Nothing, + Stored(Summary), + /// The `profile` request failed, and why: every field is required. + Unknown(String), +} + +impl OnFile { + pub fn summary(&self) -> Option<&Summary> { + match self { + OnFile::Stored(summary) => Some(summary), + _ => None, + } + } + + pub fn text(&self) -> String { + match self { + OnFile::Loading => "Checking what is on file…".into(), + OnFile::Nothing => "Nothing enrolled yet.".into(), + OnFile::Stored(summary) => summary.text(), + OnFile::Unknown(error) => { + format!("Could not check what is on file: {error} Enroll the complete profile.") + } + } + } +} + +/// What the `profile` request's `done` response says: the summary, `None` +/// when nothing is stored, or why not. +pub fn on_file_reply(response: &Value) -> Result, String> { + let malformed = || "The Sandbox manager's reply was malformed.".to_string(); + match response.get("ok").and_then(Value::as_bool) { + Some(true) => match response.get("profile") { + None | Some(Value::Null) => Ok(None), + Some(summary) => serde_json::from_value(summary.clone()) + .map(Some) + .map_err(|_| malformed()), + }, + Some(false) => Err(outcome(response)?.text()), + None => Err(malformed()), + } +} + /// The profile `profile.py` validates, for the owner `owner`, or why not. /// `github` is the token's account, which names the Git author, and /// `signing_key` the agent-only key commits are signed with. The developer @@ -151,16 +310,73 @@ pub fn profile( Ok(profile) } +/// What an enrollment over a stored profile changes, beyond the form's text. +#[derive(Default)] +pub struct Changes<'a> { + /// The new GitHub token's account; `None` keeps the stored token. + pub github: Option<&'a GitHubIdentity>, + /// A new signing key; `None` keeps the stored one. + pub signing_key: Option<&'a str>, + /// Stored model credentials to remove, by models key. + pub removed: &'a [&'static str], +} + +/// The partial profile the manager merges into the stored one: only the +/// fields filled in, `null` for each removed model credential, and nothing +/// for what stays. Each field sent is checked as `profile.py` checks it. +pub fn partial(values: &Values, owner: &str, changes: Changes) -> Result { + let text = |field| values.get(field).trim(); + let mut git = Map::new(); + if let Some(github) = changes.github { + git.insert("name".into(), github.name.clone().into()); + } + if !text(Field::GitEmail).is_empty() { + git.insert("email".into(), text(Field::GitEmail).into()); + } + if let Some(key) = changes.signing_key { + git.insert("ssh_signing_key".into(), key.into()); + } + let mut via = Map::new(); + if !text(Field::ViaProfile).is_empty() { + via.insert("profile".into(), values.get(Field::ViaProfile).into()); + } + if !text(Field::OpToken).is_empty() { + via.insert("op_token".into(), text(Field::OpToken).into()); + } + let mut models: Map = changes + .removed + .iter() + .map(|key| ((*key).to_owned(), Value::Null)) + .collect(); + for field in Field::ALL { + if let Some(key) = field.model().filter(|_| !text(field).is_empty()) { + models.insert(key.to_owned(), text(field).into()); + } + } + let mut profile = json!({"version": 1, "developer": "", "owner_pubkey": owner}); + for (name, part) in [("git", git), ("via", via), ("models", models)] { + if !part.is_empty() { + profile[name] = Value::Object(part); + } + } + if let Some(github) = changes.github { + profile["github"] = json!({"login": github.login, "token": text(Field::GithubToken)}); + } + if profile + .as_object() + .is_some_and(|profile| profile.len() == 3) + { + return Err("Nothing to change: fill in a field, or remove a credential.".into()); + } + validate_partial(&profile)?; + Ok(profile) +} + /// `profile.py`'s size limit, which Secrets Manager sets. const MAX_PROFILE_BYTES: usize = 65_536; /// `profile.py`'s `validate`, with its messages, which name no values. pub fn validate(profile: &Value) -> Result<(), String> { - let require = |ok: bool, message: &str| if ok { Ok(()) } else { Err(message.to_owned()) }; - let matches = |value: &Value, pattern: &str| { - let pattern = Regex::new(&format!("^(?:{pattern})$")).expect("a valid pattern"); - value.as_str().is_some_and(|text| pattern.is_match(text)) - }; fields( profile, &[ @@ -173,56 +389,159 @@ pub fn validate(profile: &Value) -> Result<(), String> { "models", ], )?; + owner_fields(profile)?; + let git = &profile["git"]; + fields(git, &["name", "email", "ssh_signing_key"])?; + git_identity(&git["name"])?; + git_identity(&git["email"])?; + signing_key(&git["ssh_signing_key"])?; + let github = &profile["github"]; + fields(github, &["login", "token"])?; + github_account(github)?; + let via = &profile["via"]; + fields(via, &["profile", "op_token"])?; + token(&via["op_token"])?; + via_profile(&via["profile"])?; + models(&profile["models"])?; + size(profile) +} + +/// [`validate`] for a partial profile: its fields are those of a full one, +/// each optional, and each present one passes the same rule. A `null` +/// model credential is a removal. +pub fn validate_partial(profile: &Value) -> Result<(), String> { + let only = |value: &Value, allowed: &[&str]| { + let ok = value + .as_object() + .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))); + if ok { + Ok(()) + } else { + Err("Profile fields are incomplete or unsupported".to_string()) + } + }; + only( + profile, + &[ + "version", + "developer", + "owner_pubkey", + "git", + "github", + "via", + "models", + ], + )?; + owner_fields(profile)?; + if let Some(git) = profile.get("git") { + only(git, &["name", "email", "ssh_signing_key"])?; + for name in ["name", "email"] { + git.get(name).map_or(Ok(()), git_identity)?; + } + git.get("ssh_signing_key").map_or(Ok(()), signing_key)?; + } + if let Some(github) = profile.get("github") { + fields(github, &["login", "token"])?; + github_account(github)?; + } + if let Some(via) = profile.get("via") { + only(via, &["profile", "op_token"])?; + via.get("op_token").map_or(Ok(()), token)?; + via.get("profile").map_or(Ok(()), via_profile)?; + } + if let Some(stored) = profile.get("models") { + let set: Map = stored + .as_object() + .ok_or("Unsupported model credential type")? + .iter() + .filter(|(_, value)| !value.is_null()) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let model_fields = [ + "openai_api_key", + "codex_access_token", + "anthropic_api_key", + "claude_oauth_token", + "openrouter_api_key", + ]; + let removals_known = stored.as_object().is_some_and(|stored| { + stored + .keys() + .all(|key| model_fields.contains(&key.as_str())) + }); + if !removals_known { + return Err("Unsupported model credential type".into()); + } + models(&Value::Object(set))?; + } + size(profile) +} + +fn require(ok: bool, message: &str) -> Result<(), String> { + if ok { Ok(()) } else { Err(message.to_owned()) } +} + +fn matches(value: &Value, pattern: &str) -> bool { + let pattern = Regex::new(&format!("^(?:{pattern})$")).expect("a valid pattern"); + value.as_str().is_some_and(|text| pattern.is_match(text)) +} + +fn owner_fields(profile: &Value) -> Result<(), String> { require( profile["version"] == json!(1), "Unsupported profile version", )?; // Empty until the manager fills in the signing owner's developer ID. + let developer = profile.get("developer").unwrap_or(&Value::Null); require( - profile["developer"] == json!("") - || matches( - &profile["developer"], - "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?", - ), + developer.is_null() + || *developer == json!("") + || matches(developer, "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?"), "Invalid developer ID", )?; require( matches(&profile["owner_pubkey"], "[0-9a-f]{64}"), "Invalid Buzz owner", - )?; - let git = &profile["git"]; - fields(git, &["name", "email", "ssh_signing_key"])?; - for value in [&git["name"], &git["email"]] { - let ok = value.as_str().is_some_and(|text| { - (1..=256).contains(&text.chars().count()) && !text.chars().any(|c| (c as u32) < 32) - }); - require(ok, "Invalid Git identity")?; - } + ) +} + +fn git_identity(value: &Value) -> Result<(), String> { + let ok = value.as_str().is_some_and(|text| { + (1..=256).contains(&text.chars().count()) && !text.chars().any(|c| (c as u32) < 32) + }); + require(ok, "Invalid Git identity") +} + +fn signing_key(value: &Value) -> Result<(), String> { require( matches( - &git["ssh_signing_key"], + value, "-----BEGIN OPENSSH PRIVATE KEY-----\\n[A-Za-z0-9+/=\\n]{1,4000}-----END OPENSSH PRIVATE KEY-----\\n?", ), "Invalid SSH signing key", - )?; - let github = &profile["github"]; - fields(github, &["login", "token"])?; + ) +} + +fn github_account(github: &Value) -> Result<(), String> { require( matches(&github["login"], "[a-zA-Z0-9][a-zA-Z0-9-]{0,38}"), "Invalid GitHub login", )?; - token(&github["token"])?; - let via = &profile["via"]; - fields(via, &["profile", "op_token"])?; - token(&via["op_token"])?; - let config = via["profile"].as_str().ok_or("Invalid via profile")?; + token(&github["token"]) +} + +fn via_profile(value: &Value) -> Result<(), String> { + let config = value.as_str().ok_or("Invalid via profile")?; // The parser's error quotes the profile, so it is not shown. let config: toml::Table = config.parse().map_err(|_| "Invalid via profile")?; require( truthy(config.get("services")) && truthy(config.get("providers")), "via profile requires providers and services", - )?; - let models = profile["models"] + ) +} + +fn models(models: &Value) -> Result<(), String> { + let models = models .as_object() .ok_or("Unsupported model credential type")?; let model_fields = [ @@ -256,7 +575,10 @@ pub fn validate(profile: &Value) -> Result<(), String> { .get("codex_access_token") .is_none_or(|value| value.as_str().is_some_and(|value| value.starts_with("at-"))), "Use a Codex access token (at-...)", - )?; + ) +} + +fn size(profile: &Value) -> Result<(), String> { require( profile.to_string().len() <= MAX_PROFILE_BYTES, "Profile exceeds Secrets Manager size limit", @@ -304,8 +626,12 @@ fn truthy(value: Option<&toml::Value>) -> bool { /// How an enrollment ended. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Outcome { - /// The manager stored a new version of the profile. - Enrolled { version_id: String }, + /// The manager stored a new version of the profile, which now holds + /// `summary` when the manager says. + Enrolled { + version_id: String, + summary: Option, + }, /// The manager refused it with `code`. Refused { code: String }, } @@ -342,7 +668,9 @@ impl Outcome { /// What the form shows. pub fn text(&self) -> String { match self { - Outcome::Enrolled { version_id } => format!("Enrolled. Profile version {version_id}."), + Outcome::Enrolled { version_id, .. } => { + format!("Enrolled. Profile version {version_id}.") + } Outcome::Refused { code } => { let reason = REFUSALS .iter() @@ -364,64 +692,108 @@ pub fn outcome(response: &Value) -> Result { .map(str::to_owned) }; match response.get("ok").and_then(Value::as_bool) { - Some(true) => text("version_id").map(|version_id| Outcome::Enrolled { version_id }), + Some(true) => text("version_id").map(|version_id| Outcome::Enrolled { + version_id, + // A summary the form cannot read leaves it to ask again. + summary: response + .get("profile") + .and_then(|summary| serde_json::from_value(summary.clone()).ok()), + }), Some(false) => text("code").map(|code| Outcome::Refused { code }), None => None, } .ok_or_else(|| "The Sandbox manager's reply was malformed.".into()) } -/// The outcome in the `done` reply to `request_id` among `replies`, +/// The response in the `done` reply to `request_id` among `replies`, /// skipping `accepted` and `progress` and whatever `open` refuses. pub async fn done( mut replies: impl Stream + Unpin, request_id: &str, open: impl Fn(&T) -> Option, -) -> Result { +) -> Result { while let Some(item) = replies.next().await { let Some(reply) = open(&item) else { continue; }; if reply.request_id == request_id && reply.status == Status::Done { - return outcome(reply.response.as_ref().unwrap_or(&Value::Null)); + return Ok(reply.response.unwrap_or(Value::Null)); } } Err("The community connection closed before the Sandbox manager answered.".into()) } -/// Signs the `enroll` request for `profile` to `manager` as `identity`, -/// sends it on `session`'s live socket, and waits up to [`TIMEOUT`] for -/// its `done` reply. -/// Enrolls the form's `values` as `identity`: reads the GitHub token's -/// account, creates the agent-only signing key, and sends the profile. Once -/// the manager stores it, the key's public half goes to that account with the -/// local `gh`, or back to the developer to add; the token needs no account +/// Enrolls the form's `values` as `identity`. +/// +/// With nothing on file, it reads the GitHub token's account, creates the +/// agent-only signing key, and sends the complete profile. With a profile on +/// file, it sends only what changes: a blank GitHub token keeps the stored +/// one, and the stored signing key stays unless `replace_key`. `removed` +/// names stored model credentials to drop. Once the manager stores a new +/// signing key, its public half goes to the GitHub account with the local +/// `gh`, or back to the developer to add; the token needs no account /// permission either way. pub async fn run( session: Arc, identity: Arc, manager: String, values: Values, + on_file: Option, + replace_key: bool, + removed: Vec<&'static str>, ) -> Result<(Outcome, Option), String> { let token = values.get(Field::GithubToken).trim().to_owned(); - if token.is_empty() { + let github = if !token.is_empty() { + Some(signing::identity(&token).await?) + } else if on_file.as_ref().is_some_and(|summary| summary.github.token) { + None + } else { return Err("Enter a GitHub token.".into()); - } - let github = signing::identity(&token).await?; - let key = signing::generate(&format!("buzz agents {}", github.login)).await?; - let profile = profile(&values, &identity.pubkey_hex(), &github, &key.private)?; + }; + let login = github + .as_ref() + .map(|github| github.login.clone()) + .or_else(|| on_file.as_ref().map(|summary| summary.github.login.clone())) + .filter(|login| !login.is_empty()) + .ok_or("Enter a GitHub token.")?; + let new_key = replace_key + || !on_file + .as_ref() + .is_some_and(|summary| summary.git.signing_key); + let key = if new_key { + Some(signing::generate(&format!("buzz agents {login}")).await?) + } else { + None + }; + let owner = identity.pubkey_hex(); + let profile = match (&on_file, &github, &key) { + (None, Some(github), Some(key)) => profile(&values, &owner, github, &key.private)?, + (None, _, _) => return Err("Enter a GitHub token.".into()), + (Some(_), _, _) => partial( + &values, + &owner, + Changes { + github: github.as_ref(), + signing_key: key.as_ref().map(|key| key.private.as_str()), + removed: &removed, + }, + )?, + }; let outcome = enroll(session, identity, manager, profile).await?; - if !matches!(outcome, Outcome::Enrolled { .. }) { + let (Outcome::Enrolled { .. }, Some(key)) = (&outcome, key) else { return Ok((outcome, None)); - } + }; let title = format!( "Buzz agents ({})", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC") ); - let registration = signing::register(&github.login, &title, &key.public).await; + let registration = signing::register(&login, &title, &key.public).await; Ok((outcome, Some(registration))) } +/// Signs the `enroll` request for `profile` to `manager` as `identity`, +/// sends it on `session`'s live socket, and waits up to [`TIMEOUT`] for +/// its `done` reply. pub async fn enroll( session: Arc, identity: Arc, @@ -430,15 +802,47 @@ pub async fn enroll( ) -> Result { let manager = PublicKey::from_hex(&manager).map_err(|_| "The manager key is not valid.")?; let request_id = uuid::Uuid::new_v4().to_string(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |since| since.as_secs()); let request = identity - .enroll_request(&manager, &request_id, profile, now) + .enroll_request(&manager, &request_id, profile, now()) .map_err(|error| format!("Could not sign the enrollment ({}).", error.label()))?; if json!(["EVENT", request]).to_string().len() > MAX_PUBLICATION_FRAME { return Err("The profile is too large to send. Shorten the via profile.".into()); } + let response = ask(&session, &identity, &manager, request, &request_id).await?; + outcome(&response) +} + +/// What the manager has on file for `identity`'s developer, by the +/// `profile` request: `None` when nothing is stored. +pub async fn on_file( + session: Arc, + identity: Arc, + manager: String, +) -> Result, String> { + let manager = PublicKey::from_hex(&manager).map_err(|_| "The manager key is not valid.")?; + let request_id = uuid::Uuid::new_v4().to_string(); + let request = identity + .profile_request(&manager, &request_id, now()) + .map_err(|error| format!("Could not sign the request ({}).", error.label()))?; + let response = ask(&session, &identity, &manager, request, &request_id).await?; + on_file_reply(&response) +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +/// Sends `request` on `session`'s live socket and waits up to [`TIMEOUT`] +/// for the manager's `done` response to it. +async fn ask( + session: &Session, + identity: &Identity, + manager: &PublicKey, + request: nostr::prelude::Event, + request_id: &str, +) -> Result { let filter = json!({ "kinds": [KIND], "#p": [identity.pubkey_hex()], "#e": [request.id.to_hex()], }); @@ -467,9 +871,9 @@ pub async fn enroll( _ => None, }) }); - done(Box::pin(events), &request_id, |event| { + done(Box::pin(events), request_id, |event| { identity - .open_session_reply(event, &manager, &request.id) + .open_session_reply(event, manager, &request.id) .ok() }) .await diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_form.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_form.rs index 0c21c2adc3c..1fe328c36d7 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_form.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_form.rs @@ -1,15 +1,17 @@ //! The Enroll profile form in Settings: the developer profile the Sandbox -//! manager stores, sent as the signed-in owner. Its tokens are masked, +//! manager stores, sent as the signed-in owner. It shows what is on file, +//! and once a profile is, sends only what changes. Its tokens are masked, //! cleared once sent, and never saved. -use super::enroll::{Field, Outcome, Values}; +use super::enroll::{Field, OnFile, Outcome, Values}; +use super::settings::SettingsTab; use super::signing::Registration; use crate::agent_plugins::tabs::field; use crate::community::{AppState, IdentityState}; use buzz_identity::Identity; use buzz_relay_client::{RelayOrigin, Session}; use buzz_ui::{ - Button, ButtonSize, Colors, Surface, TextArea, TextField, TypeStyled, tokens::space, + Button, ButtonSize, Checkbox, Colors, Surface, TextArea, TextField, TypeStyled, tokens::space, }; use gpui_kit::component::input::{InputState, TextareaState}; use gpui_kit::*; @@ -24,6 +26,27 @@ pub struct EnrollForm { pub status: Option>, /// How the last enrollment's signing key reached GitHub. pub registration: Option, + /// What the manager holds. + pub on_file: OnFile, + /// Replace the stored signing key instead of keeping it. + pub replace_key: bool, + /// Stored model credentials to remove, by models key. + pub removed: Vec<&'static str>, +} + +/// The placeholder for `field`, by whether a value is on file. +fn placeholder(field: Field, held: bool) -> &'static str { + if held && field.secret() || held && field == Field::ViaProfile { + return "On file · leave blank to keep"; + } + match field { + Field::GitEmail => "A verified email on your GitHub account", + Field::ViaProfile => "via profile TOML", + Field::CodexAccessToken => "Optional · at-…", + Field::ClaudeOauthToken => "Optional · sk-ant-oat01-…", + Field::OpenaiApiKey | Field::AnthropicApiKey | Field::OpenrouterApiKey => "Optional", + _ => "", + } } impl EnrollForm { @@ -35,29 +58,65 @@ impl EnrollForm { .filter(|field| *field != Field::ViaProfile) .map(|field| { let state = cx.new(|cx| { - let state = InputState::new(window, cx).masked(field.secret()); - match field { - Field::GitEmail => state - .placeholder("A verified email on your GitHub account") - .default_value(email.clone()), - Field::CodexAccessToken => state.placeholder("Optional · at-…"), - Field::ClaudeOauthToken => state.placeholder("Optional · sk-ant-oat01-…"), - Field::OpenaiApiKey | Field::AnthropicApiKey | Field::OpenrouterApiKey => { - state.placeholder("Optional") - } - _ => state, + let state = InputState::new(window, cx) + .masked(field.secret()) + .placeholder(placeholder(field, false)); + if field == Field::GitEmail { + state.default_value(email.clone()) + } else { + state } }); (field, state) }) .collect(); - let via = cx.new(|cx| TextareaState::new(window, cx).placeholder("via profile TOML")); + let via = cx.new(|cx| { + TextareaState::new(window, cx).placeholder(placeholder(Field::ViaProfile, false)) + }); Self { inputs, via, sending: false, status: None, registration: None, + on_file: OnFile::Loading, + replace_key: false, + removed: Vec::new(), + } + } + + /// Shows what is on file: on-file tokens keep their stored value when + /// left blank, and the Git email starts as the stored one. + pub fn show_on_file(&mut self, on_file: OnFile, window: &mut Window, cx: &mut App) { + let summary = on_file.summary().cloned().unwrap_or_default(); + let stored = on_file.summary().is_some(); + for (field, input) in &self.inputs { + let held = stored && summary.holds(*field); + input.update(cx, |state, cx| { + state.set_placeholder(placeholder(*field, held), window, cx); + if *field == Field::GitEmail && held { + state.set_value(summary.git.email.clone(), window, cx); + } + }); + } + let held = stored && summary.holds(Field::ViaProfile); + self.via.update(cx, |state, cx| { + state.set_placeholder(placeholder(Field::ViaProfile, held), window, cx) + }); + self.removed + .retain(|key| summary.models.iter().any(|stored| stored == key)); + if !summary.git.signing_key { + self.replace_key = false; + } + self.on_file = on_file; + } + + /// Marks the stored model credential `key` for removal, or keeps it. + pub fn toggle_removed(&mut self, key: &'static str) { + if let Some(at) = self.removed.iter().position(|removed| *removed == key) { + self.removed.remove(at); + } else { + self.removed.push(key); } } @@ -93,7 +152,13 @@ impl EnrollForm { sent } - pub fn render(&self, owner: Option<&str>, disabled: bool, c: &Colors) -> Div { + pub fn render( + &self, + owner: Option<&str>, + disabled: bool, + c: &Colors, + cx: &Context, + ) -> Div { let input = |which: Field| { let (_, state) = self .inputs @@ -138,9 +203,13 @@ impl EnrollForm { .child(div().text_body_sm().text_color(c.text_subtle).child( "Once per developer: every one of your Remote workspace agents uses it. Enrolling \ creates a signing key for your agents' commits; the GitHub CLI (gh) adds it \ - to your GitHub account, or the app shows you how. Sent encrypted to the \ - Sandbox manager and signed as you; tokens are never saved here.", + to your GitHub account, or the app shows you how. With a profile on file, \ + fill in only what changes: a blank field keeps what is stored. Sent \ + encrypted to the Sandbox manager and signed as you; tokens are never saved \ + here.", )) + .child(div().text_body_sm().child(self.on_file.text())) + .children(self.on_file_controls(disabled, c, cx)) .child(field( "Buzz owner", div() @@ -176,6 +245,76 @@ impl EnrollForm { } } +impl EnrollForm { + /// Remove or keep each stored model credential, and replace the stored + /// signing key. + fn on_file_controls( + &self, + disabled: bool, + c: &Colors, + cx: &Context, + ) -> Option
{ + let summary = self.on_file.summary()?; + let removals = summary.model_fields().into_iter().filter_map(|field| { + let key = field.model()?; + let removing = self.removed.contains(&key); + let label = if removing { + format!("Keep {}", field.label()) + } else { + format!("Remove {}", field.label()) + }; + Some( + Button::new( + SharedString::from(format!("remote-workspace-remove-{key}")), + label, + ) + .size(ButtonSize::Sm) + .outline() + .disabled(disabled) + .on_click(cx.listener(move |this, _, _, cx| { + this.enroll.toggle_removed(key); + cx.notify(); + })), + ) + }); + let removing = (!self.removed.is_empty()).then(|| { + let names: Vec<_> = self + .removed + .iter() + .filter_map(|key| Field::of_model(key)) + .map(Field::label) + .collect(); + div() + .text_body_sm() + .text_color(c.text_subtle) + .child(format!("Enrolling removes: {}.", names.join(", "))) + }); + let replace = summary.git.signing_key.then(|| { + let tab = cx.entity().downgrade(); + Checkbox::new("remote-workspace-replace-signing-key", self.replace_key) + .label("Replace the signing key") + .description("Creates a new key for your agents' commits and adds it to GitHub.") + .surface(Surface::Floating) + .disabled(disabled) + .on_change(move |checked, _, cx| { + let _ = tab.update(cx, |this, cx| { + this.enroll.replace_key = checked; + cx.notify(); + }); + }) + }); + Some( + div() + .flex() + .flex_col() + .gap(space::S2) + .child(div().flex().flex_wrap().gap(space::S2).children(removals)) + .children(removing) + .children(replace), + ) + } +} + /// Where the agents' signing key went: added with `gh`, or the steps for the /// developer to add it, so their agents' commits show as Verified. fn registration_notice(registration: &Registration, c: &Colors) -> Div { diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_tests.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_tests.rs index a8e9c87e944..6f6f035ec29 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_tests.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll_tests.rs @@ -227,11 +227,21 @@ async fn the_done_reply_maps_to_enrolled_or_its_refusal() { reply.clone() }) .await + .and_then(|response| outcome(&response)) }; assert_eq!( outcome_of(json!({"ok": true, "version_id": "v-2"})).await, Ok(Outcome::Enrolled { - version_id: "v-2".into() + version_id: "v-2".into(), + summary: None, + }) + ); + // The manager's summary of the merged profile comes with it. + assert_eq!( + outcome_of(json!({"ok": true, "version_id": "v-3", "profile": summary_json()})).await, + Ok(Outcome::Enrolled { + version_id: "v-3".into(), + summary: Some(summary()), }) ); for (code, _) in REFUSALS { @@ -310,3 +320,138 @@ fn a_gh_failure_says_what_to_do() { assert!(signing::gh_failure(stderr).contains(hint), "{stderr}"); } } + +/// The manager's summary of a stored profile, as it sends it. +fn summary_json() -> Value { + json!({ + "version_id": "v-1", + "updated_at": "2026-10-23T12:00:00Z", + "git": {"name": "Tee Eight", "email": "tee@example.com", "signing_key": true}, + "github": {"login": "tee8z", "token": true}, + "via": {"profile": true, "op_token": true}, + "models": ["claude_oauth_token"], + }) +} + +fn summary() -> Summary { + serde_json::from_value(summary_json()).unwrap() +} + +/// With a profile on file, only filled fields are sent, a blank GitHub token +/// and the signing key are kept, and a removed credential is `null`. +#[test] +fn a_change_over_a_stored_profile_sends_only_what_changes() { + let mut values = Values::default(); + values.set(Field::CodexAccessToken, " at-fixtureCodexToken ".into()); + let sent = partial( + &values, + OWNER, + Changes { + removed: &["claude_oauth_token"], + ..Changes::default() + }, + ) + .unwrap(); + assert_eq!( + sent, + json!({ + "version": 1, + "developer": "", + "owner_pubkey": OWNER, + "models": {"codex_access_token": "at-fixtureCodexToken", "claude_oauth_token": null}, + }) + ); + // A new token sends its account and the Git name; a new key is sent too. + let mut values = Values::default(); + values.set(Field::GithubToken, "ghp_fixtureGithubToken".into()); + let account = github(); + let sent = partial( + &values, + OWNER, + Changes { + github: Some(&account), + signing_key: Some(KEY), + removed: &[], + }, + ) + .unwrap(); + assert_eq!( + sent["github"], + json!({"login": "tee8z", "token": "ghp_fixtureGithubToken"}) + ); + assert_eq!( + sent["git"], + json!({"name": "Tee Eight", "ssh_signing_key": KEY}) + ); + assert!(sent.get("via").is_none() && sent.get("models").is_none()); + // Nothing filled and nothing removed is not sent. + assert_eq!( + partial(&Values::default(), OWNER, Changes::default()), + Err("Nothing to change: fill in a field, or remove a credential.".into()) + ); +} + +/// Each field a change sends passes `profile.py`'s rule for it. +#[test] +fn a_change_is_refused_as_profile_py_refuses_its_fields() { + let refused = |field, text: &str| { + let mut values = Values::default(); + values.set(field, text.into()); + partial(&values, OWNER, Changes::default()).unwrap_err() + }; + assert_eq!( + refused(Field::CodexAccessToken, "sk-notAnAccessToken"), + "Use a Codex access token (at-...)" + ); + assert_eq!( + refused(Field::OpToken, "ops with space"), + "Invalid credential" + ); + assert_eq!( + refused(Field::ViaProfile, "not = [toml"), + "Invalid via profile" + ); + let mut both = Values::default(); + both.set(Field::OpenaiApiKey, "sk-fixtureOpenaiKey".into()); + both.set(Field::CodexAccessToken, "at-fixtureCodexToken".into()); + assert_eq!( + partial(&both, OWNER, Changes::default()).unwrap_err(), + "Choose either an OpenAI API key or a Codex access token" + ); + assert_eq!( + validate_partial(&json!({"version": 1, "owner_pubkey": OWNER, + "models": {"gemini_api_key": null}})), + Err("Unsupported model credential type".into()) + ); +} + +/// The `profile` reply: the summary, nothing on file, or the refusal. +#[test] +fn the_profile_reply_names_what_is_on_file() { + assert_eq!( + on_file_reply(&json!({"ok": true, "profile": summary_json()})), + Ok(Some(summary())) + ); + assert_eq!( + on_file_reply(&json!({"ok": true, "profile": null})), + Ok(None) + ); + let refused = + on_file_reply(&json!({"ok": false, "code": "unauthorized_owner", "error": "No."})); + assert!( + refused + .unwrap_err() + .starts_with("Refused (unauthorized_owner). ") + ); + assert!(on_file_reply(&json!("done")).is_err()); + let summary = summary(); + assert!(summary.holds(Field::GithubToken) && summary.holds(Field::ClaudeOauthToken)); + assert!(!summary.holds(Field::CodexAccessToken)); + assert_eq!(summary.model_fields(), [Field::ClaudeOauthToken]); + assert_eq!( + OnFile::Stored(summary).text(), + "On file: GitHub @tee8z · Git email tee@example.com · signing key · via profile · \ + 1Password token · Claude subscription token. Updated 2026-10-23 12:00 UTC." + ); + assert_eq!(OnFile::Nothing.text(), "Nothing enrolled yet."); +} diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs index 118232cef9c..0b3c0aff874 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs @@ -4,6 +4,7 @@ //! manager over its community's relay, with the build's manager key. use super::config::{Config, EFFORTS, MANAGER_PUBKEY, RUNTIMES}; +use super::enroll::{OnFile, Outcome}; use super::enroll_form::{self, EnrollForm}; use crate::agent_plugins::tabs::{ Picking, choice, field, instructions_field, instructions_state, options, saved_config, @@ -35,7 +36,7 @@ pub struct SettingsTab { fast: bool, env: Entity, instructions: Entity, - enroll: EnrollForm, + pub(super) enroll: EnrollForm, open: Option, saving: bool, error: Option, @@ -128,7 +129,7 @@ impl SettingsTab { } })); } - Self { + let mut tab = Self { view, runtime: saved.runtime.clone(), effort: saved.effort.clone(), @@ -142,7 +143,9 @@ impl SettingsTab { saving: false, error: None, _subscriptions: subscriptions, - } + }; + tab.load_on_file(window, cx); + tab } fn reset(&mut self, saved: Config, window: &mut Window, cx: &mut Context) { @@ -204,9 +207,47 @@ impl SettingsTab { } impl SettingsTab { + /// Asks the Sandbox manager what is on file for the signed-in owner. + fn load_on_file(&mut self, window: &mut Window, cx: &mut Context) { + let started = (|| { + let owner = enroll_form::owner(cx).ok_or("Sign in first.")?; + let session = enroll_form::session(&self.view.agent.relay, cx) + .ok_or("Join the community at the relay first.")?; + Ok::<_, String>((owner, session)) + })(); + let (owner, session) = match started { + Ok(started) => started, + Err(error) => { + self.enroll.show_on_file(OnFile::Unknown(error), window, cx); + cx.notify(); + return; + } + }; + self.enroll.show_on_file(OnFile::Loading, window, cx); + let asked = crate::runtime::Runtime::handle(cx).spawn(super::enroll::on_file( + session, + owner, + MANAGER_PUBKEY.to_owned(), + )); + cx.spawn_in(window, async move |this, cx| { + let on_file = match asked.await { + Ok(Ok(Some(summary))) => OnFile::Stored(summary), + Ok(Ok(None)) => OnFile::Nothing, + Ok(Err(error)) => OnFile::Unknown(error), + Err(_) => OnFile::Unknown("The request was cancelled.".into()), + }; + let _ = this.update_in(cx, |this, window, cx| { + this.enroll.show_on_file(on_file, window, cx); + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + /// Sends the enroll form to the Sandbox manager on the agent's /// community, as the signed-in owner. Its tokens are cleared at once, - /// sent or not. + /// sent or not. With a profile on file, only what changes is sent. fn enroll(&mut self, window: &mut Window, cx: &mut Context) { let relay = self.view.agent.relay.clone(); let owner = enroll_form::owner(cx); @@ -233,17 +274,31 @@ impl SettingsTab { owner, MANAGER_PUBKEY.to_owned(), values, + self.enroll.on_file.summary().cloned(), + self.enroll.replace_key, + self.enroll.removed.clone(), )); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { let outcome = sent .await .unwrap_or_else(|_| Err("The enrollment was cancelled.".into())); - let _ = this.update(cx, |this, cx| { + let _ = this.update_in(cx, |this, window, cx| { this.enroll.sending = false; let (status, registration) = match outcome { Ok((outcome, registration)) => (Ok(outcome), registration), Err(error) => (Err(error), None), }; + if let Ok(Outcome::Enrolled { summary, .. }) = &status { + this.enroll.removed.clear(); + this.enroll.replace_key = false; + match summary.clone() { + Some(summary) => { + this.enroll + .show_on_file(OnFile::Stored(summary), window, cx) + } + None => this.load_on_file(window, cx), + } + } this.enroll.status = Some(status); this.enroll.registration = registration; cx.notify(); @@ -376,17 +431,33 @@ impl Render for SettingsTab { .map(|this| { let sending = self.enroll.sending; let owner = enroll_form::owner(cx).map(|owner| owner.pubkey_hex()); - this.child(self.enroll.render(owner.as_deref(), sending, &c)) + let unknown = matches!(self.enroll.on_file, OnFile::Unknown(_)); + this.child(self.enroll.render(owner.as_deref(), sending, &c, cx)) .child( - div().flex().child( - Button::new( - "remote-workspace-enroll", - if sending { "Enrolling…" } else { "Enroll profile" }, + div() + .flex() + .gap(space::S2) + .child( + Button::new( + "remote-workspace-enroll", + if sending { "Enrolling…" } else { "Enroll profile" }, + ) + .loading(sending) + .disabled(sending) + .on_click( + cx.listener(|this, _, window, cx| this.enroll(window, cx)), + ), ) - .loading(sending) - .disabled(sending) - .on_click(cx.listener(|this, _, window, cx| this.enroll(window, cx))), - ), + .when(unknown, |row| { + row.child( + Button::new("remote-workspace-check-on-file", "Check again") + .outline() + .disabled(sending) + .on_click(cx.listener(|this, _, window, cx| { + this.load_on_file(window, cx) + })), + ) + }), ) }) .children( diff --git a/apps/buzz-gpui/crates/identity/src/session_control.rs b/apps/buzz-gpui/crates/identity/src/session_control.rs index a1bba68bda6..d82b475feb5 100644 --- a/apps/buzz-gpui/crates/identity/src/session_control.rs +++ b/apps/buzz-gpui/crates/identity/src/session_control.rs @@ -20,7 +20,31 @@ impl Identity { created_at: u64, ) -> Result { let payload = serde_json::json!({ "profile": profile }); - let request = Request::new(Op::Enroll, request_id, payload); + self.owner_request( + manager, + Request::new(Op::Enroll, request_id, payload), + created_at, + ) + } + + /// The owner-signed `profile` request: what the developer's stored + /// profile holds, naming no value. Encrypted like [`Self::enroll_request`]. + pub fn profile_request( + &self, + manager: &PublicKey, + request_id: &str, + created_at: u64, + ) -> Result { + let request = Request::new(Op::Profile, request_id, serde_json::json!({})); + self.owner_request(manager, request, created_at) + } + + fn owner_request( + &self, + manager: &PublicKey, + request: Request, + created_at: u64, + ) -> Result { let event = control::build_request( &self.wire_keys()?, &wire_key(manager)?, diff --git a/crates/buzz-sandbox-reaper/src/enroll.rs b/crates/buzz-sandbox-reaper/src/enroll.rs index d07e3a86ef1..3890854b017 100644 --- a/crates/buzz-sandbox-reaper/src/enroll.rs +++ b/crates/buzz-sandbox-reaper/src/enroll.rs @@ -27,10 +27,11 @@ const GITHUB_TIMEOUT: Duration = Duration::from_secs(10); const MAX_GITHUB_BODY: usize = 256 * 1024; /// Enrollment steps 2 to 4, in order: the profile names the signing owner -/// and that owner's developer, and passes `profile.py`'s validation. -/// A profile without a developer ID gets the signing owner's, so a +/// and that owner's developer, and has a partial profile's shape +/// ([`profile::check_partial`]). The merged profile is validated when the +/// job runs. A profile without a developer ID gets the signing owner's, so a /// developer never needs to know the Terraform key they are listed under. -/// Returns the profile to store, or the refusal. +/// Returns the profile to merge, or the refusal. pub fn admit_profile( payload: &Value, owner: &PublicKey, @@ -62,7 +63,7 @@ pub fn admit_profile( "the profile's developer is not the signing owner's developer ID", )); } - profile::validate(&profile) + profile::check_partial(&profile) .map_err(|profile::Invalid(message)| (ErrorCode::InvalidProfile, message))?; Ok(profile) } @@ -71,10 +72,26 @@ pub fn admit_profile( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StoreFailed(pub &'static str); -/// Where profiles are written. A trait because Secrets Manager is a real -/// external boundary: production uses [`SecretsManagerStore`], tests an +/// The current version of a developer's profile secret. +pub struct Stored { + pub text: Zeroizing, + pub version_id: String, + /// When the version was written, as RFC 3339. + pub created: Option, +} + +/// Where profiles are read and written. A trait because Secrets Manager is a +/// real external boundary: production uses [`SecretsManagerStore`], tests an /// in-memory store. pub trait ProfileStore: Send + Sync { + /// The current version of the secret `arn` in `region`, or `None` when + /// it has no value yet. + fn get<'a>( + &'a self, + arn: &'a str, + region: &'a str, + ) -> Pin, StoreFailed>> + Send + 'a>>; + /// Write `profile` as the new current version of the secret `arn` in /// `region`, and return the version id. fn put<'a>( @@ -85,8 +102,8 @@ pub trait ProfileStore: Send + Sync { ) -> Pin> + Send + 'a>>; } -/// Secrets Manager `PutSecretValue` with the ambient credentials (IRSA -/// in-cluster). It writes no files. +/// Secrets Manager `GetSecretValue` and `PutSecretValue` with the ambient +/// credentials (IRSA in-cluster). It writes no files. pub struct SecretsManagerStore { shared: aws_config::SdkConfig, } @@ -106,7 +123,55 @@ impl SecretsManagerStore { } } +impl SecretsManagerStore { + fn client(&self, region: &str) -> aws_sdk_secretsmanager::Client { + let config = aws_sdk_secretsmanager::config::Builder::from(&self.shared) + .region(aws_sdk_secretsmanager::config::Region::new( + region.to_string(), + )) + .build(); + aws_sdk_secretsmanager::Client::from_conf(config) + } +} + impl ProfileStore for SecretsManagerStore { + fn get<'a>( + &'a self, + arn: &'a str, + region: &'a str, + ) -> Pin, StoreFailed>> + Send + 'a>> { + Box::pin(async move { + let output = match self + .client(region) + .get_secret_value() + .secret_id(arn) + .send() + .await + { + Ok(output) => output, + // The secret exists but holds no version until the first enroll. + Err(SdkError::ServiceError(service)) + if service.err().code() == Some("ResourceNotFoundException") => + { + return Ok(None) + } + Err(error) => return Err(store_error(error)), + }; + let created = output.created_date().and_then(|at| { + at.fmt(aws_sdk_secretsmanager::primitives::DateTimeFormat::DateTime) + .ok() + }); + match (output.secret_string(), output.version_id()) { + (Some(text), Some(version_id)) => Ok(Some(Stored { + text: Zeroizing::new(text.to_string()), + version_id: version_id.to_string(), + created, + })), + _ => Ok(None), + } + }) + } + fn put<'a>( &'a self, arn: &'a str, @@ -114,12 +179,8 @@ impl ProfileStore for SecretsManagerStore { profile: &'a str, ) -> Pin> + Send + 'a>> { Box::pin(async move { - let config = aws_sdk_secretsmanager::config::Builder::from(&self.shared) - .region(aws_sdk_secretsmanager::config::Region::new( - region.to_string(), - )) - .build(); - let output = aws_sdk_secretsmanager::Client::from_conf(config) + let output = self + .client(region) .put_secret_value() .secret_id(arn) .secret_string(profile) @@ -218,38 +279,95 @@ impl Enrollment { Self { github, store } } - /// Check the token's GitHub login, then write the profile to the - /// developer's secret and answer with the new version id. The profile is - /// dropped, and its serialized copy zeroized, before this returns. - pub async fn run(&self, developer: &Developer, profile: Value) -> Value { - // Both are strings: the profile passed validation at admission. - let github = &profile["github"]; - let token = github["token"].as_str().unwrap_or_default(); - let login = github["login"].as_str().unwrap_or_default(); - match self.github.login(token).await { - Ok(actual) if actual.eq_ignore_ascii_case(login) => {} - Ok(_) => { - return refusal( - ErrorCode::GithubLoginMismatch, - "the profile's GitHub token belongs to a different login", - ) + /// The developer's stored profile, parsed. An unreadable stored value + /// counts as none, so the next enroll must send a complete profile. + async fn stored(&self, developer: &Developer) -> Result, Value> { + match self + .store + .get(&developer.profile_secret_arn, &developer.region) + .await + { + Ok(Some(stored)) => Ok(serde_json::from_str(&stored.text) + .ok() + .map(|profile| (profile, stored))), + Ok(None) => Ok(None), + Err(StoreFailed(label)) => Err(refusal( + ErrorCode::ProfileStoreFailed, + &format!("could not read the stored profile ({label}); retry"), + )), + } + } + + /// Merge `partial` into the stored profile, validate the result, check a + /// new GitHub token's login, then write it and answer with the new + /// version id and its [`profile::summary`]. Every profile copy is + /// dropped, and the serialized one zeroized, before this returns. + pub async fn run(&self, developer: &Developer, partial: Value) -> Value { + let stored = match self.stored(developer).await { + Ok(stored) => stored.map(|(profile, _)| profile), + Err(refused) => return refused, + }; + if stored.is_none() + && ["git", "github", "via"] + .iter() + .any(|f| partial.get(f).is_none()) + { + return refusal( + ErrorCode::InvalidProfile, + "no profile is on file yet; send the complete profile", + ); + } + let new_token = stored.is_none() || partial.get("github").is_some(); + let profile = profile::merge(stored, partial); + if let Err(profile::Invalid(message)) = profile::validate(&profile) { + return refusal(ErrorCode::InvalidProfile, message); + } + if new_token { + // Both are strings: the merged profile passed validation. + let github = &profile["github"]; + let token = github["token"].as_str().unwrap_or_default(); + let login = github["login"].as_str().unwrap_or_default(); + match self.github.login(token).await { + Ok(actual) if actual.eq_ignore_ascii_case(login) => {} + Ok(_) => { + return refusal( + ErrorCode::GithubLoginMismatch, + "the profile's GitHub token belongs to a different login", + ) + } + Err(message) => return refusal(ErrorCode::GithubLoginMismatch, message), } - Err(message) => return refusal(ErrorCode::GithubLoginMismatch, message), } let text = Zeroizing::new(profile.to_string()); - drop(profile); match self .store .put(&developer.profile_secret_arn, &developer.region, &text) .await { - Ok(version_id) => serde_json::json!({"ok": true, "version_id": version_id}), + Ok(version_id) => { + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let summary = profile::summary(&profile, &version_id, Some(now)); + serde_json::json!({"ok": true, "version_id": version_id, "profile": summary}) + } Err(StoreFailed(label)) => refusal( ErrorCode::ProfileStoreFailed, &format!("could not store the profile ({label}); retry"), ), } } + + /// The `profile` operation: the stored profile's [`profile::summary`], + /// or `null` when none is stored. + pub async fn describe(&self, developer: &Developer) -> Value { + match self.stored(developer).await { + Ok(Some((profile, stored))) => serde_json::json!({ + "ok": true, + "profile": profile::summary(&profile, &stored.version_id, stored.created), + }), + Ok(None) => serde_json::json!({"ok": true, "profile": null}), + Err(refused) => refused, + } + } } #[cfg(test)] diff --git a/crates/buzz-sandbox-reaper/src/enroll_tests.rs b/crates/buzz-sandbox-reaper/src/enroll_tests.rs index a6a91089dbd..66d38067e75 100644 --- a/crates/buzz-sandbox-reaper/src/enroll_tests.rs +++ b/crates/buzz-sandbox-reaper/src/enroll_tests.rs @@ -18,6 +18,25 @@ pub(crate) struct FakeStore { } impl ProfileStore for FakeStore { + /// The last write is the current version. + fn get<'a>( + &'a self, + _arn: &'a str, + _region: &'a str, + ) -> Pin, StoreFailed>> + Send + 'a>> { + Box::pin(async move { + if let Some(label) = self.fail { + return Err(StoreFailed(label)); + } + let written = self.written.lock().unwrap(); + Ok(written.last().map(|(_, _, text)| Stored { + text: Zeroizing::new(text.clone()), + version_id: format!("version-{}", written.len()), + created: Some("2026-10-23T12:00:00Z".into()), + })) + }) + } + fn put<'a>( &'a self, arn: &'a str, @@ -110,7 +129,12 @@ async fn enroll(github: &[(&str, &str)], fail: Option<&'static str>) -> (Value, async fn a_verified_profile_is_written_to_the_developers_secret() { // GitHub logins are case-insensitive. let (response, written) = enroll(&[("ghp_fixtureGitHubTokenDoNotLeak", "Ada-L")], None).await; - assert_eq!(response, json!({"ok": true, "version_id": "version-1"})); + assert_eq!(response["ok"], true); + assert_eq!(response["version_id"], "version-1"); + assert_eq!( + response["profile"]["github"], + json!({"login": "ada-l", "token": true}) + ); let written = written.lock().unwrap(); assert_eq!(written.len(), 1); let (arn, region, text) = &written[0]; @@ -177,3 +201,136 @@ fn a_profile_without_a_developer_gets_the_owners() { let admitted = admit_profile(&json!({"profile": sent}), &owner, &developer()).unwrap(); assert_eq!(admitted["developer"], "dev"); } + +/// An enrollment with a fake GitHub that knows `tokens`, and the store. +async fn enrollment(tokens: &[(&str, &str)]) -> (Enrollment, Written) { + let written = Written::default(); + let enrollment = Enrollment::new( + GitHub::new(&fake_github(tokens).await).unwrap(), + Arc::new(FakeStore { + written: written.clone(), + fail: None, + }), + ); + (enrollment, written) +} + +fn stored(written: &Written) -> Value { + serde_json::from_str(&written.lock().unwrap().last().unwrap().2).unwrap() +} + +/// A partial profile keeps what it does not name, and needs no GitHub check +/// when it carries no new token. +#[tokio::test] +async fn a_partial_profile_keeps_every_field_it_does_not_name() { + // GitHub knows the first token only: the partial enroll must not ask it. + let (enrollment, written) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; + assert_eq!(enrollment.run(&developer(), profile()).await["ok"], true); + let partial = json!({"version": 1, "developer": "dev", "owner_pubkey": "a".repeat(64), + "models": {"codex_access_token": "at-fixtureCodexTokenDoNotLeak"}}); + let response = enrollment.run(&developer(), partial).await; + assert_eq!(response["ok"], true, "{response}"); + let mut expected = profile(); + expected["models"]["codex_access_token"] = json!("at-fixtureCodexTokenDoNotLeak"); + assert_eq!(stored(&written), expected); + assert_eq!( + response["profile"]["models"], + json!(["anthropic_api_key", "codex_access_token"]) + ); +} + +/// `null` removes a model credential, and setting one of an exclusive pair +/// replaces the other. +#[tokio::test] +async fn null_removes_a_model_credential_and_a_pair_replaces_its_other() { + let (enrollment, written) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; + enrollment.run(&developer(), profile()).await; + let owner = "a".repeat(64); + let partial = |models: Value| json!({"version": 1, "developer": "dev", "owner_pubkey": owner, "models": models}); + enrollment + .run( + &developer(), + partial(json!({"claude_oauth_token": "sk-ant-oat01-fixture"})), + ) + .await; + assert_eq!( + stored(&written)["models"], + json!({"claude_oauth_token": "sk-ant-oat01-fixture"}) + ); + enrollment + .run(&developer(), partial(json!({"claude_oauth_token": null}))) + .await; + assert_eq!(stored(&written)["models"], json!({})); +} + +/// A new token is checked against GitHub, and the merged profile is +/// validated, before anything is written. +#[tokio::test] +async fn a_new_token_and_the_merged_profile_are_checked_before_the_write() { + let (enrollment, written) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; + enrollment.run(&developer(), profile()).await; + let owner = "a".repeat(64); + let response = enrollment + .run( + &developer(), + json!({"version": 1, "developer": "dev", "owner_pubkey": owner, + "github": {"login": "ada-l", "token": "ghp_unknownToGitHub"}}), + ) + .await; + assert_eq!(response["code"], "github_login_mismatch", "{response}"); + let response = enrollment + .run( + &developer(), + json!({"version": 1, "developer": "dev", "owner_pubkey": owner, + "git": {"email": "not\nan email"}}), + ) + .await; + assert_eq!(response["error"], "Invalid Git identity", "{response}"); + assert_eq!(written.lock().unwrap().len(), 1); +} + +/// With nothing stored, a partial profile is refused. +#[tokio::test] +async fn a_first_enroll_must_send_the_complete_profile() { + let (enrollment, written) = enrollment(&[]).await; + let response = enrollment + .run( + &developer(), + json!({"version": 1, "developer": "dev", "owner_pubkey": "a".repeat(64), + "models": {"codex_access_token": "at-fixture"}}), + ) + .await; + assert_eq!(response["code"], "invalid_profile"); + assert!(response["error"] + .as_str() + .unwrap() + .contains("send the complete profile")); + assert!(written.lock().unwrap().is_empty()); +} + +/// `profile` describes the stored profile, naming no credential value. +#[tokio::test] +async fn describe_names_what_is_stored_and_no_credential() { + let (enrollment, _) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; + assert_eq!( + enrollment.describe(&developer()).await, + json!({"ok": true, "profile": null}) + ); + enrollment.run(&developer(), profile()).await; + let described = enrollment.describe(&developer()).await; + assert_eq!( + described, + json!({"ok": true, "profile": { + "version_id": "version-1", + "updated_at": "2026-10-23T12:00:00Z", + "git": {"name": "Ada Lovelace", "email": "ada@example.com", "signing_key": true}, + "github": {"login": "ada-l", "token": true}, + "via": {"profile": true, "op_token": true}, + "models": ["anthropic_api_key"], + }}) + ); + let text = described.to_string(); + for secret in ["DoNotLeak", "PRIVATE KEY", "providers.op"] { + assert!(!text.contains(secret), "{secret}"); + } +} diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index 95892f96338..5bb232017b8 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -254,11 +254,14 @@ pub struct Job { pub enum Work { /// A provider protocol request, against the owner's namespace. Provider { request: Request, target: Target }, - /// Steps 5 and 6 of enrollment: the GitHub login check and the write. + /// Steps 5 and 6 of enrollment: the merge, the GitHub login check and + /// the write. Enroll { developer: Developer, profile: serde_json::Value, }, + /// The `profile` operation: describe the developer's stored profile. + Profile { developer: Developer }, } impl Work { @@ -271,6 +274,7 @@ impl Work { Work::Enroll { developer, profile } => { manager.enrollment.run(&developer, profile).await } + Work::Profile { developer } => manager.enrollment.describe(&developer).await, } } } @@ -433,6 +437,13 @@ impl Manager { }, Err((code, message)) => return refused(code, message), } + } else if op == Op::Profile { + if opened.request.payload != serde_json::json!({}) { + return refused(ErrorCode::InvalidProfile, "the profile payload must be {}"); + } + Work::Profile { + developer: developer.clone(), + } } else { match self.payload(&opened) { // 6. The namespace comes from the verified owner, never the request. @@ -568,12 +579,12 @@ impl Manager { /// Steps 2 and 7: the verified owner. fn owner(&self, opened: &OpenedRequest) -> Result { - if opened.request.op == Op::Enroll { - // Enrollment is owner-level: an agent key's attestation is not enough. + if matches!(opened.request.op, Op::Enroll | Op::Profile) { + // The profile is owner-level: an agent key's attestation is not enough. if opened.auth_tag.is_some() { return Err(( ErrorCode::SignerMismatch, - "enroll must be signed by the owner key itself", + "enroll and profile must be signed by the owner key itself", )); } return Ok(opened.signer); diff --git a/crates/buzz-sandbox-reaper/src/manager_tests.rs b/crates/buzz-sandbox-reaper/src/manager_tests.rs index 04e93c97cc1..fc810d7bb7d 100644 --- a/crates/buzz-sandbox-reaper/src/manager_tests.rs +++ b/crates/buzz-sandbox-reaper/src/manager_tests.rs @@ -307,14 +307,16 @@ fn profile(id: &str, owner: &Keys) -> Value { } /// Step 7 and enrollment steps 1 to 4: signed by a configured owner key -/// itself, for that owner and its own developer, with a valid profile. +/// itself, for that owner and its own developer, with a partial profile's +/// shape. The merged profile's values are validated when the job runs. /// Each refusal has its own code. #[tokio::test] async fn enroll_is_refused_unless_the_profile_is_the_signing_owners() { let manager = manager(); let mine = || profile("dev", &owner()); + // A new GitHub token without its login. let mut invalid = mine(); - invalid["github"]["login"] = json!("-not-a-login"); + invalid["github"].as_object_mut().unwrap().remove("login"); let mut without_developer = mine(); without_developer .as_object_mut() @@ -358,9 +360,16 @@ async fn enroll_is_refused_unless_the_profile_is_the_signing_owners() { "start" ); } + // A partial profile is admitted: its merge runs as the job. + let mut partial = mine(); + partial.as_object_mut().unwrap().remove("via"); + assert_eq!( + admit(&self::manager(), &enroll(owner(), None, partial)), + "start" + ); // The refusal is the image's message, which names no value. let mut incomplete = mine(); - incomplete.as_object_mut().unwrap().remove("via"); + incomplete["via"]["unknown"] = json!("x"); let invalid = manager.admit(&enroll(owner(), None, incomplete).event(), now()); let Admission::Done { response, .. } = invalid else { panic!("not refused"); @@ -389,6 +398,44 @@ async fn enroll_runs_for_the_signing_owners_developer() { assert_eq!(profile["developer"], "another-dev"); } +/// `profile` is owner-level like `enroll`, carries an empty payload, and +/// describes the signing owner's developer. +#[tokio::test] +async fn profile_runs_only_for_the_signing_owner_with_an_empty_payload() { + let profile = |signer: Keys, auth: Option, payload: Value| Sent { + signer, + op: Op::Profile, + payload, + auth, + ..Sent::deploy() + }; + for (sent, expected) in [ + ( + profile(agent(), Some(tag(&owner(), &agent(), "")), json!({})), + "signer_mismatch", + ), + ( + profile(other_agent(), None, json!({})), + "unauthorized_owner", + ), + ( + profile(owner(), None, json!({"profile": {}})), + "invalid_profile", + ), + ] { + assert_eq!(admit(&manager(), &sent), expected); + } + let Admission::Start { job, .. } = + manager().admit(&profile(owner(), None, json!({})).event(), now()) + else { + panic!("not started"); + }; + let Work::Profile { developer } = job.work else { + panic!("not a profile request"); + }; + assert_eq!(developer.id, "dev"); +} + /// An unknown envelope version or op is refused with its request id. #[tokio::test] async fn unknown_versions_and_ops_are_refused_with_their_request_id() { diff --git a/crates/buzz-sandbox-reaper/src/profile.rs b/crates/buzz-sandbox-reaper/src/profile.rs index 182290a2c33..d3c4d17ce24 100644 --- a/crates/buzz-sandbox-reaper/src/profile.rs +++ b/crates/buzz-sandbox-reaper/src/profile.rs @@ -226,6 +226,130 @@ fn python_string_len(value: &str) -> usize { .sum::() } +/// The sections of a profile and the keys each holds. +const SECTIONS: &[(&str, &[&str])] = &[ + ("git", &["name", "email", "ssh_signing_key"]), + ("github", &["login", "token"]), + ("via", &["profile", "op_token"]), +]; + +/// Model credentials a profile may hold only one of, each with its other. +const EXCLUSIVE_MODELS: &[(&str, &str)] = &[ + ("openai_api_key", "codex_access_token"), + ("anthropic_api_key", "claude_oauth_token"), +]; + +/// The shape of a partial profile `enroll` merges into the stored one: +/// `profile.py`'s fields, each optional, with `null` removing a model +/// credential. [`validate`] checks the merged profile's values. +pub fn check_partial(profile: &Value) -> Result<(), Invalid> { + const INCOMPLETE: &str = "Profile fields are incomplete or unsupported"; + let top = profile.as_object().ok_or(Invalid(INCOMPLETE))?; + require(top.keys().all(|k| FIELDS.contains(&k.as_str())), INCOMPLETE)?; + let version = top.get("version").unwrap_or(&Value::Null); + require( + (version.is_u64() || version.is_i64()) && version.as_i64() == Some(1), + "Unsupported profile version", + )?; + for (section, keys) in SECTIONS { + if let Some(value) = top.get(*section) { + let map = value.as_object().ok_or(Invalid(INCOMPLETE))?; + require(map.keys().all(|k| keys.contains(&k.as_str())), INCOMPLETE)?; + } + } + // A new token is checked against its login, so the two travel together. + if let Some(github) = top.get("github") { + require( + github.get("login").is_some() && github.get("token").is_some(), + "A GitHub token needs its login", + )?; + } + if let Some(models) = top.get("models") { + let ok = models.as_object().is_some_and(|models| { + models.iter().all(|(key, value)| { + MODEL_FIELDS.contains(&key.as_str()) && (value.is_string() || value.is_null()) + }) + }); + require(ok, "Unsupported model credential type")?; + } + Ok(()) +} + +/// `partial` over `stored`: a field `partial` names replaces the stored one, +/// and any other is kept. In `models`, `null` removes a credential, and +/// setting one of an exclusive pair removes the other. +pub fn merge(stored: Option, partial: Value) -> Value { + let mut merged = stored + .filter(Value::is_object) + .unwrap_or_else(|| Value::Object(Map::new())); + let Value::Object(partial) = partial else { + return merged; + }; + for (field, value) in partial { + let section = SECTIONS.iter().any(|(name, _)| *name == field); + if !section && field != "models" { + merged[&field] = value; + continue; + } + if !merged[&field].is_object() { + merged[&field] = Value::Object(Map::new()); + } + let (Some(target), Value::Object(values)) = (merged[&field].as_object_mut(), value) else { + continue; + }; + for (key, value) in values { + if field != "models" { + target.insert(key, value); + } else if value.is_null() { + target.remove(&key); + } else { + for (one, other) in EXCLUSIVE_MODELS { + if key == *one { + target.remove(*other); + } else if key == *other { + target.remove(*one); + } + } + target.insert(key, value); + } + } + } + // A profile may hold no model credentials. + if !merged["models"].is_object() { + merged["models"] = Value::Object(Map::new()); + } + merged +} + +/// What `profile` holds, for its owner: the GitHub login and Git identity, +/// and which credentials are present. It names no credential value. +pub fn summary(profile: &Value, version_id: &str, updated_at: Option) -> Value { + let present = |pointer: &str| { + profile + .pointer(pointer) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + }; + let text = |pointer: &str| profile.pointer(pointer).cloned().unwrap_or(Value::Null); + let mut models: Vec<&String> = profile["models"] + .as_object() + .map(|models| models.keys().collect()) + .unwrap_or_default(); + models.sort(); + serde_json::json!({ + "version_id": version_id, + "updated_at": updated_at, + "git": { + "name": text("/git/name"), + "email": text("/git/email"), + "signing_key": present("/git/ssh_signing_key"), + }, + "github": {"login": text("/github/login"), "token": present("/github/token")}, + "via": {"profile": present("/via/profile"), "op_token": present("/via/op_token")}, + "models": models, + }) +} + #[cfg(test)] #[path = "profile_tests.rs"] pub(crate) mod tests; diff --git a/crates/buzz-sandbox-reaper/src/profile_tests.rs b/crates/buzz-sandbox-reaper/src/profile_tests.rs index db586ba715a..c003d29f446 100644 --- a/crates/buzz-sandbox-reaper/src/profile_tests.rs +++ b/crates/buzz-sandbox-reaper/src/profile_tests.rs @@ -220,3 +220,26 @@ fn python_json_length_matches_json_dumps() { let value = json!({"a": [1, true, null], "b": "\n\u{e9}\u{1f600}\u{7f}"}); assert_eq!(python_json_len(&value), 57); } + +/// A partial profile may omit any section or key, but not add one, and a +/// model credential is a string to set or `null` to remove. +#[test] +fn a_partial_profile_may_omit_fields_but_not_add_them() { + let base = || json!({"version": 1, "owner_pubkey": "a".repeat(64)}); + assert_eq!(check_partial(&base()), Ok(())); + let mut partial = base(); + partial["models"] = json!({"codex_access_token": "at-x", "claude_oauth_token": null}); + partial["git"] = json!({"email": "ada@example.com"}); + assert_eq!(check_partial(&partial), Ok(())); + for (pointer, bad, message) in [ + ("/models", json!({"codex_access_token": 1}), "Unsupported model credential type"), + ("/models", json!({"gemini_api_key": "x"}), "Unsupported model credential type"), + ("/git", json!({"signing": "x"}), "Profile fields are incomplete or unsupported"), + ("/github", json!({"token": "ghp_x"}), "A GitHub token needs its login"), + ("/version", json!(2), "Unsupported profile version"), + ] { + let mut partial = base(); + partial[&pointer[1..]] = bad; + assert_eq!(check_partial(&partial), Err(Invalid(message)), "{pointer}"); + } +} diff --git a/crates/buzz-sandbox-reaper/src/serve_tests.rs b/crates/buzz-sandbox-reaper/src/serve_tests.rs index 539d7bfeee7..28882cfcbd0 100644 --- a/crates/buzz-sandbox-reaper/src/serve_tests.rs +++ b/crates/buzz-sandbox-reaper/src/serve_tests.rs @@ -389,10 +389,10 @@ async fn enroll_runs_end_to_end_and_its_events_carry_no_profile_value() { let replies = client.request(&request, None).await; let statuses: Vec = replies.iter().map(|reply| reply.status).collect(); assert_eq!(statuses, [Status::Accepted, Status::Done]); - assert_eq!( - replies[1].response, - Some(json!({"ok": true, "version_id": "version-1"})) - ); + let response = replies[1].response.as_ref().unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["version_id"], "version-1"); + assert_eq!(response["profile"]["github"]["login"], "ada-l"); { let written = written.lock().unwrap(); assert_eq!(written.len(), 1); diff --git a/crates/buzz-session-control/src/lib.rs b/crates/buzz-session-control/src/lib.rs index bcfadfae042..ceb7650f73a 100644 --- a/crates/buzz-session-control/src/lib.rs +++ b/crates/buzz-session-control/src/lib.rs @@ -51,6 +51,9 @@ pub enum Op { Stop, /// Store a developer's agent profile. Signed by the owner key itself. Enroll, + /// Describe the developer's stored profile, naming no value. Signed by + /// the owner key itself. + Profile, } impl Op { @@ -61,11 +64,12 @@ impl Op { Op::Deploy => "deploy", Op::Stop => "stop", Op::Enroll => "enroll", + Op::Profile => "profile", } } fn parse(value: &str) -> Option { - [Op::Info, Op::Deploy, Op::Stop, Op::Enroll] + [Op::Info, Op::Deploy, Op::Stop, Op::Enroll, Op::Profile] .into_iter() .find(|op| op.as_str() == value) } diff --git a/crates/buzz-session-control/src/tests.rs b/crates/buzz-session-control/src/tests.rs index dfcd8143e51..734f96c903a 100644 --- a/crates/buzz-session-control/src/tests.rs +++ b/crates/buzz-session-control/src/tests.rs @@ -170,9 +170,10 @@ fn golden_events_open_to_their_envelopes() { #[test] fn envelopes_round_trip_for_every_op_and_status() { - for op in [Op::Info, Op::Deploy, Op::Stop, Op::Enroll] { + for op in [Op::Info, Op::Deploy, Op::Stop, Op::Enroll, Op::Profile] { let request = Request::new(op, "r-1", json!({"op": op.as_str()})); - let auth = (op != Op::Enroll).then(auth_tag); + let owner_signed = matches!(op, Op::Enroll | Op::Profile); + let auth = (!owner_signed).then(auth_tag); let event = build_request( &agent(), &manager().public_key(), @@ -184,7 +185,7 @@ fn envelopes_round_trip_for_every_op_and_status() { let opened = open_request(&event, &manager()).unwrap(); assert_eq!(opened.request, request); assert_eq!(opened.event_id, event.id); - assert_eq!(opened.auth_tag.is_some(), op != Op::Enroll); + assert_eq!(opened.auth_tag.is_some(), !owner_signed); assert_eq!(opened.expiration, Some(now() + EXPIRATION_SECS)); } let request = request_event(&secret_request()); diff --git a/docs/remote-agents.md b/docs/remote-agents.md index b94c4a3e9af..2414a305923 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1857,10 +1857,10 @@ subscribers and never stores it. No stored fallback kind exists. | Event | Signer | Clear-text tags | Encrypted content (NIP-44 v2) | | --- | --- | --- | --- | -| Request | agent key (`private_key_nsec`); owner key for `enroll` | `["p", ]`, the owner's NIP-OA `auth` tag (not on `enroll`), `expiration` | `{"v":1,"op","request_id","payload"}`, to the manager | +| Request | agent key (`private_key_nsec`); owner key for `enroll` and `profile` | `["p", ]`, the owner's NIP-OA `auth` tag (not on `enroll` or `profile`), `expiration` | `{"v":1,"op","request_id","payload"}`, to the manager | | Reply | manager key | `["p", ]`, `["e", ]`, `expiration` | `{"v":1,"request_id","status","response"}`, to the requester | -- `op` is `info`, `deploy`, `stop` or `enroll`. Recovery is a `deploy` with +- `op` is `info`, `deploy`, `stop`, `enroll` or `profile`. Recovery is a `deploy` with `provider_config.sandbox.recovery`, as in the stdin protocol. - `payload` is the provider protocol request, the same JSON as the stdin request, including `op`. `response` is the provider protocol response, @@ -1927,7 +1927,8 @@ authorizes each request in this order: 6. The namespace is the verified owner's developer namespace from the configuration file. It replaces `provider_config.namespace`, and the verified agent and owner replace `identity_policy`. The manager runs Sandbox sessions only. -7. `enroll` is signed by the owner key itself, without an `auth` tag. +7. `enroll` and `profile` are signed by the owner key itself, without an + `auth` tag. Then the manager replies `accepted` and runs the request in-process with its own in-cluster credentials (`provider::run`), and replies `done` with the @@ -2011,25 +2012,58 @@ because its debug logs quote whole relay frames. needs Secrets Manager access to enroll. The Buzz app signs it with the owner key itself, with no `auth` tag. The payload is `{"profile": }`, where the profile is the schema `voltage/images/buzz-agent/profile.py` -validates. After the authorization steps above, the manager checks, in -order: +validates, or part of it. A partial profile always carries `version` and +`owner_pubkey`. Any section (`git`, `github`, `via`, `models`), and any key +in a section, may be left out, and the stored value is kept. In `models`, a +string sets a credential and `null` removes it. Setting one of an exclusive +pair (`openai_api_key` and `codex_access_token`, or `anthropic_api_key` and +`claude_oauth_token`) removes the other. A `github` section carries both +`login` and `token`. + +After the authorization steps above, the manager checks, in order: 1. The signer is a configured developer's `owner_pubkey` (`unauthorized_owner`). 2. The profile's `owner_pubkey` is the signer (`profile_owner_mismatch`). 3. The profile's `developer` is that developer's ID (`profile_developer_mismatch`). -4. The profile passes the port of `profile.py`'s `validate()` - (`crates/buzz-sandbox-reaper/src/profile.rs`, `invalid_profile`). -5. `GET /user` with the profile's GitHub token reports - the profile's `github.login`, ignoring case (`github_login_mismatch`). - -Then it calls `secretsmanager:PutSecretValue` on the developer's -`profile_secret_arn` with the profile JSON, using the ambient AWS -credentials (IRSA), and replies `done` with `{"ok":true,"version_id":""}`. -A failed write is `profile_store_failed`. Replies, logs and refusal -messages never contain a profile value; the serialized profile is zeroized -after the write. Neither the check nor the write uses the filesystem. +4. The profile has a partial profile's shape (`invalid_profile`). + +Then, as the request's job, the manager: + +1. Reads the stored profile with `secretsmanager:GetSecretValue`. With + nothing stored, the profile must be complete (`invalid_profile`). +2. Merges the profile into the stored one. +3. Checks that the merged profile passes the port of `profile.py`'s + `validate()` (`crates/buzz-sandbox-reaper/src/profile.rs`, + `invalid_profile`). +4. If the request carries a `github` section, or nothing is stored, checks + that `GET /user` with the token reports + `github.login`, ignoring case (`github_login_mismatch`). +5. Calls `secretsmanager:PutSecretValue` on the developer's + `profile_secret_arn` with the merged profile JSON, using the ambient AWS + credentials (IRSA). + +It replies `done` with `{"ok":true,"version_id":"","profile":}`. +A failed read or write is `profile_store_failed`. + +**Profile summary.** `profile`, with the payload `{}`, replies +`{"ok":true,"profile":}`, or `"profile":null` when nothing is +stored. The summary is: + +```json +{"version_id": "", "updated_at": "", + "git": {"name": "", "email": "", "signing_key": true}, + "github": {"login": "", "token": true}, + "via": {"profile": true, "op_token": true}, + "models": ["claude_oauth_token"]} +``` + +It names the GitHub login and the Git identity, and only whether each +credential is present. Replies are encrypted to the owner. Replies, logs and +refusal messages never contain a credential; the serialized profile is +zeroized after the write. Neither the merge nor the write uses the +filesystem. The manager does not check the 1Password token (`op whoami`); the image still refuses a profile it cannot load. A profile with an empty `developer` gets the signing owner's developer ID before validation, so the client need diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index b8e6b324cb6..4beff73388c 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -253,7 +253,7 @@ Every agent asks the Voltage Sandbox manager on its community, so there is nothi ### 4. Enroll your profile -Do this once, on any of your Remote workspace agents. Enroll again only to change a credential. +Do this once, on any of your Remote workspace agents. To change a credential later, see [Change one credential](#change-one-credential). | Field | Value | |---|---| @@ -275,6 +275,18 @@ The app finds your GitHub login and name from the token. The manager finds your To stop your agents from signing commits as you, delete the **Buzz agents** key under **Settings → SSH and GPG keys** on GitHub. +#### Change one credential + +The enroll form shows what is **On file**: your GitHub login, Git email, and the credentials you enrolled, by name only. It never shows a credential value. + +1. Fill in only the fields to change. A field marked **On file · leave blank to keep** keeps its stored value. +2. To remove a stored model credential, select **Remove** next to it. +3. Select **Enroll profile**. + +You need a GitHub token only to change it. The signing key stays the same, unless you select **Replace the signing key**. Then the app adds the new key to GitHub, as in the first enrollment. + +Entering a Codex access token replaces a stored OpenAI API key, and entering a Claude subscription token replaces a stored Anthropic API key. The same applies the other way round, because an agent can use only one of each pair. + ### 5. Start a session 1. Mention the agent in a new thread, for example `@my-agent summarize the open PRs in voltagecloud/cd`.