diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index be1a6a2ff64..e4f5ea6fd6a 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -253,7 +253,7 @@ jobs: run: | set -euo pipefail python3 -O test-native-agent.py "$IMAGE" - for test in policy setup accounts checkpoint checkpoint-storage restore gate worker cache; do + for test in policy accounts checkpoint checkpoint-storage restore gate worker cache; do echo "==> test-$test.py" python3 "test-$test.py" "$IMAGE" done diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/config.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/config.rs index 91ffc2ee386..066f23bd7c8 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/config.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/config.rs @@ -5,15 +5,24 @@ use buzz_host::api::{Launch, LaunchSpec}; use serde_json::{Value, json}; use std::collections::BTreeMap; -/// Runs each thread's Sandbox with the developer's own cluster access. -pub const KUBERNETES: &str = "kubernetes"; -/// Asks Voltage's Sandbox manager to run it, over the relay. +/// Every Remote workspace agent asks Voltage's Sandbox manager, over the +/// relay, to run each thread's Sandbox: a developer needs only their Buzz key. pub const RELAY: &str = "relay"; -/// The providers whose sessions belong to threads, as Settings names them. -pub const PROVIDERS: &[(&str, &str)] = &[ - (KUBERNETES, "Kubernetes"), - (RELAY, "Sandbox manager (relay)"), +/// Voltage's staging Sandbox manager, which every Remote workspace agent asks +/// (cd `charts/buzz-sandbox-manager`). Its key is public; rotating it needs a +/// new build. +pub const MANAGER_PUBKEY: &str = "1881c0c6186c81c00e607f9dcd9b6c655c49cd9a0227054e764a78e6829d31fd"; + +/// The effort levels Settings offers, applied as the session's +/// `thought_level` option; a level the model lacks leaves its default. +pub const EFFORTS: &[(&str, &str)] = &[ + ("", "Harness default"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("xhigh", "Extra high"), + ("max", "Max"), ]; /// What the relay provider takes; the manager decides the rest. @@ -27,6 +36,8 @@ pub struct Runtime { command: &'static str, args: &'static [&'static str], default_env: &'static [(&'static str, &'static str)], + /// How Settings explains the Model field for this harness. + pub model_help: &'static str, } pub const RUNTIMES: &[Runtime] = &[ @@ -36,6 +47,7 @@ pub const RUNTIMES: &[Runtime] = &[ command: "claude-agent-acp", args: &[], default_env: &[], + model_help: "A Claude model ID, such as claude-opus-5-5. Empty uses Claude Code's default.", }, Runtime { id: "codex", @@ -43,13 +55,7 @@ pub const RUNTIMES: &[Runtime] = &[ command: "codex-acp", args: &[], default_env: &[], - }, - Runtime { - id: "goose", - label: "Goose", - command: "goose", - args: &["acp"], - default_env: &[("GOOSE_MODE", "auto")], + model_help: "An OpenAI model ID, as Codex's /model lists it. Empty uses Codex's default.", }, Runtime { id: "buzz-agent", @@ -57,6 +63,7 @@ pub const RUNTIMES: &[Runtime] = &[ command: "buzz-agent", args: &[], default_env: &[("MCP_HOOK_SERVERS", "*")], + model_help: "Required. With an OpenRouter key, an OpenRouter model ID such as qwen/qwen3-coder.", }, ]; @@ -72,6 +79,10 @@ pub struct Config { pub runtime: String, /// Empty uses the harness's default. pub model: String, + /// One of [`EFFORTS`]; empty uses the model's default. + pub effort: String, + /// Fast mode, on models that offer it. + pub fast: bool, pub instructions: String, pub parallelism: u32, } @@ -79,11 +90,13 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { - provider: KUBERNETES.into(), + provider: RELAY.into(), provider_config: json!({ "sandbox": true }), env: BTreeMap::new(), runtime: RUNTIMES[0].id.into(), model: String::new(), + effort: String::new(), + fast: false, instructions: String::new(), parallelism: 1, } @@ -114,11 +127,15 @@ impl Config { .filter(|id| RUNTIMES.iter().any(|runtime| runtime.id == id)) .unwrap_or(default.runtime); Self { - provider: text("provider").unwrap_or(default.provider), + provider: default.provider, provider_config, env, runtime, model: text("model").unwrap_or_default(), + effort: text("effort") + .filter(|effort| EFFORTS.iter().any(|(id, _)| id == effort)) + .unwrap_or_default(), + fast: value.get("fast").and_then(Value::as_bool).unwrap_or(false), instructions: text("instructions").unwrap_or_default(), parallelism: value .get("parallelism") @@ -136,6 +153,8 @@ impl Config { "env": self.env, "runtime": self.runtime, "model": self.model, + "effort": self.effort, + "fast": self.fast, "instructions": self.instructions, "parallelism": self.parallelism, }) @@ -153,11 +172,7 @@ impl Config { /// Whether the host will accept it, as far as the GUI can tell. pub fn validate(&self) -> Result<(), String> { - match self.provider.as_str() { - KUBERNETES => {} - RELAY => validate_relay(&self.provider_config)?, - _ => return Err("Remote workspaces run on the kubernetes or relay provider.".into()), - } + validate_relay(&self.provider_config)?; buzz_remote_sessions::validate_provider_config(&self.provider_config)?; if let Some(key) = self.env.keys().find(|key| !env_name(key)) { return Err(format!("{key:?} is not an environment variable name.")); @@ -201,6 +216,13 @@ impl Config { }; set(key, self.model.trim().into()); } + // buzz-acp applies both as session options when the model offers them. + if !self.effort.is_empty() { + set("BUZZ_ACP_EFFORT_LEVEL", self.effort.clone()); + } + if self.fast { + set("BUZZ_ACP_FAST_MODE", "on".into()); + } let title: String = name.chars().filter(|c| !c.is_control()).collect(); if !title.trim().is_empty() { set("BUZZ_ACP_SESSION_TITLE", title.trim().into()); 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 419fd31bf84..f738b6f51d0 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 @@ -4,6 +4,7 @@ //! `done` reply. Tokens are never saved or logged, and leave the form once //! it is sent. +use super::signing::{self, GitHubIdentity}; use buzz_identity::Identity; use buzz_relay_client::Session; use buzz_relay_client::socket::{MAX_PUBLICATION_FRAME, SubscriptionEvent}; @@ -22,12 +23,8 @@ pub const TIMEOUT: Duration = Duration::from_secs(60); /// A form field. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Field { - Developer, - GitName, - GitEmail, - SigningKey, - GithubLogin, GithubToken, + GitEmail, ViaProfile, OpToken, CodexAccessToken, @@ -38,13 +35,9 @@ pub enum Field { } impl Field { - pub const ALL: [Field; 13] = [ - Field::Developer, - Field::GitName, - Field::GitEmail, - Field::SigningKey, - Field::GithubLogin, + pub const ALL: [Field; 9] = [ Field::GithubToken, + Field::GitEmail, Field::ViaProfile, Field::OpToken, Field::CodexAccessToken, @@ -70,12 +63,8 @@ impl Field { pub fn label(self) -> &'static str { match self { - Field::Developer => "Developer ID", - Field::GitName => "Git name", - Field::GitEmail => "Git email", - Field::SigningKey => "Signing key fingerprint", - Field::GithubLogin => "GitHub login", Field::GithubToken => "GitHub token", + Field::GitEmail => "Git email", Field::ViaProfile => "via profile", Field::OpToken => "1Password service account token", Field::CodexAccessToken => "Codex access token", @@ -129,7 +118,15 @@ impl Values { } /// The profile `profile.py` validates, for the owner `owner`, or why not. -pub fn profile(values: &Values, owner: &str) -> Result { +/// `github` is the token's account, which names the Git author, and +/// `signing_key` the agent-only key commits are signed with. The developer +/// ID is left for the Sandbox manager, which knows it from the owner key. +pub fn profile( + values: &Values, + owner: &str, + github: &GitHubIdentity, + signing_key: &str, +) -> Result { let text = |field| values.get(field).trim(); let models: Map = Field::ALL .into_iter() @@ -139,14 +136,14 @@ pub fn profile(values: &Values, owner: &str) -> Result { .collect(); let profile = json!({ "version": 1, - "developer": text(Field::Developer), + "developer": "", "owner_pubkey": owner, "git": { - "name": text(Field::GitName), + "name": github.name, "email": text(Field::GitEmail), - "signing_key": text(Field::SigningKey), + "ssh_signing_key": signing_key, }, - "github": {"login": text(Field::GithubLogin), "token": text(Field::GithubToken)}, + "github": {"login": github.login, "token": text(Field::GithubToken)}, "via": {"profile": values.get(Field::ViaProfile), "op_token": text(Field::OpToken)}, "models": models, }); @@ -180,11 +177,13 @@ pub fn validate(profile: &Value) -> Result<(), String> { profile["version"] == json!(1), "Unsupported profile version", )?; + // Empty until the manager fills in the signing owner's developer ID. require( - matches( - &profile["developer"], - "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?", - ), + profile["developer"] == json!("") + || matches( + &profile["developer"], + "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?", + ), "Invalid developer ID", )?; require( @@ -192,16 +191,19 @@ pub fn validate(profile: &Value) -> Result<(), String> { "Invalid Buzz owner", )?; let git = &profile["git"]; - fields(git, &["name", "email", "signing_key"])?; - for value in git.as_object().into_iter().flat_map(|git| git.values()) { + 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")?; } require( - matches(&git["signing_key"], "[0-9A-Fa-f]{40,64}"), - "Use a full OpenPGP signing fingerprint", + matches( + &git["ssh_signing_key"], + "-----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"])?; @@ -390,6 +392,36 @@ pub async fn done( /// 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 +/// permission either way. +pub async fn run( + session: Arc, + identity: Arc, + manager: String, + values: Values, +) -> Result<(Outcome, Option), String> { + let token = values.get(Field::GithubToken).trim().to_owned(); + if token.is_empty() { + 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 outcome = enroll(session, identity, manager, profile).await?; + if !matches!(outcome, Outcome::Enrolled { .. }) { + 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; + Ok((outcome, Some(registration))) +} + pub async fn enroll( session: Arc, identity: Arc, 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 3b02a614805..0c21c2adc3c 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 @@ -2,12 +2,15 @@ //! manager stores, sent as the signed-in owner. Its tokens are masked, //! cleared once sent, and never saved. -use super::enroll::{self, Field, Outcome, Values}; +use super::enroll::{Field, Outcome, Values}; +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::{Colors, Surface, TextArea, TextField, TypeStyled, tokens::space}; +use buzz_ui::{ + Button, ButtonSize, Colors, Surface, TextArea, TextField, TypeStyled, tokens::space, +}; use gpui_kit::component::input::{InputState, TextareaState}; use gpui_kit::*; use std::sync::Arc; @@ -19,11 +22,14 @@ pub struct EnrollForm { pub sending: bool, /// How the last enrollment ended. pub status: Option>, + /// How the last enrollment's signing key reached GitHub. + pub registration: Option, } impl EnrollForm { - /// `developer` prefills the developer ID. - pub fn new(developer: &str, window: &mut Window, cx: &mut App) -> Self { + /// The Git email starts as this device's `git config --global user.email`. + pub fn new(window: &mut Window, cx: &mut App) -> Self { + let email = local_git_email(); let inputs = Field::ALL .into_iter() .filter(|field| *field != Field::ViaProfile) @@ -31,8 +37,12 @@ impl EnrollForm { let state = cx.new(|cx| { let state = InputState::new(window, cx).masked(field.secret()); match field { - Field::Developer => state.default_value(developer.to_owned()), - Field::AnthropicApiKey | Field::ClaudeOauthToken | Field::OpenaiApiKey => { + 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, @@ -47,6 +57,7 @@ impl EnrollForm { via, sending: false, status: None, + registration: None, } } @@ -74,18 +85,12 @@ impl EnrollForm { .update(cx, |state, cx| state.set_value(text, window, cx)); } - /// Takes the profile for `owner` out of the form, leaving its tokens - /// cleared whether or not it is valid. - pub fn submit( - &self, - owner: &str, - window: &mut Window, - cx: &mut App, - ) -> Result { + /// Takes the form's values to send, leaving its tokens cleared. + pub fn submit(&self, window: &mut Window, cx: &mut App) -> Values { let mut values = self.values(cx); let sent = values.submit(); self.write(&values, window, cx); - enroll::profile(&sent, owner) + sent } pub fn render(&self, owner: Option<&str>, disabled: bool, c: &Colors) -> Div { @@ -131,8 +136,10 @@ impl EnrollForm { .gap(space::S4) .child(div().text_label_sm().child("Enroll profile")) .child(div().text_body_sm().text_color(c.text_subtle).child( - "The Sandbox manager stores this profile for your Sandboxes. Sent encrypted \ - to the manager and signed as you; tokens are never saved here.", + "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.", )) .child(field( "Buzz owner", @@ -142,9 +149,7 @@ impl EnrollForm { None, c, )) - .child(row(&[Field::Developer])) - .child(row(&[Field::GitName, Field::GitEmail, Field::SigningKey])) - .child(row(&[Field::GithubLogin, Field::GithubToken])) + .child(row(&[Field::GithubToken, Field::GitEmail])) .child(field( Field::ViaProfile.label(), TextArea::new(&self.via) @@ -163,9 +168,65 @@ impl EnrollForm { Field::OpenrouterApiKey, ])) .children(status) + .children( + self.registration + .as_ref() + .map(|registration| registration_notice(registration, c)), + ) } } +/// 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 { + match registration { + Registration::Added => div().text_body_sm().text_color(c.text_success).child( + "Added your agents' signing key to your GitHub account (Settings → SSH and GPG keys).", + ), + Registration::Manual { public, reason } => { + let copied = public.clone(); + div() + .flex() + .flex_col() + .gap(space::S2) + .child(div().text_label_sm().child("Add your agents' signing key to GitHub")) + .child(div().text_body_sm().text_color(c.text_subtle).child(format!( + "Until you add it, your agents' commits are signed but show as Unverified. \ + The app could not add it for you: {reason}." + ))) + .child(div().text_body_sm().child( + "1. On GitHub, open Settings → SSH and GPG keys and press New SSH key. \ + 2. Set Key type to Signing Key. 3. Paste the key below and press Add SSH key.", + )) + .child(div().text_body_sm().font_family("monospace").child(public.clone())) + .child( + div().flex().child( + Button::new("remote-workspace-copy-signing-key", "Copy key") + .size(ButtonSize::Sm) + .outline() + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(copied.clone())); + }), + ), + ) + } + } +} + +/// `git config --global user.email` on this device, or empty. +fn local_git_email() -> String { + std::process::Command::new("git") + .args(["config", "--global", "user.email"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|email| email.trim().to_owned()) + .unwrap_or_default() +} + /// The signed-in owner. pub fn owner(cx: &App) -> Option> { match &cx.global::().identity { 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 23b2aa1f552..a8e9c87e944 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 @@ -10,18 +10,27 @@ const TOKENS: [&str; 4] = [ "sk-fixtureOpenaiKey", ]; +/// An agent signing key in the shape `ssh-keygen -t ed25519` writes. +const KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ==\n-----END OPENSSH PRIVATE KEY-----\n"; + +/// The GitHub token's account. +fn github() -> GitHubIdentity { + GitHubIdentity { + login: "tee8z".into(), + name: "Tee Eight".into(), + } +} + +/// `profile` for the fixture account and key. +fn built(values: &Values, owner: &str) -> Result { + profile(values, owner, &github(), KEY) +} + /// A filled form, with the Anthropic and OpenAI keys. fn values() -> Values { let mut values = Values::default(); for (field, text) in [ - (Field::Developer, "tee8z"), - (Field::GitName, "Tee Eight"), (Field::GitEmail, "tee@example.com"), - ( - Field::SigningKey, - "0123456789ABCDEF0123456789ABCDEF01234567", - ), - (Field::GithubLogin, "tee8z"), (Field::GithubToken, " ghp_fixtureGithubToken "), ( Field::ViaProfile, @@ -38,17 +47,18 @@ fn values() -> Values { #[test] fn the_form_builds_a_profile_matching_profile_py() { - let built = profile(&values(), OWNER).unwrap(); + let profile = built(&values(), OWNER).unwrap(); assert_eq!( - built, + profile, json!({ "version": 1, - "developer": "tee8z", + // The manager fills in the signing owner's developer ID. + "developer": "", "owner_pubkey": OWNER, "git": { "name": "Tee Eight", "email": "tee@example.com", - "signing_key": "0123456789ABCDEF0123456789ABCDEF01234567", + "ssh_signing_key": KEY, }, "github": {"login": "tee8z", "token": "ghp_fixtureGithubToken"}, "via": { @@ -64,14 +74,14 @@ fn the_form_builds_a_profile_matching_profile_py() { let mut no_models = values(); no_models.set(Field::AnthropicApiKey, String::new()); no_models.set(Field::OpenaiApiKey, " ".into()); - assert_eq!(profile(&no_models, OWNER).unwrap()["models"], json!({})); + assert_eq!(built(&no_models, OWNER).unwrap()["models"], json!({})); // Both subscriptions, for Codex and Claude Code in parallel. let mut subscriptions = no_models; subscriptions.set(Field::CodexAccessToken, "at-fixtureCodexToken".into()); subscriptions.set(Field::ClaudeOauthToken, "sk-ant-oat01-fixture".into()); subscriptions.set(Field::OpenrouterApiKey, "sk-or-fixture".into()); assert_eq!( - profile(&subscriptions, OWNER).unwrap()["models"], + built(&subscriptions, OWNER).unwrap()["models"], json!({ "codex_access_token": "at-fixtureCodexToken", "claude_oauth_token": "sk-ant-oat01-fixture", @@ -85,19 +95,11 @@ fn the_profile_is_refused_as_profile_py_refuses_it() { let refused = |field: Field, text: &str| { let mut values = values(); values.set(field, text.into()); - profile(&values, OWNER).unwrap_err() + built(&values, OWNER).unwrap_err() }; for (field, text, message) in [ - (Field::Developer, "Tee8z", "Invalid developer ID"), - (Field::Developer, "tee8z-", "Invalid developer ID"), - (Field::GitName, "", "Invalid Git identity"), + (Field::GitEmail, "", "Invalid Git identity"), (Field::GitEmail, "tee\n@example.com", "Invalid Git identity"), - ( - Field::SigningKey, - "ABCDEF01", - "Use a full OpenPGP signing fingerprint", - ), - (Field::GithubLogin, "-tee8z", "Invalid GitHub login"), (Field::GithubToken, "", "Invalid credential"), (Field::GithubToken, "ghp_a b", "Invalid credential"), (Field::ViaProfile, "[services", "Invalid via profile"), @@ -124,22 +126,49 @@ fn the_profile_is_refused_as_profile_py_refuses_it() { ] { assert_eq!(refused(field, text), message, "{field:?}"); } - assert_eq!(profile(&values(), "AA").unwrap_err(), "Invalid Buzz owner"); + assert_eq!(built(&values(), "AA").unwrap_err(), "Invalid Buzz owner"); + // What the form does not ask for is checked as the manager will check it. + let with = |pointer: &str, value: Value| { + let mut profile = built(&values(), OWNER).unwrap(); + *profile.pointer_mut(pointer).unwrap() = value; + validate(&profile) + }; + assert!(with("/developer", json!("tee8z")).is_ok()); + for (pointer, value, message) in [ + ("/developer", json!("Tee8z"), "Invalid developer ID"), + ("/developer", json!("tee8z-"), "Invalid developer ID"), + ("/git/name", json!(""), "Invalid Git identity"), + ("/github/login", json!("-tee8z"), "Invalid GitHub login"), + ( + "/git/ssh_signing_key", + json!("ABCDEF0123456789ABCDEF0123456789ABCDEF01"), + "Invalid SSH signing key", + ), + ( + "/git/ssh_signing_key", + json!( + "-----BEGIN OPENSSH PRIVATE KEY-----\nab cd\n-----END OPENSSH PRIVATE KEY-----\n" + ), + "Invalid SSH signing key", + ), + ] { + assert_eq!(with(pointer, value).unwrap_err(), message, "{pointer}"); + } // An OpenAI API key pasted as the Codex access token. let mut pasted = values(); pasted.set(Field::OpenaiApiKey, String::new()); pasted.set(Field::CodexAccessToken, "sk-proj-fixture".into()); assert_eq!( - profile(&pasted, OWNER).unwrap_err(), + built(&pasted, OWNER).unwrap_err(), "Use a Codex access token (at-...)" ); - let mut extra = profile(&values(), OWNER).unwrap(); + let mut extra = built(&values(), OWNER).unwrap(); extra["models"]["gemini_api_key"] = json!("x"); assert_eq!( validate(&extra).unwrap_err(), "Unsupported model credential type" ); - extra = profile(&values(), OWNER).unwrap(); + extra = built(&values(), OWNER).unwrap(); extra["extra"] = json!(true); assert_eq!( validate(&extra).unwrap_err(), @@ -151,9 +180,9 @@ fn the_profile_is_refused_as_profile_py_refuses_it() { fn the_signed_enroll_event_holds_no_token_or_profile_field() { let owner = Identity::ephemeral(); let manager = Identity::ephemeral().public_key(); - let built = profile(&values(), &owner.pubkey_hex()).unwrap(); + let profile = built(&values(), &owner.pubkey_hex()).unwrap(); let event = owner - .enroll_request(&manager, "enroll-1", built, 1_700_000_000) + .enroll_request(&manager, "enroll-1", profile, 1_700_000_000) .unwrap(); assert_eq!(event.kind.as_u16(), KIND); let text = event.as_json(); @@ -242,5 +271,42 @@ fn sending_clears_the_tokens_and_keeps_the_rest() { } assert_eq!(sent.get(field), values().get(field), "{field:?}"); } - assert!(profile(&sent, OWNER).is_ok()); + assert!(built(&sent, OWNER).is_ok()); +} + +#[test] +fn the_git_author_comes_from_the_github_account() { + let named = signing::parse_identity(br#"{"login": "tee8z", "name": " Tee Eight "}"#).unwrap(); + assert_eq!(named, github()); + // An account without a name commits under its login. + for body in [ + &br#"{"login": "tee8z", "name": null}"#[..], + br#"{"login": "tee8z", "name": ""}"#, + br#"{"login": "tee8z", "name": "a\nb"}"#, + ] { + assert_eq!(signing::parse_identity(body).unwrap().name, "tee8z"); + } + assert!(signing::parse_identity(br#"{"name": "Tee"}"#).is_err()); + assert!(signing::parse_identity(b"not json").is_err()); +} + +#[test] +fn a_gh_failure_says_what_to_do() { + for (stderr, hint) in [ + ( + "HTTP 404: This API operation needs the \"admin:ssh_signing_key\" scope.", + "gh auth refresh -h github.com -s admin:ssh_signing_key", + ), + ( + "To get started with GitHub CLI, please run: gh auth login", + "gh auth login", + ), + ( + "HTTP 422: key is already in use", + "already on a GitHub account", + ), + ("HTTP 500", "could not add"), + ] { + assert!(signing::gh_failure(stderr).contains(hint), "{stderr}"); + } } diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs index 7b958f62b66..63b41dc43f0 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs @@ -1,7 +1,6 @@ //! Remote workspace agents: each thread the agent is mentioned in gets its -//! own Kubernetes Sandbox, which the agent's host starts with Voltage's -//! `buzz-backend-kubernetes` provider, or `buzz-backend-relay`, which asks -//! the Sandbox manager to start it. The harness runs in the Sandbox and +//! own Kubernetes Sandbox, which the agent's host asks Voltage's Sandbox +//! manager to start over the relay (`buzz-backend-relay`). The harness runs in the Sandbox and //! talks to the community itself; this type only starts the session. //! Built only with the `voltage-sandbox` feature. @@ -10,6 +9,7 @@ mod enroll; mod enroll_form; mod recovery; mod settings; +mod signing; use crate::agent_activity::local::WorkOutcome; use crate::agents2::runtime::HostLink; @@ -23,10 +23,9 @@ use gpui_kit::*; use std::sync::Arc; use std::time::Duration; -/// Registered as `voltage.remote-workspace/kubernetes`, whichever provider -/// an agent uses: the key never changes. +/// Registered as `voltage.remote-workspace/sandbox`. pub const PLUGIN: &str = "voltage.remote-workspace"; -pub const TYPE_ID: &str = "kubernetes"; +pub const TYPE_ID: &str = "sandbox"; /// Over the host's 10 minutes to start a session, under the 30-minute /// limit. @@ -91,13 +90,8 @@ impl AgentType for RemoteWorkspace { fn summary(&self, agent: &crate::agents2::Agent) -> Option { let config = Config::read(&agent.config); let text = |name: &str| config.provider_config.get(name)?.as_str(); - match config.provider.as_str() { - config::RELAY => { - let relay = url::Url::parse(text("relay_url")?).ok()?; - Some(format!("Sandbox manager · {}", relay.host_str()?)) - } - _ => Some(format!("Kubernetes · {}", text("namespace")?)), - } + let relay = url::Url::parse(text("relay_url")?).ok()?; + Some(format!("Sandbox manager · {}", relay.host_str()?)) } fn tabs(&self) -> Vec { 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 e39c0763df2..118232cef9c 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 @@ -1,9 +1,9 @@ -//! What the agent launches with: the provider, the harness, its model and -//! instructions, and the environment. The Kubernetes provider's config is -//! edited as JSON; the relay provider's is its relay and manager key, and -//! beside them the form that enrolls a developer profile with the manager. +//! What the agent launches with: the harness, its model, effort and fast +//! mode, its instructions, and the environment, beside the form that +//! enrolls a developer profile with the Sandbox manager. Every agent asks the +//! manager over its community's relay, with the build's manager key. -use super::config::{Config, KUBERNETES, PROVIDERS, RELAY, RUNTIMES}; +use super::config::{Config, EFFORTS, MANAGER_PUBKEY, RUNTIMES}; use super::enroll_form::{self, EnrollForm}; use crate::agent_plugins::tabs::{ Picking, choice, field, instructions_field, instructions_state, options, saved_config, @@ -13,25 +13,26 @@ use buzz_ui::{ActiveTheme, Button, Surface, TextArea, TextField, TypeStyled, tok use gpui_kit::component::input::{InputEvent, InputState, TextareaState}; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; -use serde_json::{Value, json}; +use serde_json::Value; #[derive(Clone, Copy, PartialEq)] pub enum Picker { - Provider, Runtime, + Effort, + Fast, } +/// The Fast mode choices. +const FAST: &[(&str, &str)] = &[("off", "Off"), ("on", "On")]; + pub struct SettingsTab { view: AgentView, /// The saved settings the draft started from. saved: Config, - provider: String, runtime: String, model: Entity, - /// The Kubernetes provider's config. - provider_config: Entity, - relay_url: Entity, - manager_pubkey: Entity, + effort: String, + fast: bool, env: Entity, instructions: Entity, enroll: EnrollForm, @@ -57,65 +58,20 @@ fn textarea(value: &str, window: &mut Window, cx: &mut App) -> Entity { - pub provider: &'a str, pub runtime: &'a str, pub model: &'a str, - /// The Kubernetes provider's config, as JSON. - pub provider_config: &'a str, + pub effort: &'a str, + pub fast: bool, + /// The agent's community, and the manager key: neither is edited here. pub relay_url: &'a str, pub manager_pubkey: &'a str, pub env: &'a str, pub instructions: &'a str, } -/// The saved Kubernetes config, for its JSON field; a thread Sandbox -/// for agents on another provider. -fn kubernetes_config(saved: &Config) -> Value { - if saved.provider == KUBERNETES { - saved.provider_config.clone() - } else { - json!({ "sandbox": true }) - } -} - -/// The saved relay and manager key, for their fields. The relay is the -/// agent's community, `community`, until one is saved. -pub fn relay_fields(saved: &Config, community: &str) -> (String, String) { - let text = |name: &str| { - (saved.provider == RELAY) - .then(|| saved.provider_config.get(name)?.as_str()) - .flatten() - .map(str::to_owned) - }; - ( - text("relay_url").unwrap_or_else(|| community.to_owned()), - text("manager_pubkey").unwrap_or_default(), - ) -} - -/// The developer ID a Kubernetes config's namespace names, for the -/// enroll form. -fn developer_of(saved: &Config) -> &str { - saved - .provider_config - .get("namespace") - .and_then(Value::as_str) - .and_then(|namespace| namespace.strip_prefix("buzz-agent-staging-")) - .unwrap_or_default() -} - /// The draft as a config: the fields parsed, and the whole checked. pub fn parse(saved: &Config, draft: Draft) -> Result { - let provider_config = if draft.provider == RELAY { - Config::relay(draft.relay_url, draft.manager_pubkey) - } else { - let config: Value = serde_json::from_str(draft.provider_config) - .map_err(|error| format!("The provider config is not valid JSON: {error}"))?; - if !config.is_object() { - return Err("The provider config must be a JSON object.".into()); - } - config - }; + let provider_config = Config::relay(draft.relay_url, draft.manager_pubkey); let env: Value = serde_json::from_str(draft.env) .map_err(|error| format!("The environment is not valid JSON: {error}"))?; let all_text = env @@ -125,9 +81,10 @@ pub fn parse(saved: &Config, draft: Draft) -> Result { return Err("The environment must be a JSON object of text values.".into()); } let mut value = saved.value(); - value["provider"] = draft.provider.into(); value["runtime"] = draft.runtime.into(); value["model"] = draft.model.trim().into(); + value["effort"] = draft.effort.into(); + value["fast"] = draft.fast.into(); value["provider_config"] = provider_config; value["env"] = env; value["instructions"] = draft.instructions.into(); @@ -144,33 +101,18 @@ impl SettingsTab { .placeholder("The harness's default") .default_value(saved.model.clone()) }); - let provider_config = textarea(&pretty(&kubernetes_config(&saved)), window, cx); - let (relay, manager) = relay_fields(&saved, &view.agent.relay); - let relay_url = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("wss://") - .default_value(relay) - }); - let manager_pubkey = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("64 hex characters") - .default_value(manager) - }); let env = textarea(&pretty(&saved.env), window, cx); let instructions = instructions_state(&saved.instructions, window, cx); - let enroll = EnrollForm::new(developer_of(&saved), window, cx); + let enroll = EnrollForm::new(window, cx); let mut subscriptions = Vec::new(); - for input in [&model, &relay_url, &manager_pubkey] - .into_iter() - .chain(enroll.inputs()) - { + for input in std::iter::once(&model).chain(enroll.inputs()) { subscriptions.push(cx.subscribe(input, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } })); } - for area in [&provider_config, &env, &instructions] { + for area in [&env, &instructions] { subscriptions.push(cx.subscribe(area, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); @@ -188,13 +130,11 @@ impl SettingsTab { } Self { view, - provider: saved.provider.clone(), runtime: saved.runtime.clone(), + effort: saved.effort.clone(), + fast: saved.fast, saved, model, - provider_config, - relay_url, - manager_pubkey, env, instructions, enroll, @@ -209,19 +149,15 @@ impl SettingsTab { self.model.update(cx, |state, cx| { state.set_value(saved.model.clone(), window, cx) }); - let (relay, manager) = relay_fields(&saved, &self.view.agent.relay); - for (input, text) in [(&self.relay_url, relay), (&self.manager_pubkey, manager)] { - input.update(cx, |state, cx| state.set_value(text, window, cx)); - } for (area, text) in [ - (&self.provider_config, pretty(&kubernetes_config(&saved))), (&self.env, pretty(&saved.env)), (&self.instructions, saved.instructions.clone()), ] { area.update(cx, |state, cx| state.set_value(text, window, cx)); } - self.provider = saved.provider.clone(); self.runtime = saved.runtime.clone(); + self.effort = saved.effort.clone(); + self.fast = saved.fast; self.saved = saved; cx.notify(); } @@ -230,12 +166,12 @@ impl SettingsTab { parse( &self.saved, Draft { - provider: &self.provider, runtime: &self.runtime, model: &self.model.read(cx).value(), - provider_config: &self.provider_config.read(cx).value(), - relay_url: &self.relay_url.read(cx).value(), - manager_pubkey: &self.manager_pubkey.read(cx).value(), + effort: &self.effort, + fast: self.fast, + relay_url: &self.view.agent.relay, + manager_pubkey: MANAGER_PUBKEY, env: &self.env.read(cx).value(), instructions: &self.instructions.read(cx).value(), }, @@ -268,30 +204,20 @@ impl SettingsTab { } impl SettingsTab { - /// Sends the enroll form to the manager in the relay fields, as the - /// signed-in owner. Its tokens are cleared at once, sent or not. + /// 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. fn enroll(&mut self, window: &mut Window, cx: &mut Context) { - let relay = self.relay_url.read(cx).value().trim().to_owned(); - let manager = self.manager_pubkey.read(cx).value().trim().to_owned(); + let relay = self.view.agent.relay.clone(); let owner = enroll_form::owner(cx); - let pubkey = owner - .as_ref() - .map(|owner| owner.pubkey_hex()) - .unwrap_or_default(); - let profile = self.enroll.submit(&pubkey, window, cx); - let relay_config = Config { - provider: RELAY.into(), - provider_config: Config::relay(&relay, &manager), - ..Config::default() - }; + let values = self.enroll.submit(window, cx); let started = (|| { let owner = owner.ok_or("Sign in to enroll a profile.")?; - relay_config.validate()?; let session = enroll_form::session(&relay, cx) .ok_or("Join the community at the relay to enroll through it.")?; - Ok::<_, String>((owner, session, profile?)) + Ok::<_, String>((owner, session, values)) })(); - let (owner, session, profile) = match started { + let (owner, session, values) = match started { Ok(started) => started, Err(error) => { self.enroll.status = Some(Err(error)); @@ -301,15 +227,25 @@ impl SettingsTab { }; self.enroll.sending = true; self.enroll.status = None; - let sent = crate::runtime::Runtime::handle(cx) - .spawn(super::enroll::enroll(session, owner, manager, profile)); + self.enroll.registration = None; + let sent = crate::runtime::Runtime::handle(cx).spawn(super::enroll::run( + session, + owner, + MANAGER_PUBKEY.to_owned(), + values, + )); cx.spawn(async move |this, cx| { let outcome = sent .await .unwrap_or_else(|_| Err("The enrollment was cancelled.".into())); let _ = this.update(cx, |this, cx| { this.enroll.sending = false; - this.enroll.status = Some(outcome); + let (status, registration) = match outcome { + Ok((outcome, registration)) => (Ok(outcome), registration), + Err(error) => (Err(error), None), + }; + this.enroll.status = Some(status); + this.enroll.registration = registration; cx.notify(); }); }) @@ -327,8 +263,9 @@ impl Picking for SettingsTab { fn pick(&mut self, picker: Picker, value: String, _: &mut Context) { match picker { - Picker::Provider => self.provider = value, Picker::Runtime => self.runtime = value, + Picker::Effort => self.effort = value, + Picker::Fast => self.fast = value == "on", } } } @@ -343,29 +280,16 @@ impl Render for SettingsTab { .iter() .map(|runtime| (runtime.id.to_string(), runtime.label.to_string())) .collect(); + let model_help = RUNTIMES + .iter() + .find(|runtime| runtime.id == self.runtime) + .map(|runtime| runtime.model_help) + .unwrap_or_default(); div() .flex() .flex_col() .gap(space::S4) .children(self.view.type_control(changed, window, cx)) - .child(field( - "Runs on", - choice( - self.open, - Picker::Provider, - "remote-workspace-provider", - &self.provider, - options(PROVIDERS), - saving, - window, - cx, - ), - Some( - "The Sandbox manager starts Sandboxes for you, without cluster access on this device." - .into(), - ), - &c, - )) .child( div() .flex() @@ -391,42 +315,46 @@ impl Render for SettingsTab { TextField::new(&self.model) .surface(Surface::Floating) .disabled(saving), - None, + Some(model_help.into()), + &c, + ))), + ) + .child( + div() + .flex() + .flex_wrap() + .gap(space::S4) + .child(div().flex_1().min_w(rems(14.)).child(field( + "Effort", + choice( + self.open, + Picker::Effort, + "remote-workspace-effort", + &self.effort, + options(EFFORTS), + saving, + window, + cx, + ), + Some("How hard the model thinks, on models that offer it.".into()), + &c, + ))) + .child(div().flex_1().min_w(rems(14.)).child(field( + "Fast mode", + choice( + self.open, + Picker::Fast, + "remote-workspace-fast", + if self.fast { "on" } else { "off" }, + options(FAST), + saving, + window, + cx, + ), + Some("Faster output on models that offer it, at higher usage.".into()), &c, ))), ) - .when(self.provider != RELAY, |this| { - this.child(field( - "Provider config", - TextArea::new(&self.provider_config) - .rows(10) - .surface(Surface::Floating) - .disabled(saving), - Some( - "buzz-backend-kubernetes settings, such as context, namespace and image. Always a Sandbox per thread; no secrets." - .into(), - ), - &c, - )) - }) - .when(self.provider == RELAY, |this| { - this.child(field( - "Relay", - TextField::new(&self.relay_url) - .surface(Surface::Floating) - .disabled(saving), - Some("Where the Sandbox manager listens; the agent's community by default.".into()), - &c, - )) - .child(field( - "Manager key", - TextField::new(&self.manager_pubkey) - .surface(Surface::Floating) - .disabled(saving), - Some("The Sandbox manager's public key, in hex.".into()), - &c, - )) - }) .child(field( "Environment", TextArea::new(&self.env) @@ -434,7 +362,7 @@ impl Render for SettingsTab { .surface(Surface::Floating) .disabled(saving), Some( - "A JSON object, such as BUZZ_CHECKPOINT_BUCKET and BUZZ_CHECKPOINT_PREFIX. Saved on this device as plain text." + "Usually empty: extra environment for the Sandbox, as a JSON object. Saved on this device as plain text, so never put secrets here." .into(), ), &c, @@ -445,7 +373,7 @@ impl Render for SettingsTab { "Sent as the harness's system prompt when a thread's Sandbox starts.", &c, )) - .when(self.provider == RELAY, |this| { + .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)) diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/signing.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/signing.rs new file mode 100644 index 00000000000..9c4595b1a99 --- /dev/null +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/signing.rs @@ -0,0 +1,205 @@ +//! The developer's agent-only commit signing key. Enrollment generates an +//! Ed25519 key with `ssh-keygen` and sends the private half in the profile, so +//! Sandbox commits are signed as the developer without their own GPG key +//! leaving this device. Once the manager stores the profile, the public half +//! is added to the developer's GitHub account as a signing key with their +//! local GitHub CLI (`gh`), or they add it themselves; either way the agents' +//! GitHub token needs no account permission. Nothing here logs a token or a +//! private key. + +use std::time::Duration; +use zeroize::Zeroizing; + +const API: &str = "https://api.github.com"; + +/// Who a GitHub token belongs to. +#[derive(Clone, Debug, PartialEq)] +pub struct GitHubIdentity { + pub login: String, + /// The profile name, or the login when the account has none. + pub name: String, +} + +/// A generated key pair: the OpenSSH private key and its public line. +pub struct SigningKey { + pub private: Zeroizing, + pub public: String, +} + +fn client() -> Result { + reqwest::Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| "Could not reach GitHub.".to_string()) +} + +fn request( + method: reqwest::Method, + path: &str, + token: &str, +) -> Result { + Ok(client()? + .request(method, format!("{API}{path}")) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "buzz-gpui")) +} + +/// The identity of `token`'s account, from `GET /user`. +pub async fn identity(token: &str) -> Result { + let response = request(reqwest::Method::GET, "/user", token)? + .send() + .await + .map_err(|_| "Could not reach GitHub.".to_string())?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("GitHub did not accept the token.".into()); + } + if !response.status().is_success() { + return Err(format!( + "GitHub refused the token ({}).", + response.status().as_u16() + )); + } + let body = response + .bytes() + .await + .map_err(|_| "Could not read GitHub's reply.".to_string())?; + parse_identity(&body) +} + +/// `GET /user`'s login and name. +pub fn parse_identity(body: &[u8]) -> Result { + let user: serde_json::Value = + serde_json::from_slice(body).map_err(|_| "Could not read GitHub's reply.".to_string())?; + let login = user["login"] + .as_str() + .filter(|login| !login.is_empty()) + .ok_or("Could not read GitHub's reply.")? + .to_owned(); + let name = user["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty() && !name.chars().any(char::is_control)) + .unwrap_or(&login) + .to_owned(); + Ok(GitHubIdentity { login, name }) +} + +/// A fresh Ed25519 key, without a passphrase, from `ssh-keygen`. +pub async fn generate(comment: &str) -> Result { + let comment = comment.to_owned(); + tokio::task::spawn_blocking(move || { + let directory = tempfile::tempdir().map_err(|_| "Could not create the signing key.")?; + let path = directory.path().join("agent-signing-key"); + let status = std::process::Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f"]) + .arg(&path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|_| "Could not run ssh-keygen. Install OpenSSH.")?; + if !status.success() { + return Err("ssh-keygen could not create the signing key."); + } + let private = Zeroizing::new( + std::fs::read_to_string(&path).map_err(|_| "Could not read the signing key.")?, + ); + let public = std::fs::read_to_string(path.with_extension("pub")) + .map_err(|_| "Could not read the signing key.")? + .trim() + .to_owned(); + Ok(SigningKey { private, public }) + }) + .await + .map_err(|_| "Could not create the signing key.".to_string())? + .map_err(str::to_string) +} + +/// How the public half reached GitHub. +#[derive(Clone, Debug, PartialEq)] +pub enum Registration { + /// Added with the local GitHub CLI. + Added, + /// For the developer to add, and why `gh` could not. + Manual { + public: String, + reason: &'static str, + }, +} + +/// How long one `gh` call may take. +const GH_TIMEOUT: Duration = Duration::from_secs(30); + +async fn gh(args: &[&str]) -> Result { + let child = tokio::process::Command::new("gh") + .args(args) + .env("GH_HOST", "github.com") + .env("GH_PROMPT_DISABLED", "1") + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(GH_TIMEOUT, child).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(_)) => Err("the GitHub CLI (gh) is not installed"), + Err(_) => Err("the GitHub CLI (gh) did not answer"), + } +} + +/// Why a failed `gh` call could not add the key, from its error output. +pub fn gh_failure(stderr: &str) -> &'static str { + if stderr.contains("admin:ssh_signing_key") { + "gh needs the admin:ssh_signing_key scope: run gh auth refresh -h github.com -s admin:ssh_signing_key, then enroll again" + } else if stderr.contains("gh auth login") || stderr.contains("not logged") { + "gh is not signed in: run gh auth login" + } else if stderr.contains("key is already in use") { + "this key is already on a GitHub account" + } else { + "gh could not add the key" + } +} + +/// Adds `public` to `login`'s GitHub account as a signing key titled +/// `title`, with the local GitHub CLI signed in as that account. +pub async fn add_with_gh(login: &str, title: &str, public: &str) -> Result<(), &'static str> { + let user = gh(&["api", "user", "--jq", ".login"]).await?; + if !user.status.success() { + return Err(gh_failure(&String::from_utf8_lossy(&user.stderr))); + } + let signed_in = String::from_utf8_lossy(&user.stdout); + if !signed_in.trim().eq_ignore_ascii_case(login) { + return Err("gh is signed in to a different GitHub account than the token"); + } + let title = format!("title={title}"); + let key = format!("key={public}"); + let added = gh(&[ + "api", + "--method", + "POST", + "/user/ssh_signing_keys", + "-f", + &title, + "-f", + &key, + ]) + .await?; + if added.status.success() { + Ok(()) + } else { + Err(gh_failure(&String::from_utf8_lossy(&added.stderr))) + } +} + +/// Adds the key with `gh`, or hands it back for the developer to add. +pub async fn register(login: &str, title: &str, public: &str) -> Registration { + match add_with_gh(login, title, public).await { + Ok(()) => Registration::Added, + Err(reason) => Registration::Manual { + public: public.to_owned(), + reason, + }, + } +} diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/tests.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/tests.rs index e43427b676e..2b33d426e7c 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/tests.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/tests.rs @@ -1,5 +1,5 @@ use super::config::Config; -use super::settings::{Draft, parse, relay_fields}; +use super::settings::{Draft, parse}; use super::*; use crate::agents2::attention::{Channels, EventData, EventWatch, TimerWatch}; use crate::agents2::runtime::{AgentAttention, AgentHandle, CancelSource, Working, always_current}; @@ -93,7 +93,7 @@ async fn host() -> (HostLink, Arc>>) { fn config() -> serde_json::Value { json!({ - "provider_config": {"context": "voltage-staging", "namespace": "agents"}, + "provider_config": {"relay_url": RELAY, "manager_pubkey": MANAGER}, "env": {"BUZZ_CHECKPOINT_PREFIX": "tee8z/"}, "model": "opus", }) @@ -172,7 +172,8 @@ async fn a_mention_starts_its_threads_session() { let config: serde_json::Value = serde_json::from_str(&session.spec.provider_config_json).unwrap(); assert_eq!(config["sandbox"], true, "always a thread Sandbox"); - assert_eq!(config["namespace"], "agents"); + assert_eq!(config["relay_url"], RELAY); + assert_eq!(config["manager_pubkey"], MANAGER); assert_eq!(session.spec.env["BUZZ_CHECKPOINT_PREFIX"], "tee8z/"); assert_eq!(session.spec.launch.command, "claude-agent-acp"); assert_eq!(session.spec.launch.policy_env["ANTHROPIC_MODEL"], "opus"); @@ -225,43 +226,21 @@ async fn watches_timers_and_strangers_start_nothing() { } #[test] -fn settings_refuse_a_bad_provider_config() { +fn settings_refuse_a_bad_environment() { let saved = Config::read(&config()); - let parsed = |provider_config: &str, env: &str| { + let parsed = |env: &str| { let draft = Draft { - provider: "kubernetes", runtime: "codex", - provider_config, env, - ..Default::default() + ..relay_draft(RELAY, MANAGER) }; parse(&saved, draft) }; - let config = parsed(r#"{"namespace": "agents", "sandbox": false}"#, "{}").unwrap(); + let config = parsed(r#"{"BUZZ_CHECKPOINT_PREFIX": "tee8z/"}"#).unwrap(); assert_eq!(config.provider_config["sandbox"], true); assert_eq!(config.runtime, "codex"); - // A staging Sandbox config, with its toleration's `key`. - let staging = json!({ - "context": "voltage-staging", "namespace": "buzz-agent-staging-tee8z", - "image": "498537461460.dkr.ecr.us-west-2.amazonaws.com/voltage-buzz-agent-staging@sha256:c5466fa2ecdf51e0095448995ff5484daf438c1bf818930e7ad4f385b2b49cec", - "cpu_request": "500m", "service_account": "agent-workspace", "sandbox": true, - "pod_options": {"active_deadline_seconds": 43200, "tolerations": [ - {"effect": "NoSchedule", "key": "workload", "operator": "Equal", "value": "remote-agents"}, - ]}, - }); - let env = r#"{"BUZZ_CHECKPOINT_BUCKET": "bucket", "BUZZ_CHECKPOINT_PREFIX": "tee8z/"}"#; - assert!(parsed(&staging.to_string(), env).is_ok()); - for (provider_config, env) in [ - (r#"{"api_token": "x"}"#, "{}"), - ("[]", "{}"), - ("{", "{}"), - ("{}", r#"{"BAD-NAME": "x"}"#), - ("{}", r#"{"COUNT": 1}"#), - ] { - assert!( - parsed(provider_config, env).is_err(), - "{provider_config} {env}" - ); + for env in ["{", "[]", r#"{"BAD-NAME": "x"}"#, r#"{"COUNT": 1}"#] { + assert!(parsed(env).is_err(), "{env}"); } } @@ -270,7 +249,6 @@ const MANAGER: &str = "5be7aa6bbcf1e8a4b4b4bd0b2a2a0e0c3fa4a4b7f5a1d1cb6d1d2e7f9 /// A relay draft for `relay` and `manager`. fn relay_draft<'a>(relay: &'a str, manager: &'a str) -> Draft<'a> { Draft { - provider: "relay", runtime: "claude", relay_url: relay, manager_pubkey: manager, @@ -288,12 +266,6 @@ fn settings_take_the_relay_provider_by_its_relay_and_manager_only() { config.provider_config, json!({"relay_url": RELAY, "manager_pubkey": MANAGER, "sandbox": true}) ); - // The Kubernetes JSON is not read for the relay provider. - let draft = Draft { - provider_config: "{", - ..relay_draft(RELAY, MANAGER) - }; - assert!(parse(&saved, draft).is_ok()); let (upper, long) = (MANAGER.to_uppercase(), format!("{MANAGER}0")); for (relay, manager) in [ ("https://relay.example", MANAGER), @@ -331,30 +303,37 @@ fn validation_follows_each_providers_schema() { config[key] = value; assert!(relay(config).validate().is_err(), "{key}"); } - // Kubernetes keeps its own config; the relay's fields are no help there. - let kubernetes = Config::read(&json!({"provider": "kubernetes", "provider_config": base})); - assert!(kubernetes.validate().is_ok()); - let other = Config::read(&json!({"provider": "docker", "provider_config": {}})); - assert!(other.validate().is_err()); - let ids: Vec<_> = super::config::PROVIDERS.iter().map(|(id, _)| *id).collect(); - assert_eq!(ids, buzz_remote_sessions::THREAD_PROVIDERS); } +/// Settings asks the agent's community and the build's manager, whatever +/// the saved config names, and saves effort and fast mode. #[test] -fn the_relay_field_starts_as_the_agents_community() { - let kubernetes = Config::read(&config()); - assert_eq!( - relay_fields(&kubernetes, "wss://community.example"), - ("wss://community.example".into(), String::new()) - ); - let saved = Config::read(&json!({ - "provider": "relay", - "provider_config": {"relay_url": RELAY, "manager_pubkey": MANAGER}, - })); +fn settings_use_the_community_and_the_builds_manager() { + let saved = Config::read(&config()); + let draft = Draft { + effort: "xhigh", + fast: true, + ..relay_draft("wss://community.example", super::config::MANAGER_PUBKEY) + }; + let config = parse(&saved, draft).unwrap(); assert_eq!( - relay_fields(&saved, "wss://community.example"), - (RELAY.into(), MANAGER.into()) + config.provider_config, + json!({ + "relay_url": "wss://community.example", + "manager_pubkey": super::config::MANAGER_PUBKEY, + "sandbox": true, + }) ); + assert_eq!((config.effort.as_str(), config.fast), ("xhigh", true)); + let spec = config.spec("Ada", None); + assert_eq!(spec.launch.policy_env["BUZZ_ACP_EFFORT_LEVEL"], "xhigh"); + assert_eq!(spec.launch.policy_env["BUZZ_ACP_FAST_MODE"], "on"); + // Defaults leave both to the harness; an unknown effort reads as the default. + let plain = Config::read(&json!({"effort": "turbo", "fast": "yes"})); + assert_eq!((plain.effort.as_str(), plain.fast), ("", false)); + let spec = plain.spec("Ada", None); + assert!(!spec.launch.policy_env.contains_key("BUZZ_ACP_EFFORT_LEVEL")); + assert!(!spec.launch.policy_env.contains_key("BUZZ_ACP_FAST_MODE")); } #[tokio::test] @@ -413,12 +392,11 @@ fn the_launch_follows_the_desktops_policy() { vec!["BUZZ_CHECKPOINT_PREFIX"] ); - config.runtime = "goose".into(); - let goose = config.spec("Ada", None); - assert_eq!(goose.launch.args, vec!["acp"]); - assert_eq!(goose.launch.policy_env["GOOSE_MODE"], "auto"); - assert_eq!(goose.launch.policy_env["BUZZ_ACP_MODEL"], "opus"); - assert!(goose.env.contains_key("ANTHROPIC_MODEL")); + config.runtime = "codex".into(); + let codex = config.spec("Ada", None); + assert_eq!(codex.launch.command, "codex-acp"); + assert_eq!(codex.launch.policy_env["BUZZ_ACP_MODEL"], "opus"); + assert!(codex.env.contains_key("ANTHROPIC_MODEL")); // The Buzz agent reads its startup model, such as an OpenRouter model ID, itself. config.runtime = "buzz-agent".into(); @@ -451,3 +429,16 @@ fn instructions_carry_across_a_type_change() { None ); } + +/// Every agent runs through the Sandbox manager: a new one starts there, and +/// a config naming another provider is read as the relay's. +#[test] +fn agents_run_through_the_sandbox_manager() { + let defaults = Config::read(&RemoteWorkspace.defaults().config); + assert_eq!(defaults.provider, "relay"); + let other = Config::read( + &json!({"provider": "kubernetes", "provider_config": {"namespace": "agents"}}), + ); + assert_eq!(other.provider, "relay"); + assert!(other.validate().is_err(), "the relay takes no namespace"); +} diff --git a/apps/buzz-gpui/flake.nix b/apps/buzz-gpui/flake.nix index 3b3660f7bdf..03bf627d48a 100644 --- a/apps/buzz-gpui/flake.nix +++ b/apps/buzz-gpui/flake.nix @@ -76,10 +76,9 @@ sourceRoot=. ''; }; - # The Sandbox providers that buzz-gpui-voltage's host runs, from the - # root workspace: buzz-backend-kubernetes (a session on the user's - # cluster) and buzz-backend-relay (a session through the Sandbox - # manager). Building one root crate loads every root member. + # The Sandbox provider that buzz-gpui-voltage's host runs, from the + # root workspace: buzz-backend-relay, which asks the Sandbox manager + # over the relay. Building one root crate loads every root member. providersArgs = { pname = "buzz-sandbox-providers"; inherit version; @@ -94,7 +93,7 @@ in sourceFilter path type && builtins.any (dir: under dir || above dir) kept; }; - cargoExtraArgs = "--locked -p buzz-backend-kubernetes -p buzz-backend-relay"; + cargoExtraArgs = "--locked -p buzz-backend-relay"; doCheck = false; nativeBuildInputs = with pkgs; [ pkg-config cmake perl protobuf ]; buildInputs = with pkgs; [ openssl ]; @@ -238,9 +237,8 @@ packages.default = self.packages.${system}.buzzd; packages.buzz-gpui = gpuiPackage { pname = "buzz-gpui"; }; - # Voltage's build: remote workspaces (Kubernetes Sandboxes), with the - # buzz-backend-kubernetes and buzz-backend-relay providers beside the - # app. + # Voltage's build: remote workspaces (Kubernetes Sandboxes through the + # Sandbox manager), with the buzz-backend-relay provider beside the app. packages.buzz-gpui-voltage = gpuiPackage { pname = "buzz-gpui-voltage"; features = [ "voltage-sandbox" ]; diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0f58e555121..1d55af6ef36 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2291,6 +2291,32 @@ pub fn extract_thought_level_config_id(result: &serde_json::Value) -> Option Option { + result["configOptions"].as_array()?.iter().find_map(|opt| { + let id = opt + .get("configId") + .or_else(|| opt.get("id")) + .and_then(|v| v.as_str())?; + FAST_MODE_CONFIG_IDS.contains(&id).then(|| id.to_string()) + }) +} + +/// `BUZZ_ACP_FAST_MODE`, as the value to set: `on` or `off`, or `None` when +/// it is unset or not a recognized switch. +pub fn parse_fast_mode(raw: Option<&str>) -> Option<&'static str> { + match raw?.trim().to_ascii_lowercase().as_str() { + "on" | "true" | "1" => Some("on"), + "off" | "false" | "0" => Some("off"), + _ => None, + } +} + /// Match a desired model ID against a fresh `session/new` response. /// /// Returns the correct ACP method to call, or `None` if no match. @@ -2953,6 +2979,38 @@ mod tests { assert!(super::extract_model_state(&result).is_none()); } + #[test] + fn fast_mode_is_found_under_either_adapters_id() { + for id in ["fast", "fast-mode"] { + let result = serde_json::json!({"configOptions": [ + {"id": "model", "category": "model"}, + {"id": id, "category": "model_config", "type": "select"}, + ]}); + assert_eq!(extract_fast_mode_config_id(&result).as_deref(), Some(id)); + } + let none = + serde_json::json!({"configOptions": [{"id": "effort", "category": "thought_level"}]}); + assert_eq!(extract_fast_mode_config_id(&none), None); + assert_eq!(extract_fast_mode_config_id(&serde_json::json!({})), None); + } + + #[test] + fn fast_mode_switch_values() { + for (raw, want) in [ + (Some("on"), Some("on")), + (Some(" TRUE "), Some("on")), + (Some("1"), Some("on")), + (Some("off"), Some("off")), + (Some("false"), Some("off")), + (Some("0"), Some("off")), + (Some("fast"), None), + (Some(""), None), + (None, None), + ] { + assert_eq!(parse_fast_mode(raw), want, "{raw:?}"); + } + } + #[test] fn extract_thought_level_config_id_finds_config_id() { let result = serde_json::json!({ diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 1479f7fa0a2..ece50ae541a 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -30,9 +30,10 @@ use tokio::time::timeout; use uuid::Uuid; use crate::acp::{ - extract_model_config_options, extract_model_state, extract_thought_level_config_id, - model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, - ModelSwitchMethod, StopReason, SystemPromptTransport, BUZZ_PI_ACP_NAME, + extract_fast_mode_config_id, extract_model_config_options, extract_model_state, + extract_thought_level_config_id, model_in_catalog, parse_fast_mode, + resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, + StopReason, SystemPromptTransport, BUZZ_PI_ACP_NAME, }; use crate::config::{compose_scoped_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -1792,6 +1793,9 @@ async fn create_session_and_apply_model( // the cached configOptions tell the truth about the running session. let effort_snapshot = post_switch_snapshot.as_ref().unwrap_or(&resp.raw); let effort_outcome = apply_startup_effort(agent, effort_snapshot, &resp.session_id).await?; + // Fast mode, from BUZZ_ACP_FAST_MODE, the same way: per session, on the + // running model's option, and skipped when the model has none. + let fast_outcome = apply_startup_fast_mode(agent, effort_snapshot, &resp.session_id).await?; // Emit session config for desktop consumption (config bridge tier 1b). // Emitted AFTER desired_model resolution so the desktop caches the @@ -1810,8 +1814,10 @@ async fn create_session_and_apply_model( .get("configOptions") .cloned() .unwrap_or(serde_json::Value::Null); - if let Some(StartupEffortOutcome::Applied { config_id, value }) = &effort_outcome { - patch_config_option_current_value(&mut opts, config_id, value); + for outcome in [&effort_outcome, &fast_outcome] { + if let Some(StartupEffortOutcome::Applied { config_id, value }) = outcome { + patch_config_option_current_value(&mut opts, config_id, value); + } } opts }; @@ -2128,6 +2134,71 @@ async fn apply_startup_effort( } } +/// Apply `BUZZ_ACP_FAST_MODE` via `session/set_config_option`, if it is set and +/// the running model advertises a fast-mode option. Errors are classified as +/// [`apply_startup_effort`] classifies them: transport-class errors propagate, +/// and a rejection leaves the session on the adapter's default speed. +async fn apply_startup_fast_mode( + agent: &mut OwnedAgent, + session_new_result: &serde_json::Value, + session_id: &str, +) -> Result, AcpError> { + let Some(value) = parse_fast_mode(std::env::var("BUZZ_ACP_FAST_MODE").ok().as_deref()) else { + return Ok(None); + }; + let Some(config_id) = extract_fast_mode_config_id(session_new_result) else { + tracing::info!( + target: "pool::fast", + "fast mode {value} configured but the model advertises no fast-mode option — leaving agent default" + ); + return Ok(None); + }; + let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async { + agent + .acp + .session_set_config_option(session_id, &config_id, value) + .await + }) + .await; + match result { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::fast", + "applied fast mode {value} via configId={config_id} on session {session_id}" + ); + Ok(Some(StartupEffortOutcome::Applied { + config_id, + value: value.to_string(), + })) + } + Ok(Err(e @ AcpError::Io(_))) + | Ok(Err(e @ AcpError::WriteTimeout(_))) + | Ok(Err(e @ AcpError::Timeout(_))) + | Ok(Err(e @ AcpError::Protocol(_))) + | Ok(Err(e @ AcpError::AgentExited)) => { + tracing::error!( + target: "pool::fast", + "fatal error applying fast mode {value} via configId={config_id}: {e}" + ); + Err(e) + } + Ok(Err(e)) => { + tracing::warn!( + target: "pool::fast", + "adapter rejected fast mode {value} via configId={config_id}: {e} — proceeding with agent default" + ); + Ok(Some(StartupEffortOutcome::Rejected)) + } + Err(_) => { + tracing::error!( + target: "pool::fast", + "fast mode {value} via configId={config_id} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal" + ); + Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT)) + } + } +} + /// Patch the `currentValue` of the configOption whose `configId`/`id` matches /// `config_id` in a session/new `configOptions` array, in place. /// diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs index 7fb156b8f10..7c50f154c13 100644 --- a/crates/buzz-backend-kubernetes/src/config.rs +++ b/crates/buzz-backend-kubernetes/src/config.rs @@ -103,9 +103,6 @@ pub struct PodOptions { /// Positive absolute Pod lifetime, including startup. pub active_deadline_seconds: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] - /// Expose the server-assigned Pod UID to a bounded owner setup gate. - pub setup_pending: bool, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] /// Mount a short-lived ServiceAccount token so an orchestrating agent can /// claim sub-agent Sandboxes (§Sub-agents). Off by default. pub enable_subagents: bool, @@ -453,7 +450,6 @@ pub fn config_schema() -> serde_json::Value { "ephemeral_storage_request": {"type": "string", "minLength": 1}, "ephemeral_storage_limit": {"type": "string", "minLength": 1}, "active_deadline_seconds": {"type": "integer", "minimum": 1}, - "setup_pending": {"type": "boolean", "default": false}, "enable_subagents": {"type": "boolean", "default": false}, "priority_class_name": {"type": "string", "minLength": 1} } diff --git a/crates/buzz-backend-kubernetes/src/pod.rs b/crates/buzz-backend-kubernetes/src/pod.rs index 3450f9afc26..b44b08dbef5 100644 --- a/crates/buzz-backend-kubernetes/src/pod.rs +++ b/crates/buzz-backend-kubernetes/src/pod.rs @@ -13,10 +13,9 @@ use crate::naming::{ }; use k8s_openapi::api::core::v1::{ Capabilities, ConfigMapProjection, Container, DownwardAPIProjection, DownwardAPIVolumeFile, - EmptyDirVolumeSource, EnvFromSource, EnvVar, EnvVarSource, KeyToPath, ObjectFieldSelector, Pod, - PodSecurityContext, PodSpec, ProjectedVolumeSource, ResourceRequirements, SeccompProfile, - Secret, SecretEnvSource, SecurityContext, ServiceAccountTokenProjection, Volume, VolumeMount, - VolumeProjection, + EmptyDirVolumeSource, EnvFromSource, KeyToPath, ObjectFieldSelector, Pod, PodSecurityContext, + PodSpec, ProjectedVolumeSource, ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, + SecurityContext, ServiceAccountTokenProjection, Volume, VolumeMount, VolumeProjection, }; use k8s_openapi::apimachinery::pkg::api::resource::Quantity; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; @@ -182,20 +181,6 @@ pub fn build_pod( }), ..Default::default() }]), - // Explicit env wins over envFrom, so a Secret cannot spoof the UID. - env: cfg.pod_options.setup_pending.then(|| { - vec![EnvVar { - name: "BUZZ_SETUP_POD_UID".into(), - value_from: Some(EnvVarSource { - field_ref: Some(ObjectFieldSelector { - api_version: Some("v1".into()), - field_path: "metadata.uid".into(), - }), - ..Default::default() - }), - ..Default::default() - }] - }), resources: Some(ResourceRequirements { requests: Some(requests), limits: Some(limits), @@ -423,34 +408,6 @@ mod tests { assert_eq!(mount.mount_path, WORKSPACE_PATH); } - #[test] - fn setup_uid_is_opt_in_and_comes_from_the_api_not_the_secret() { - let mut cfg = provider_config(); - let fp = intent_template(&cfg, ["A".into()]).fingerprint(); - assert!(spec(&build_pod(&identity(), &cfg, "g", &fp)).containers[0] - .env - .is_none()); - cfg.pod_options.setup_pending = true; - let changed = intent_template(&cfg, ["A".into()]).fingerprint(); - assert_ne!(fp, changed); - let pod = build_pod(&identity(), &cfg, "g", &changed); - let env = spec(&pod).containers[0].env.as_ref().unwrap(); - assert_eq!(env.len(), 1); - assert_eq!(env[0].name, "BUZZ_SETUP_POD_UID"); - assert!(env[0].value.is_none()); - assert_eq!( - env[0] - .value_from - .as_ref() - .unwrap() - .field_ref - .as_ref() - .unwrap() - .field_path, - "metadata.uid" - ); - } - #[test] fn subagent_token_is_opt_in_short_lived_and_off_the_default_path() { let mut cfg = provider_config(); @@ -498,7 +455,6 @@ mod tests { serde_json::json!({"ephemeral_storage_request": "3Gi"}), serde_json::json!({"ephemeral_storage_limit": "4Gi"}), serde_json::json!({"active_deadline_seconds": 28800}), - serde_json::json!({"setup_pending": true}), serde_json::json!({"enable_subagents": true}), serde_json::json!({"priority_class_name": "agent-session"}), ] { diff --git a/crates/buzz-backend-kubernetes/src/sandbox.rs b/crates/buzz-backend-kubernetes/src/sandbox.rs index 6f0bd367bd4..aff2a3117c7 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox.rs @@ -321,57 +321,6 @@ fn verify_live(sandbox: &DynamicObject, fingerprints: &Fingerprints) -> Result<( Ok(()) } -fn configure_setup( - cfg: &mut ProviderConfig, - identity: &naming::AgentIdentity, - env: &mut BTreeMap, -) -> Result<(), String> { - let Some(mode) = env.get("BUZZ_SETUP_MODE") else { - return Ok(()); - }; - if mode != "codex-device-v1" - || env.get("BUZZ_ACP_AGENT_COMMAND").map(String::as_str) != Some("codex-acp") - || env.get("BUZZ_ACP_PERMISSION_MODE").map(String::as_str) != Some("agent-full-access") - { - return Err( - "Sandbox account setup requires codex-device-v1, codex-acp, and agent-full-access" - .into(), - ); - } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| "could not establish account setup time")? - .as_secs(); - let (_, digest) = cfg - .image - .as_str() - .rsplit_once('@') - .ok_or("image digest is missing")?; - // The image acknowledges this exact Pod/agent/image generation. The Pod UID - // comes from the downward API, overriding any caller-supplied environment. - cfg.pod_options.setup_pending = true; - env.remove("BUZZ_SETUP_POD_UID"); - env.extend([ - ( - "BUZZ_SETUP_AGENT_PUBKEY".into(), - identity.pubkey_hex().into(), - ), - ("BUZZ_SETUP_IMAGE_DIGEST".into(), digest.into()), - ( - "BUZZ_SETUP_NONCE".into(), - uuid::Uuid::new_v4().simple().to_string(), - ), - ("BUZZ_SETUP_DEADLINE".into(), (now + 900).to_string()), - ("CODEX_HOME".into(), "/home/agent/.codex".into()), - ( - "CODEX_CONFIG".into(), - r#"{"cli_auth_credentials_store":"file"}"#.into(), - ), - ("NO_BROWSER".into(), "1".into()), - ]); - Ok(()) -} - /// Per-deploy handles and invariants. struct Session<'a> { sandboxes: Api, @@ -417,9 +366,6 @@ pub async fn deploy( owner: &str, mut env: BTreeMap, ) -> Result { - let mut effective_cfg = cfg.clone(); - configure_setup(&mut effective_cfg, identity, &mut env)?; - let cfg = &effective_cfg; if cfg.pod_options.active_deadline_seconds.is_none() { return Err("Sandbox requires pod_options.active_deadline_seconds".into()); } diff --git a/crates/buzz-backend-kubernetes/src/sandbox_tests.rs b/crates/buzz-backend-kubernetes/src/sandbox_tests.rs index ec20dc951b3..02efaee3b42 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox_tests.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox_tests.rs @@ -106,38 +106,6 @@ fn options_parse_accepts_only_a_canonical_thread_scope() { } } -fn identity() -> naming::AgentIdentity { - naming::AgentIdentity::from_nsec(&nostr::Keys::generate().secret_key().to_bech32().unwrap()) - .unwrap() -} - -#[test] -fn account_setup_refuses_any_other_mode_runtime_or_permission() { - let cfg = crate::config::parse(&json!({"namespace":"agents", - "image":format!("example.com/agent@sha256:{}", "a".repeat(64))})) - .unwrap(); - for (key, value) in [ - ("BUZZ_SETUP_MODE", "codex-device-v2"), - ("BUZZ_ACP_AGENT_COMMAND", "claude-agent-acp"), - ("BUZZ_ACP_PERMISSION_MODE", "default"), - ] { - let mut cfg = cfg.clone(); - let mut env = fixture_env(); - env.insert(key.into(), value.into()); - let before = env.clone(); - assert!(configure_setup(&mut cfg, &identity(), &mut env) - .unwrap_err() - .contains("codex-device-v1")); - assert_eq!(env, before, "a refused setup must not change the launch"); - assert!(!cfg.pod_options.setup_pending); - } - let mut cfg = cfg.clone(); - let mut env = fixture_env(); - configure_setup(&mut cfg, &identity(), &mut env).unwrap(); - assert!(cfg.pod_options.setup_pending); - assert!(!env.contains_key("BUZZ_SETUP_POD_UID")); -} - #[test] fn session_env_requires_checkpoint_storage_unless_disabled() { let scope = Options { @@ -187,17 +155,11 @@ fn fixture_env() -> BTreeMap { ("BUZZ_RELAY_URL".into(), "wss://fixture.invalid".into()), ("BUZZ_CHECKPOINT_BUCKET".into(), "fixture-bucket".into()), ("BUZZ_CHECKPOINT_PREFIX".into(), "fixture/".into()), - ("BUZZ_SETUP_MODE".into(), "codex-device-v1".into()), ("BUZZ_ACP_AGENT_COMMAND".into(), "codex-acp".into()), ( "BUZZ_ACP_PERMISSION_MODE".into(), "agent-full-access".into(), ), - ( - "BUZZ_SETUP_AGENT_PUBKEY".into(), - "stale-caller-value".into(), - ), - ("BUZZ_SETUP_POD_UID".into(), "stale-caller-value".into()), ]) } @@ -277,13 +239,7 @@ async fn live_controller_reuses_scopes_and_refuses_lost_workspaces() { let before = secrets.list(&ListParams::default()).await.unwrap(); assert_eq!(before.items.len(), 1); let data = before.items[0].data.as_ref().unwrap(); - assert_eq!( - data["BUZZ_SETUP_AGENT_PUBKEY"].0, - identity.pubkey_hex().as_bytes() - ); - assert_eq!(data["BUZZ_SETUP_NONCE"].0.len(), 32); assert_eq!(data["BUZZ_MANAGED_AGENT_START_NONCE"].0.len(), 32); - assert!(!data.contains_key("BUZZ_SETUP_POD_UID")); assert_eq!( deploy( client.clone(), @@ -301,7 +257,7 @@ async fn live_controller_reuses_scopes_and_refuses_lost_workspaces() { assert_eq!(after.items.len(), 1); assert_eq!( after.items[0].data, before.items[0].data, - "retry must preserve setup binding and deadline" + "a retry must reuse the launch Secret" ); assert!(deploy( client.clone(), diff --git a/crates/buzz-sandbox-reaper/src/enroll.rs b/crates/buzz-sandbox-reaper/src/enroll.rs index 884713ce14c..d07e3a86ef1 100644 --- a/crates/buzz-sandbox-reaper/src/enroll.rs +++ b/crates/buzz-sandbox-reaper/src/enroll.rs @@ -28,21 +28,28 @@ 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 +/// developer never needs to know the Terraform key they are listed under. /// Returns the profile to store, or the refusal. pub fn admit_profile( payload: &Value, owner: &PublicKey, developer: &Developer, ) -> Result { - let profile = payload + let mut profile = payload .as_object() .filter(|payload| payload.len() == 1) .and_then(|payload| payload.get("profile")) .filter(|profile| profile.is_object()) + .cloned() .ok_or(( ErrorCode::InvalidProfile, "the enroll payload must be {\"profile\": }", ))?; + let named = profile.get("developer").and_then(Value::as_str); + if named.is_none_or(str::is_empty) { + profile["developer"] = Value::String(developer.id.clone()); + } if profile.get("owner_pubkey").and_then(Value::as_str) != Some(owner.to_hex().as_str()) { return Err(( ErrorCode::ProfileOwnerMismatch, @@ -55,9 +62,9 @@ pub fn admit_profile( "the profile's developer is not the signing owner's developer ID", )); } - profile::validate(profile) + profile::validate(&profile) .map_err(|profile::Invalid(message)| (ErrorCode::InvalidProfile, message))?; - Ok(profile.clone()) + Ok(profile) } /// Why a profile write failed: a stable label, safe to log and to show. diff --git a/crates/buzz-sandbox-reaper/src/enroll_tests.rs b/crates/buzz-sandbox-reaper/src/enroll_tests.rs index a642dd9480d..a6a91089dbd 100644 --- a/crates/buzz-sandbox-reaper/src/enroll_tests.rs +++ b/crates/buzz-sandbox-reaper/src/enroll_tests.rs @@ -166,3 +166,14 @@ fn the_github_api_must_be_https_unless_loopback() { assert!(GitHub::new("http://github.example.com").is_err()); assert!(GitHub::new("not a url").is_err()); } + +/// A profile without a developer ID is stored under the signing owner's. +#[test] +fn a_profile_without_a_developer_gets_the_owners() { + let owner = nostr::Keys::generate().public_key(); + let mut sent = profile(); + sent.as_object_mut().unwrap().remove("developer"); + sent["owner_pubkey"] = json!(owner.to_hex()); + let admitted = admit_profile(&json!({"profile": sent}), &owner, &developer()).unwrap(); + assert_eq!(admitted["developer"], "dev"); +} diff --git a/crates/buzz-sandbox-reaper/src/manager_tests.rs b/crates/buzz-sandbox-reaper/src/manager_tests.rs index 9fe479e676d..8f28ba12997 100644 --- a/crates/buzz-sandbox-reaper/src/manager_tests.rs +++ b/crates/buzz-sandbox-reaper/src/manager_tests.rs @@ -301,6 +301,13 @@ async fn enroll_is_refused_unless_the_profile_is_the_signing_owners() { let mine = || profile("dev", &owner()); let mut invalid = mine(); invalid["github"]["login"] = json!("-not-a-login"); + let mut without_developer = mine(); + without_developer + .as_object_mut() + .unwrap() + .remove("developer"); + let mut empty_developer = mine(); + empty_developer["developer"] = json!(""); let cases = [ ( enroll(agent(), Some(tag(&owner(), &agent(), "")), mine()), @@ -329,6 +336,14 @@ async fn enroll_is_refused_unless_the_profile_is_the_signing_owners() { for (sent, expected) in cases { assert_eq!(admit(&manager, &sent), expected); } + // Without a developer ID, the signing owner's is filled in. Each runs on a + // fresh manager: the same request ID would be refused as a replay. + for profile in [without_developer, empty_developer] { + assert_eq!( + admit(&self::manager(), &enroll(owner(), None, profile)), + "start" + ); + } // The refusal is the image's message, which names no value. let mut incomplete = mine(); incomplete.as_object_mut().unwrap().remove("via"); diff --git a/crates/buzz-sandbox-reaper/src/profile.rs b/crates/buzz-sandbox-reaper/src/profile.rs index 00b77d4651c..182290a2c33 100644 --- a/crates/buzz-sandbox-reaper/src/profile.rs +++ b/crates/buzz-sandbox-reaper/src/profile.rs @@ -77,6 +77,24 @@ fn is_github_login(value: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || *b == b'-') } +/// `profile.py`'s `SSH_SIGNING_KEY`: one OpenSSH private key block, its body +/// 1 to 4,000 base64 characters and newlines, with an optional final newline. +fn is_ssh_signing_key(value: &str) -> bool { + const BEGIN: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n"; + const END: &str = "-----END OPENSSH PRIVATE KEY-----"; + let Some(rest) = value.strip_prefix(BEGIN) else { + return false; + }; + let rest = rest.strip_suffix('\n').unwrap_or(rest); + let Some(body) = rest.strip_suffix(END) else { + return false; + }; + (1..=4000).contains(&body.len()) + && body + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'\n')) +} + /// Python truthiness of a TOML value. fn truthy(value: Option<&toml::Value>) -> bool { match value { @@ -112,8 +130,8 @@ pub fn validate(profile: &Value) -> Result<(), Invalid> { }), "Invalid Buzz owner", )?; - let git = fields(&top["git"], &["name", "email", "signing_key"])?; - for value in git.values() { + let git = fields(&top["git"], &["name", "email", "ssh_signing_key"])?; + for value in [&git["name"], &git["email"]] { require( value.as_str().is_some_and(|value| { (1..=256).contains(&value.chars().count()) && !value.chars().any(|c| c < ' ') @@ -122,10 +140,10 @@ pub fn validate(profile: &Value) -> Result<(), Invalid> { )?; } require( - git["signing_key"].as_str().is_some_and(|key| { - (40..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_hexdigit()) - }), - "Use a full OpenPGP signing fingerprint", + git["ssh_signing_key"] + .as_str() + .is_some_and(is_ssh_signing_key), + "Invalid SSH signing key", )?; let github = fields(&top["github"], &["login", "token"])?; require( diff --git a/crates/buzz-sandbox-reaper/src/profile_tests.rs b/crates/buzz-sandbox-reaper/src/profile_tests.rs index 2f7fec2b297..db586ba715a 100644 --- a/crates/buzz-sandbox-reaper/src/profile_tests.rs +++ b/crates/buzz-sandbox-reaper/src/profile_tests.rs @@ -1,6 +1,9 @@ use super::*; use serde_json::json; +/// An agent signing key in the shape `ssh-keygen -t ed25519` writes. +pub(crate) const SSH_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ==\nAAAAMwAAAAtzc2gtZWQyNTUxOQ\n-----END OPENSSH PRIVATE KEY-----\n"; + /// A profile `profile.py` accepts, with fixture secrets. pub(crate) fn valid(developer: &str, owner: &str) -> Value { json!({ @@ -8,7 +11,7 @@ pub(crate) fn valid(developer: &str, owner: &str) -> Value { "developer": developer, "owner_pubkey": owner, "git": {"name": "Ada Lovelace", "email": "ada@example.com", - "signing_key": "ABCDEF0123456789ABCDEF0123456789ABCDEF01"}, + "ssh_signing_key": SSH_KEY}, "github": {"login": "ada-l", "token": "ghp_fixtureGitHubTokenDoNotLeak"}, "via": {"profile": "[providers.op]\nkind = \"1password\"\n\n[services.github]\nprovider = \"op\"\n", "op_token": "ops_fixtureOnePasswordTokenDoNotLeak"}, @@ -40,6 +43,9 @@ fn accepts_a_complete_profile() { validate(&value).unwrap(); value["models"] = json!({"openrouter_api_key": "sk-or-x", "anthropic_api_key": "k"}); validate(&value).unwrap(); + // The final newline is optional, as `profile.py`'s `\n?` allows. + value["git"]["ssh_signing_key"] = json!(SSH_KEY.trim_end_matches('\n')); + validate(&value).unwrap(); } #[test] @@ -80,20 +86,36 @@ fn refuses_each_rule_with_its_message() { ("/git/email", json!("a".repeat(257)), "Invalid Git identity"), ("/git/name", json!("Ada\nLovelace"), "Invalid Git identity"), ("/git/email", json!(7), "Invalid Git identity"), + // The old OpenPGP fingerprint is not a signing key the Sandbox can use. + ( + "/git/ssh_signing_key", + json!("ABCDEF0123456789ABCDEF0123456789ABCDEF01"), + "Invalid SSH signing key", + ), + ( + "/git/ssh_signing_key", + json!("-----BEGIN OPENSSH PRIVATE KEY-----\n-----END OPENSSH PRIVATE KEY-----\n"), + "Invalid SSH signing key", + ), ( - "/git/signing_key", - json!("ABCDEF01"), - "Use a full OpenPGP signing fingerprint", + "/git/ssh_signing_key", + json!( + "-----BEGIN OPENSSH PRIVATE KEY-----\nab cd\n-----END OPENSSH PRIVATE KEY-----\n" + ), + "Invalid SSH signing key", ), ( - "/git/signing_key", - json!("G".repeat(40)), - "Use a full OpenPGP signing fingerprint", + "/git/ssh_signing_key", + json!(format!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----\n", + "a".repeat(4000) + )), + "Invalid SSH signing key", ), ( - "/git/signing_key", - json!("a".repeat(65)), - "Use a full OpenPGP signing fingerprint", + "/git/ssh_signing_key", + json!("-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----\n"), + "Invalid SSH signing key", ), ("/github/login", json!("-ada"), "Invalid GitHub login"), ( diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 923fb9961ce..b94c4a3e9af 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -2030,8 +2030,10 @@ 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. -Unlike `profile.py enroll`, the manager does not check the 1Password token -(`op whoami`); the image still refuses a profile it cannot load. +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 +not know it. **The relay provider.** `buzz-backend-relay` (`crates/buzz-backend-relay`) speaks the provider protocol on stdin and stdout like diff --git a/voltage/images/README.md b/voltage/images/README.md index 063cefe38cd..27b180b93f9 100644 --- a/voltage/images/README.md +++ b/voltage/images/README.md @@ -1,6 +1,7 @@ # Voltage images This directory holds the container images that Voltage builds from this fork for Buzz remote agents. +To set up and use remote agents, see [Run Buzz agents in staging Sandboxes](../remote-agents.md). It is Voltage-only, so upstream block/buzz syncs do not conflict with it. The agent images moved here from voltagecloud/images at `f944157`. The same workflow also builds the Buzz relay image from the upstream root `Dockerfile`; see [The relay image](#the-relay-image). diff --git a/voltage/images/buzz-agent/Dockerfile b/voltage/images/buzz-agent/Dockerfile index 89e1a8bcbb1..8c9a6a9a194 100644 --- a/voltage/images/buzz-agent/Dockerfile +++ b/voltage/images/buzz-agent/Dockerfile @@ -85,11 +85,8 @@ COPY --chmod=0644 voltage/images/buzz-agent/subagents.md /etc/buzz-agent/subagen COPY --chmod=0755 voltage/images/buzz-agent/heavy-bin/ /opt/buzz-heavy/bin/ COPY --chmod=0755 voltage/images/buzz-agent/gh.sh /usr/local/bin/gh COPY --chmod=0755 voltage/images/buzz-agent/via.sh /usr/local/bin/via -COPY --chmod=0755 voltage/images/buzz-agent/gpg.sh /usr/local/bin/buzz-gpg COPY --chmod=0755 voltage/images/buzz-agent/entrypoint.sh /usr/local/bin/sprig-entrypoint -COPY --chmod=0755 voltage/images/buzz-agent/setup.js /usr/local/bin/buzz-agent-setup -RUN ln -s /usr/local/bin/buzz-agent-setup /usr/local/bin/buzz-setup-complete \ - && ln -s /usr/local/bin/buzz-heavy /usr/local/bin/buzz-agent-gate \ +RUN ln -s /usr/local/bin/buzz-heavy /usr/local/bin/buzz-agent-gate \ && for name in buzz-acp buzz-agent buzz-dev-mcp rg tree buzz git-credential-nostr git-sign-nostr; do \ ln -s sprig "/usr/local/bin/$name"; \ done diff --git a/voltage/images/buzz-agent/Dockerfile.dockerignore b/voltage/images/buzz-agent/Dockerfile.dockerignore index 677c9f0a594..d9fba31564e 100644 --- a/voltage/images/buzz-agent/Dockerfile.dockerignore +++ b/voltage/images/buzz-agent/Dockerfile.dockerignore @@ -85,7 +85,6 @@ voltage/ !voltage/images/buzz-agent/package.json !voltage/images/buzz-agent/package-lock.json !voltage/images/buzz-agent/entrypoint.sh -!voltage/images/buzz-agent/setup.js !voltage/images/buzz-agent/account.py !voltage/images/buzz-agent/profile.py !voltage/images/buzz-agent/cache.py @@ -93,7 +92,6 @@ voltage/ !voltage/images/buzz-agent/checkpoint-gitleaks.toml !voltage/images/buzz-agent/gh.sh !voltage/images/buzz-agent/via.sh -!voltage/images/buzz-agent/gpg.sh !voltage/images/buzz-agent/heavy.py !voltage/images/buzz-agent/heavy-bin/ !voltage/images/buzz-agent/worker.py diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index 310407e29fd..f4ab2e4f2e3 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -32,7 +32,6 @@ bash test-image.sh buzz-agent:local bash test-harness.sh buzz-agent:local python3 -O test-native-agent.py buzz-agent:local python3 test-policy.py buzz-agent:local -python3 test-setup.py buzz-agent:local python3 test-accounts.py buzz-agent:local python3 test-checkpoint.py buzz-agent:local python3 test-checkpoint-storage.py buzz-agent:local @@ -56,7 +55,7 @@ Alloy retains sanitized events in Loki; workspace deletion must not delete the c The provider supplies `BUZZ_SANDBOX_POD_UID` through the downward API and binds its Secret to `BUZZ_SANDBOX_INITIAL_POD_UID`. If these UIDs differ, the entrypoint refuses startup. A replacement Pod requires explicit workspace recovery. -New sessions load the developer's current account profile; interactive model logins still require separate enrollment. +New sessions load the developer's current account profile. Checkpoints use the developer's S3 prefix and service account from Terraform. The harness stops adapter processes before invoking `buzz-agent-checkpoint save`, including cancellation during startup. A failed save retains the Sandbox for recovery; do not delete its workspace. @@ -89,35 +88,14 @@ Developer tools and configured MCP servers remain available; the previous text-o ## Developer accounts -Enroll GitHub, development `via`, and model accounts once per developer. Each of their five Sandboxes loads the same owner-bound profile. -The Depot cache credential is shared through the development vault, not enrolled; see [Depot build cache](#depot-build-cache). -Terraform creates the empty Secrets Manager secret and grants the developer's service account read-only access to its exact ARN. +Each developer enrolls one profile from the Buzz app (GPUI), through the Sandbox manager over the relay. Every one of their Sandboxes and workers loads it. +Terraform creates the empty Secrets Manager secret per developer; the manager writes it, and the developer's `agent-workspace` and `agent-worker` service accounts can only read its exact ARN. +The manager sets `BUZZ_DEVELOPER_ID` and `BUZZ_DEVELOPER_PROFILE_ARN` in each launch from its developer config, and fills in the profile's `developer` from the signing owner's entry. +[profile.example.json](profile.example.json) shows the stored shape. -On the developer workstation, copy [profile.example.json](profile.example.json) to a private file outside the repository. -Set mode `0600`, replace the placeholders, and supply the approved development via configuration as the `via.profile` string. -Use an AWS profile with `secretsmanager:PutSecretValue` permission for the developer's profile ARN. -Run with Python 3.11+, AWS CLI, GitHub CLI, and 1Password CLI installed: - -```bash -AWS_PROFILE=tee8z python3 voltage/images/buzz-agent/profile.py enroll \ - --arn '' \ - --file /private/path/developer-profile.json -``` - -The helper verifies the GitHub login and 1Password service account before publishing. It prints only the developer ID and secret version. -Remove the local profile file after enrollment. Never paste its contents into Buzz, Git, Terraform, tickets, or logs. - -Configure these public values in each agent's Buzz launch environment: - -```text -BUZZ_DEVELOPER_ID=tee8z -BUZZ_DEVELOPER_PROFILE_ARN= -``` - -Use that developer's `agent-workspace` service account. The bootstrap checks the profile against Buzz's verified owner before writing credentials. -It configures Git identity, GitHub, via, and the vault's Depot credential in private workspace files. Missing profiles, owner mismatches, and unsafe paths refuse startup. -Re-enrollment publishes a new version for new sessions. Running sessions retain their current credentials; revoke upstream tokens when required. -Credentials are excluded from checkpoints. Profile access through IRSA still needs staging validation; the local test uses a Secrets Manager emulator. +The bootstrap checks the profile against Buzz's verified owner, then writes Git identity, GitHub, via, the agent signing key, and the vault's Depot credential to private workspace files. +Missing profiles, owner mismatches, and unsafe paths refuse startup. A new enrollment takes effect for new sessions; running sessions keep their credentials, so revoke upstream tokens when required. +Credentials are excluded from checkpoints. The image test uses a Secrets Manager emulator. `models` accepts these optional credentials: @@ -137,34 +115,15 @@ A profile can contain `openai_api_key` or `codex_access_token`, not both: Codex A profile can contain `anthropic_api_key` or `claude_oauth_token`, not both. Claude Code prefers an API key, so mixing them would bill the API silently. `openrouter_api_key` serves the Buzz agent runtime (`buzz-agent`), which can run any OpenRouter model: set the agent's model to an OpenRouter model ID such as `qwen/qwen3-coder`. The bootstrap refuses an inherited `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_ACCESS_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, or `OPENROUTER_API_KEY`. - -Keep the API keys and the Codex access token out of `models` to use the Codex setup gate for manual model-account enrollment; the gate refuses them. -`claude_oauth_token` is not an API key, so a profile with it can still use the gate. -The gate session runs Codex only, so the bootstrap does not export the Claude token into it. Other Sandboxes that load the same profile receive it. Model credentials stay in the harness environment and are never written to workspace files. Tools that the agent runs inherit them. The checkpoint secret scan also refuses Claude subscription tokens. -Do not clone one rotating Codex login cache across concurrent Sandboxes; [OpenAI's account-cache guidance](https://learn.chatgpt.com/docs/auth/ci-cd-auth) requires separate runners or serialized use. - -The profile stores a public signing fingerprint. Connect the selected remote signer and import its public key before testing signed commits. -This change does not distribute private signing keys or establish the signing connection. -For manual enrollment or troubleshooting in an existing Sandbox: +### Commit signing -Set `BUZZ_GIT_IDENTITY=user`, `BUZZ_GIT_NAME`, `BUZZ_GIT_EMAIL`, and `BUZZ_GIT_SIGNING_KEY` for the workspace owner. -The account helper configures Git to require OpenPGP signatures. `buzz-gpg` uses an available GPG agent without starting one. -Remote signing transport still requires validation; no private key is embedded or exported by this image. - -From an interactive terminal in the selected Sandbox: - -```bash -buzz-agent-account github --github-user tee8z -buzz-agent-account via -buzz-agent-account status -``` - -The helpers accept credentials through hidden terminal input and store them in private workspace files. -GitHub enrollment checks the selected login. `via` uses the developer's development-vault service account and public service profile. -Deleting the workspace removes local copies; upstream revocation remains separate. +Every commit and tag is signed with an agent-only SSH key, `git.ssh_signing_key` in the profile: one OpenSSH Ed25519 private key. +The GPUI enroll form generates it with `ssh-keygen`. Once the manager stores the profile, the developer's local GitHub CLI (`gh`) adds the public half to their GitHub account as a signing key; otherwise the form shows the key and the steps to add it. Commits then show as Verified with the developer's verified Git email. The agents' GitHub token needs no account permission. +The bootstrap writes it to `.config/buzz-agent/ssh-signing-key` (mode `0600`, excluded from checkpoints), and `buzz-agent-account init` configures `gpg.format ssh` with `commit.gpgsign` and `tag.gpgsign`. Without the key, Git refuses to commit. +The developer's own GPG key never leaves their device. Revoke agent signing by deleting that key under GitHub → Settings → SSH and GPG keys; enrolling again creates a new one. ### Depot build cache @@ -180,9 +139,7 @@ secrets.token = "op://Buzz Agents Dev/Depot/credential" ``` Without the service, or when the vault read fails, the session starts without the remote cache and logs `Depot cache unavailable`. -To enroll a different token by hand in an existing Sandbox, run `buzz-agent-account depot --organization `. The harness and its tools inherit the cache configuration. -For a separate interactive shell, use `buzz-agent-cache bash` after enrollment. The image pins Depot CLI and sccache; each repository selects its Turborepo version. Rust uses sccache's WebDAV backend, Turborepo uses `TURBO_API`, and Go 1.24+ uses `depot gocache`. Credentials stay in private runtime files and are excluded from workspace checkpoints. @@ -202,18 +159,6 @@ python3 voltage/images/buzz-agent/test-depot.py buzz-agent:local \ This builds a unique fixture in two fresh containers. The first must upload; the second must report a remote hit without cache errors. The test prints only counts and elapsed time. It does not persist credentials outside the disposable containers. -### Codex setup gate - -The provider supplies `BUZZ_SETUP_MODE=codex-device-v1`, the agent public key, Pod UID, generation, image digest, random nonce, and setup deadline. -The gate validates the runtime configuration and waits for owner enrollment before starting Buzz. -After enrollment, invoke `buzz-setup-complete` with this public binding on stdin: - -```json -{"version":1,"agent_pubkey":"","pod_uid":"","generation":"","image_digest":"sha256:"} -``` - -The helper consumes a private, one-use acknowledgement. Timeout or termination leaves the session for explicit cleanup. - ## Publish to staging Each merge to `main` that changes the image inputs publishes this image to `voltage-buzz-agent-staging`, tagged with the full commit SHA. diff --git a/voltage/images/buzz-agent/account.py b/voltage/images/buzz-agent/account.py index 1acfcf6e9fb..d1a87e46499 100644 --- a/voltage/images/buzz-agent/account.py +++ b/voltage/images/buzz-agent/account.py @@ -1,14 +1,11 @@ #!/usr/bin/env python3 -"""Configure one developer's container account without printing credentials.""" +"""Configure the developer's Git identity in a Sandbox without printing credentials.""" import argparse -import getpass import json import os from pathlib import Path -import re import subprocess import sys -import tomllib WORKSPACE_HOME = Path('/home/agent') DIRECTORY = WORKSPACE_HOME / '.config/buzz-agent' @@ -21,26 +18,6 @@ def run(*command, **kwargs): return result.stdout.strip() -def save(name, value): - DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(DIRECTORY, 0o700) - target = DIRECTORY / name - # Refuse links left by an earlier process; never follow a credential path. - fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) - with os.fdopen(fd, 'w') as stream: - os.fchmod(stream.fileno(), 0o600) - stream.write(value) - - -def secret(prompt): - if not sys.stdin.isatty() or not sys.stderr.isatty(): - raise ValueError('credential input requires the developer interactive terminal') - value = getpass.getpass(prompt) - if not value or len(value) > 8192 or any(c.isspace() for c in value): - raise ValueError('invalid credential input') - return value - - def initialize(): if os.environ.get('BUZZ_GIT_IDENTITY') != 'user': return @@ -50,61 +27,23 @@ def initialize(): raise ValueError('developer Git identity is incomplete') for option, key in fields.items(): run('git', 'config', '--global', option, os.environ[key]) - for option, value in {'commit.gpgsign': 'true', 'tag.gpgsign': 'true', - 'gpg.format': 'openpgp', 'gpg.program': '/usr/local/bin/buzz-gpg', + # Every commit and tag is signed with the developer's agent-only SSH key file. + for option, value in {'commit.gpgsign': 'true', 'tag.gpgsign': 'true', 'gpg.format': 'ssh', 'credential.https://github.com.helper': '!gh auth git-credential'}.items(): run('git', 'config', '--global', option, value) os.chmod(WORKSPACE_HOME / '.gitconfig', 0o600) - (WORKSPACE_HOME / '.gnupg').mkdir(mode=0o700, exist_ok=True) def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=['init', 'github', 'via', 'via-profile', 'depot', 'status']) - parser.add_argument('--github-user') - parser.add_argument('--organization') + parser.add_argument('action', choices=['init', 'status']) args = parser.parse_args() if args.action == 'init': initialize() - elif args.action == 'github': - if not args.github_user: - raise ValueError('expected --github-user is required') - token = secret('GitHub token for selected development repositories (hidden): ') - env = {**os.environ, 'GH_TOKEN': token} - actor = run('/usr/bin/gh', 'api', 'user', '--jq', '.login', env=env) - if actor.casefold() != args.github_user.casefold(): - raise ValueError('GitHub token belongs to a different developer') - save('github-token', token) - print('GitHub account configured:', actor) - elif args.action == 'via': - token = secret('Development vault service-account token (hidden): ') - # Validate before storing; no vault contents or returned values are printed. - run('/usr/local/bin/op', 'whoami', '--format=json', env={ - **{k: v for k, v in os.environ.items() if not k.startswith('OP_')}, - 'OP_SERVICE_ACCOUNT_TOKEN': token}) - save('op-token', token) - print('Development vault account configured') - elif args.action == 'via-profile': - value = sys.stdin.read(65537) - if len(value) > 65536: - raise ValueError('via profile is too large') - profile = tomllib.loads(value) - if not profile.get('services') or not profile.get('providers'): - raise ValueError('via profile requires providers and services') - save('via.toml', value) - run('/opt/agent-tools/via', '--config', str(DIRECTORY / 'via.toml'), 'capabilities', '--json') - print('Development via profile installed') - elif args.action == 'depot': - if not args.organization or not re.fullmatch(r'[a-zA-Z0-9_-]{1,64}', args.organization): - raise ValueError('a Depot --organization ID is required') - token = secret('Depot user or organization token (hidden): ') - save('depot-token', token) - save('depot.json', json.dumps({'organization': args.organization})) - print('Depot credential stored; verify access with cold and warm builds') else: print(json.dumps({'git_identity': run('git', 'config', '--global', 'user.email'), 'signing_required': run('git', 'config', '--global', 'commit.gpgsign') == 'true', - 'signer_connected': (WORKSPACE_HOME / '.gnupg/S.gpg-agent').is_socket(), + 'signing_key': (DIRECTORY / 'ssh-signing-key').is_file(), 'github_configured': (DIRECTORY / 'github-token').is_file(), 'depot_configured': all((DIRECTORY / name).is_file() for name in ['depot-token', 'depot.json']), 'via_configured': all((DIRECTORY / name).is_file() for name in ['op-token', 'via.toml'])})) diff --git a/voltage/images/buzz-agent/entrypoint.sh b/voltage/images/buzz-agent/entrypoint.sh index eb02df5d063..508108d0609 100755 --- a/voltage/images/buzz-agent/entrypoint.sh +++ b/voltage/images/buzz-agent/entrypoint.sh @@ -27,9 +27,4 @@ fi # buzz-acp owns ephemeral Git setup for both native and MCP shells. # The image init forwards termination to this direct harness child. -if [[ -n "${BUZZ_SETUP_MODE+x}" ]]; then - # Reject harness arguments during the fixed, single-use Codex setup flow. - [[ "$#" -eq 0 ]] || exit 1 - exec /usr/local/bin/buzz-agent-setup gate -fi exec /usr/local/bin/buzz-agent-cache buzz-acp "$@" diff --git a/voltage/images/buzz-agent/gpg.sh b/voltage/images/buzz-agent/gpg.sh deleted file mode 100644 index 0e635b48cb1..00000000000 --- a/voltage/images/buzz-agent/gpg.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# Fail if the developer's forwarded signer is absent; never create a local signer. -exec /usr/bin/gpg --no-autostart "$@" diff --git a/voltage/images/buzz-agent/profile.example.json b/voltage/images/buzz-agent/profile.example.json index 08dcfd92199..99c17937449 100644 --- a/voltage/images/buzz-agent/profile.example.json +++ b/voltage/images/buzz-agent/profile.example.json @@ -5,7 +5,7 @@ "git": { "name": "REPLACE_WITH_GIT_NAME", "email": "REPLACE_WITH_GIT_EMAIL", - "signing_key": "REPLACE_WITH_FULL_PUBLIC_SIGNING_FINGERPRINT" + "ssh_signing_key": "REPLACE_WITH_AGENT_SSH_SIGNING_PRIVATE_KEY (ssh-keygen -t ed25519; register the .pub on GitHub as a signing key)" }, "github": { "login": "tee8z", diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index 001da05c5ae..b7bde99a077 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Enroll one developer profile and load it into that developer's Sandboxes.""" +"""Load the developer profile the Sandbox manager enrolled into that developer's Sandboxes.""" import argparse import json import os @@ -8,7 +8,6 @@ import stat import subprocess import sys -import tempfile import tomllib MAX_BYTES = 65536 @@ -16,13 +15,13 @@ FIELDS = {'version', 'developer', 'owner_pubkey', 'git', 'github', 'via', 'models'} MODEL_FIELDS = {'openai_api_key', 'codex_access_token', 'anthropic_api_key', 'claude_oauth_token', 'openrouter_api_key'} -# The Codex device-login gate refuses these: billed API keys and a Codex access token. -API_KEY_FIELDS = {'openai_api_key', 'codex_access_token', 'anthropic_api_key', 'openrouter_api_key'} # Inherited values would override or mix with the profile's selected accounts. INHERITED_MODEL_VARIABLES = ('OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OPENROUTER_API_KEY') # The shared Depot cache credential: the via profile's `depot` service, read from the vault. DEPOT_SERVICE = 'depot' +# The developer's agent-only commit signing key, registered on their GitHub account at enrollment. +SSH_SIGNING_KEY = re.compile(r'-----BEGIN OPENSSH PRIVATE KEY-----\n[A-Za-z0-9+/=\n]{1,4000}-----END OPENSSH PRIVATE KEY-----\n?') def require(condition, message): @@ -46,11 +45,12 @@ def validate(profile): and re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?', profile['developer']), 'Invalid developer ID') require(isinstance(profile['owner_pubkey'], str) and re.fullmatch(r'[0-9a-f]{64}', profile['owner_pubkey']), 'Invalid Buzz owner') - fields(profile['git'], {'name', 'email', 'signing_key'}) - for value in profile['git'].values(): + fields(profile['git'], {'name', 'email', 'ssh_signing_key'}) + for value in (profile['git']['name'], profile['git']['email']): require(isinstance(value, str) and 0 < len(value) <= 256 and not any(ord(c) < 32 for c in value), 'Invalid Git identity') - require(re.fullmatch(r'[0-9A-Fa-f]{40,64}', profile['git']['signing_key']), 'Use a full OpenPGP signing fingerprint') + require(isinstance(profile['git']['ssh_signing_key'], str) + and SSH_SIGNING_KEY.fullmatch(profile['git']['ssh_signing_key']), 'Invalid SSH signing key') fields(profile['github'], {'login', 'token'}) require(isinstance(profile['github']['login'], str) and re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9-]{0,38}', profile['github']['login']), 'Invalid GitHub login') @@ -75,17 +75,6 @@ def validate(profile): require(len(json.dumps(profile).encode()) <= MAX_BYTES, 'Profile exceeds Secrets Manager size limit') -def private_file(path): - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) - with os.fdopen(descriptor) as stream: - info = os.fstat(stream.fileno()) - require(stat.S_ISREG(info.st_mode) and not info.st_mode & 0o077 - and info.st_uid == os.getuid(), 'Profile input must be an owner-only regular file') - value = stream.read(MAX_BYTES + 1) - require(len(value.encode()) <= MAX_BYTES, 'Profile input is too large') - return value - - def secret_region(arn, developer): match = re.fullmatch(r'arn:aws:secretsmanager:([a-z0-9-]+):[0-9]{12}:secret:voltage/staging/agents/' + re.escape(developer) + r'/profile-[a-zA-Z0-9]{6}', arn) @@ -105,27 +94,6 @@ def aws(arn, developer, operation, *args): '--secret-id', arn, '--output', 'json', *args)) -def enroll(args): - profile = json.loads(private_file(args.file)) - validate(profile) - secret_region(args.arn, profile['developer']) - # Check the actual GitHub actor before any upload. The token is never an argument. - env = {**os.environ, 'GH_TOKEN': profile['github']['token'], 'GH_HOST': 'github.com'} - login = command('gh', 'api', '--hostname', 'github.com', 'user', '--jq', '.login', env=env).strip() - require(login.casefold() == profile['github']['login'].casefold(), 'GitHub credential belongs to a different developer') - env = {k: v for k, v in os.environ.items() if not k.startswith('OP_')} - env['OP_SERVICE_ACCOUNT_TOKEN'] = profile['via']['op_token'] - command('op', 'whoami', '--format=json', env=env) - # AWS reads the private file directly. Secret bytes never enter argv or stdout. - # Rewrite validated data to avoid a caller modifying the source between validation and upload. - with tempfile.TemporaryDirectory(prefix='buzz-profile-') as directory: - path = Path(directory) / 'profile.json' - path.write_text(json.dumps(profile)) - path.chmod(0o600) - result = aws(args.arn, profile['developer'], 'put-secret-value', '--secret-string', 'file://' + str(path)) - print(json.dumps({'developer': profile['developer'], 'version_id': result['VersionId']})) - - def write_file(path, value): path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) # Every parent must be an owner-controlled directory, not a symlink into a repository. @@ -179,18 +147,18 @@ def bootstrap(): require(profile['developer'] == developer and profile['owner_pubkey'] == os.environ.get('BUZZ_ACP_AGENT_OWNER'), 'Developer profile owner does not match the verified launch owner') require(result['ARN'] == arn, 'Secret response does not match the requested profile') - setup_mode = bool(os.environ.get('BUZZ_SETUP_MODE')) - require(not setup_mode or not API_KEY_FIELDS & set(profile['models']), - 'Interactive account login cannot be combined with model API keys or a Codex access token') env = os.environ.copy() for key in INHERITED_MODEL_VARIABLES: require(not env.get(key), 'Model credentials must come from the selected developer profile') + # Commits are signed with the agent-only SSH key; the key file stays out of checkpoints. + signing_key = DIRECTORY / 'ssh-signing-key' env.update(BUZZ_GIT_IDENTITY='user', BUZZ_GIT_NAME=profile['git']['name'], - BUZZ_GIT_EMAIL=profile['git']['email'], BUZZ_GIT_SIGNING_KEY=profile['git']['signing_key'], + BUZZ_GIT_EMAIL=profile['git']['email'], BUZZ_GIT_SIGNING_KEY=str(signing_key), GIT_AUTHOR_NAME=profile['git']['name'], GIT_AUTHOR_EMAIL=profile['git']['email'], GIT_COMMITTER_NAME=profile['git']['name'], GIT_COMMITTER_EMAIL=profile['git']['email']) files = {'github-token': profile['github']['token'], 'op-token': profile['via']['op_token'], 'via.toml': profile['via']['profile'], + 'ssh-signing-key': profile['git']['ssh_signing_key'].rstrip('\n') + '\n', 'profile.json': json.dumps({'developer': developer, 'version_id': result['VersionId']})} cache = depot(profile) if cache: @@ -213,8 +181,7 @@ def bootstrap(): # BUZZ_AGENT_PROVIDER still wins. env['OPENROUTER_API_KEY'] = profile['models']['openrouter_api_key'] env.setdefault('BUZZ_AGENT_PROVIDER', 'openrouter') - # The Codex setup session cannot use the Claude token, and its gate refuses CLAUDE_* variables. - if 'claude_oauth_token' in profile['models'] and not setup_mode: + if 'claude_oauth_token' in profile['models']: env['CLAUDE_CODE_OAUTH_TOKEN'] = profile['models']['claude_oauth_token'] command('/usr/local/bin/buzz-agent-account', 'init', env=env) return env @@ -251,16 +218,11 @@ def install_subagent_guide(env): def main(): parser = argparse.ArgumentParser(description=__doc__) actions = parser.add_subparsers(dest='action', required=True) - enroll_parser = actions.add_parser('enroll', help='Validate and publish a private profile once per developer') - enroll_parser.add_argument('--arn', required=True) - enroll_parser.add_argument('--file', required=True, type=Path) run_parser = actions.add_parser('run', help='Load the verified owner profile, then start the harness') run_parser.add_argument('arguments', nargs=argparse.REMAINDER) actions.add_parser('work', help='Load the verified owner profile, then run one headless worker task') args = parser.parse_args() - if args.action == 'enroll': - enroll(args) - elif args.action == 'work': + if args.action == 'work': env = bootstrap() worker = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-agent-worker'] os.execvpe(worker[0], worker, env) @@ -269,11 +231,7 @@ def main(): restore_checkpoint() install_subagent_guide(env) arguments = args.arguments[1:] if args.arguments[:1] == ['--'] else args.arguments - if env.get('BUZZ_SETUP_MODE'): - require(not arguments, 'Interactive setup does not accept harness arguments') - harness = ['/usr/local/bin/buzz-agent-setup', 'gate'] - else: - harness = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-acp', *arguments] + harness = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-acp', *arguments] os.execvpe(harness[0], harness, env) diff --git a/voltage/images/buzz-agent/setup.js b/voltage/images/buzz-agent/setup.js deleted file mode 100644 index 21018dbb351..00000000000 --- a/voltage/images/buzz-agent/setup.js +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/local/bin/node -// Fixed Codex enrollment boundary. Kubernetes operators and same-UID code are trusted. -'use strict'; -const fs = require('node:fs'); -const { performance } = require('node:perf_hooks'); - -const DIRECTORY = '/home/agent/.agent-setup'; -const ACK = `${DIRECTORY}/complete.json`; -const CODEX_HOME = '/home/agent/.codex'; -const CONFIG = 'cli_auth_credentials_store = "file"\n'; -const SESSION_CONFIG = '{"cli_auth_credentials_store":"file"}'; -const PATH = '/opt/buzz-heavy/bin:/opt/buzz-runtimes/node_modules/.bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin'; -const FIELDS = ['version', 'agent_pubkey', 'pod_uid', 'generation', 'image_digest', 'setup_nonce', 'deadline']; -const COMPLETION_FIELDS = ['version', 'agent_pubkey', 'pod_uid', 'generation', 'image_digest']; -const env = process.env; - -function requireValue(condition) { - if (!condition) throw new Error('refused'); -} - -function descriptor() { - requireValue(process.getuid() === 10001 && process.getgid() === 10001); - requireValue(env.HOME === '/home/agent' && process.cwd() === '/home/agent'); - requireValue(env.PATH === PATH); - requireValue(env.BUZZ_SETUP_MODE === 'codex-device-v1'); - requireValue(env.BUZZ_ACP_AGENT_COMMAND === 'codex-acp'); - requireValue(!env.BUZZ_ACP_AGENT_ARGS); - requireValue(env.BUZZ_ACP_MCP_COMMAND === 'buzz-dev-mcp'); - requireValue(env.BUZZ_ACP_PERMISSION_MODE === 'agent-full-access'); - requireValue(env.CODEX_HOME === CODEX_HOME); - requireValue(env.CODEX_CONFIG === SESSION_CONFIG); - requireValue(env.NO_BROWSER === '1'); - for (const key of Object.keys(env)) { - if (/^(CODEX_|OPENAI_|ANTHROPIC_|CLAUDE_)/.test(key)) { - requireValue(['CODEX_HOME', 'CODEX_CONFIG'].includes(key)); - } - requireValue(!['MODEL_PROVIDER', 'DEFAULT_AUTH_REQUEST'].includes(key)); - if (key.startsWith('BUZZ_SETUP_')) { - requireValue(['BUZZ_SETUP_MODE', 'BUZZ_SETUP_NONCE', 'BUZZ_SETUP_DEADLINE', - 'BUZZ_SETUP_AGENT_PUBKEY', 'BUZZ_SETUP_POD_UID', 'BUZZ_SETUP_IMAGE_DIGEST'].includes(key)); - } - } - const value = { - version: 1, - agent_pubkey: env.BUZZ_SETUP_AGENT_PUBKEY, - pod_uid: env.BUZZ_SETUP_POD_UID, - generation: env.BUZZ_MANAGED_AGENT_START_NONCE, - image_digest: env.BUZZ_SETUP_IMAGE_DIGEST, - setup_nonce: env.BUZZ_SETUP_NONCE, - deadline: Number(env.BUZZ_SETUP_DEADLINE), - }; - requireValue(/^[0-9a-f]{64}$/.test(value.agent_pubkey || '')); - requireValue(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.pod_uid || '')); - requireValue(/^(?:[0-9a-f]{8}|[0-9a-f]{32})$/.test(value.generation || '')); - requireValue(/^sha256:[0-9a-f]{64}$/.test(value.image_digest || '')); - requireValue(/^[0-9a-f]{32}$/.test(value.setup_nonce || '')); - requireValue(/^[0-9]+$/.test(env.BUZZ_SETUP_DEADLINE || '')); - requireValue(Number.isSafeInteger(value.deadline)); - checkDeadline(value); - return value; -} - -function checkDeadline(value) { - const remaining = value.deadline - Date.now() / 1000; - requireValue(remaining > 0 && remaining <= 900); -} - -function privateDirectory(path) { - const stat = fs.lstatSync(path); - requireValue(stat.isDirectory() && !stat.isSymbolicLink()); - requireValue(stat.uid === 10001 && (stat.mode & 0o777) === 0o700); -} - -function ensureDirectory(path) { - try { fs.mkdirSync(path, { mode: 0o700 }); } - catch (error) { if (error.code !== 'EEXIST') throw error; } - privateDirectory(path); -} - -function privateFile(path) { - // Nonregular paths must reach fstat without waiting for a FIFO writer. - const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); - try { - const stat = fs.fstatSync(fd); - requireValue(stat.isFile() && stat.uid === 10001 && stat.nlink === 1); - requireValue((stat.mode & 0o777) === 0o600 && stat.size <= 4096); - // Bound the read even if trusted same-UID code grows the file after fstat. - const buffer = Buffer.alloc(4097); - const length = fs.readSync(fd, buffer, 0, buffer.length, 0); - requireValue(length <= 4096); - return buffer.toString('utf8', 0, length); - } finally { fs.closeSync(fd); } -} - -function exclusiveFile(path, content) { - const fd = fs.openSync(path, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600); - try { fs.writeFileSync(fd, content); } - finally { fs.closeSync(fd); } -} - -function absent(path) { - try { fs.lstatSync(path); } - catch (error) { if (error.code === 'ENOENT') return; throw error; } - throw new Error('refused'); -} - -function validateAck(text, expected, fields = FIELDS) { - requireValue(Buffer.byteLength(text) <= 4096); - // Bindings have fixed scalar formats, so property counts also reject duplicate keys. - requireValue((text.match(/"(?:[^"\\]|\\.)*"\s*:/g) || []).length === fields.length); - const value = JSON.parse(text); - requireValue(value && typeof value === 'object' && !Array.isArray(value)); - requireValue(Object.keys(value).length === fields.length); - for (const key of fields) requireValue(Object.hasOwn(value, key) && value[key] === expected[key]); - checkDeadline(expected); -} - -async function complete(expected) { - privateDirectory(DIRECTORY); - validateAck(privateFile(`${DIRECTORY}/pending.json`), expected); - absent(`${DIRECTORY}/consumed`); - let input = ''; - for await (const chunk of process.stdin) { - input += chunk.toString('utf8'); - requireValue(Buffer.byteLength(input) <= 4096); - } - validateAck(input, expected, COMPLETION_FIELDS); - privateDirectory(CODEX_HOME); - requireValue(privateFile(`${CODEX_HOME}/config.toml`) === CONFIG); - absent(ACK); - // The retained exclusive claim prevents a second helper invocation or replay. - exclusiveFile(`${DIRECTORY}/ack-claimed`, 'claimed\n'); - exclusiveFile(`${DIRECTORY}/ack-staged`, JSON.stringify(expected)); - fs.renameSync(`${DIRECTORY}/ack-staged`, ACK); - console.log('agent setup completion accepted'); -} - -async function gate(expected) { - requireValue(typeof process.execve === 'function'); - ensureDirectory(DIRECTORY); - exclusiveFile(`${DIRECTORY}/gate-claimed`, 'claimed\n'); - absent(ACK); - absent(`${DIRECTORY}/consumed`); - ensureDirectory(CODEX_HOME); - absent(`${CODEX_HOME}/auth.json`); - exclusiveFile(`${CODEX_HOME}/config.toml`, CONFIG); - exclusiveFile(`${DIRECTORY}/pending.json`, JSON.stringify(expected)); - console.log('agent setup pending'); - const started = performance.now(); - const remainingMs = (expected.deadline - Date.now() / 1000) * 1000; - while (true) { - checkDeadline(expected); - requireValue(performance.now() - started < remainingMs); - privateDirectory(DIRECTORY); - let exists = false; - try { fs.lstatSync(ACK); exists = true; } - catch (error) { if (error.code !== 'ENOENT') throw error; } - if (exists) { - validateAck(privateFile(ACK), expected); - privateDirectory(CODEX_HOME); - requireValue(privateFile(`${CODEX_HOME}/config.toml`) === CONFIG); - exclusiveFile(`${DIRECTORY}/consumed`, 'consumed\n'); - fs.renameSync(ACK, `${DIRECTORY}/accepted`); - fs.unlinkSync(`${DIRECTORY}/accepted`); - fs.unlinkSync(`${DIRECTORY}/pending.json`); - env.CODEX_HOME = CODEX_HOME; - env.CODEX_CONFIG = SESSION_CONFIG; - env.NO_BROWSER = '1'; - console.log('agent setup complete; starting harness'); - process.execve('/usr/local/bin/buzz-agent-cache', ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-acp'], env); - throw new Error('refused'); - } - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - -process.on('SIGTERM', () => process.exit(143)); -process.on('SIGINT', () => process.exit(130)); -(async () => { - const helper = process.argv[1] === '/usr/local/bin/buzz-setup-complete'; - requireValue(helper ? process.argv.length === 2 : process.argv.length === 3 && ['gate', 'complete'].includes(process.argv[2])); - const expected = descriptor(); - if (!helper && process.argv[2] === 'gate') await gate(expected); - else await complete(expected); -})().catch(() => { - console.error('agent setup refused'); - process.exitCode = 1; -}); diff --git a/voltage/images/buzz-agent/test-accounts.py b/voltage/images/buzz-agent/test-accounts.py index 47729eb6b9c..95f071942e5 100644 --- a/voltage/images/buzz-agent/test-accounts.py +++ b/voltage/images/buzz-agent/test-accounts.py @@ -1,4 +1,4 @@ -"""Exercise the packaged user Git configuration with a disposable test signer.""" +"""Exercise the packaged user Git configuration with a disposable agent signing key.""" import subprocess import sys @@ -14,38 +14,33 @@ def run(*args,input=None,env=None): home=Path('/home/agent') with tempfile.TemporaryDirectory() as temp: - signer=Path(temp)/'signer' - signer.mkdir(mode=0o700) - run('gpg','--homedir',str(signer),'--batch','--pinentry-mode','loopback', - '--passphrase','','--quick-generate-key','Signing Fixture ', - 'ed25519','sign','1d') - public=run('gpg','--homedir',str(signer),'--armor','--export') - listing=run('gpg','--homedir',str(signer),'--with-colons','--list-keys').decode() - fingerprint=next(line.split(':')[9] for line in listing.splitlines() if line.startswith('fpr:')) + # A profile Sandbox signs with the developer's agent-only SSH key file. + key=Path(temp)/'agent-signing-key' + run('ssh-keygen','-q','-t','ed25519','-N','','-C','fixture agents','-f',str(key)) + key.chmod(0o600) env={**os.environ,'BUZZ_GIT_IDENTITY':'user','BUZZ_GIT_NAME':'Signing Fixture', - 'BUZZ_GIT_EMAIL':'fixture@example.invalid','BUZZ_GIT_SIGNING_KEY':fingerprint} + 'BUZZ_GIT_EMAIL':'fixture@example.invalid','BUZZ_GIT_SIGNING_KEY':str(key)} run('buzz-agent-account','init',env=env) - client=home/'.gnupg' - run('gpg','--homedir',str(client),'--no-autostart','--import',input=public) - extra=run('gpgconf','--homedir',str(signer),'--list-dirs','agent-extra-socket').decode().strip() - (client/'S.gpg-agent').symlink_to(extra) repo=home/'fixture-repo' repo.mkdir() run('git','-C',str(repo),'init') (repo/'source.txt').write_text('signed fixture\n') run('git','-C',str(repo),'add','source.txt') - run('git','-C',str(repo),'commit','-m','test: verify remote signer') - run('git','-C',str(repo),'verify-commit','HEAD') - identity=run('git','-C',str(repo),'log','-1','--format=%an|%ae|%cn|%ce|%GF').decode().strip() - assert identity=='Signing Fixture|fixture@example.invalid|Signing Fixture|fixture@example.invalid|'+fingerprint - assert not list((client/'private-keys-v1.d').glob('*.key')) - (client/'S.gpg-agent').unlink() - (repo/'source.txt').write_text('signer disconnected\n') + run('git','-C',str(repo),'commit','-m','test: sign with the agent key') + signers=Path(temp)/'allowed_signers' + signers.write_text('fixture@example.invalid '+key.with_suffix('.pub').read_text()) + run('git','-C',str(repo),'-c','gpg.ssh.allowedSignersFile='+str(signers),'verify-commit','HEAD') + identity=run('git','-C',str(repo),'log','-1','--format=%an|%ae|%cn|%ce').decode().strip() + assert identity=='Signing Fixture|fixture@example.invalid|Signing Fixture|fixture@example.invalid' + status=json.loads(run('buzz-agent-account','status',env=env)) + assert status['signing_required'] is True + # Without the key, Git refuses to commit rather than commit unsigned. + key.unlink() + (repo/'source.txt').write_text('key removed\n') run('git','-C',str(repo),'add','source.txt') - failed=subprocess.run(['git','-C',str(repo),'commit','-m','test: require connected signer'],capture_output=True,timeout=15) - assert failed.returncode != 0, 'missing signer produced an unsigned commit' - print('PASS: author/committer identity and signature verified through an extra socket; workspace has no private key; signer loss refuses commits') - run('gpgconf','--homedir',str(signer),'--kill','gpg-agent') + failed=subprocess.run(['git','-C',str(repo),'commit','-m','test: require the signing key'],capture_output=True,timeout=15) + assert failed.returncode != 0, 'a missing signing key produced an unsigned commit' + print('PASS: author/committer identity and SSH signature verified; a missing key refuses commits') ''' result = subprocess.run(['docker', 'run', '--rm', '-i', '--network', 'none', diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 757a9b3d20c..68df4f81b5e 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -7,7 +7,7 @@ OWNER = 'a' * 64 PROFILE = { 'version': 1, 'developer': 'alice', 'owner_pubkey': OWNER, - 'git': {'name': 'Alice Fixture', 'email': 'alice@example.invalid', 'signing_key': 'A' * 40}, + 'git': {'name': 'Alice Fixture', 'email': 'alice@example.invalid', 'ssh_signing_key': '-----BEGIN OPENSSH PRIVATE KEY-----\nc3ludGhldGljLWZpeHR1cmUta2V5\n-----END OPENSSH PRIVATE KEY-----\n'}, 'github': {'login': 'alice', 'token': 'synthetic-profile-github'}, 'via': {'profile': '[providers.fixture]\ntype="fixture"\n[services.fixture]\nprovider="fixture"\n' '[services.depot]\nprovider="fixture"\n' @@ -35,47 +35,13 @@ def run(*args, **kwargs): if ready.returncode == 0: break time.sleep(0.2) else: raise RuntimeError('Secrets Manager fixture did not start') -value = json.loads(run('aws', 'secretsmanager', 'create-secret', '--name', - 'voltage/staging/agents/alice/profile')) +# The Sandbox manager enrolls profiles (its tests cover refusals); seed one as it stores it. path = Path('/tmp/profile.json') path.write_text(json.dumps(profile)) path.chmod(0o600) -tools = Path('/tmp/account-tools') -tools.mkdir() -for name, body in {'gh':'printf "alice\\n"', 'op':'printf "{}\\n"'}.items(): - tool = tools/name - tool.write_text('#!/bin/sh\n'+body+'\n') - tool.chmod(0o700) -env = {**os.environ, 'PATH':str(tools)+':'+os.environ['PATH']} -receipt = json.loads(run('buzz-agent-profile', 'enroll', '--arn', value['ARN'], '--file', str(path), env=env)) -require(receipt['developer'] == 'alice', 'wrong enrollment receipt') -before = run('aws', 'secretsmanager', 'get-secret-value', '--secret-id', value['ARN']) -# An enrollment error must not change the current profile. -(tools/'gh').write_text('#!/bin/sh\nprintf "someone-else\\n"\n') -failed = subprocess.run(['buzz-agent-profile', 'enroll', '--arn', value['ARN'], '--file', str(path)], - env=env, capture_output=True, text=True, timeout=60) -require(failed.returncode != 0 and 'synthetic-profile-' not in failed.stdout+failed.stderr, 'wrong actor was enrolled or leaked') -after = run('aws', 'secretsmanager', 'get-secret-value', '--secret-id', value['ARN']) -require(json.loads(before)['VersionId'] == json.loads(after)['VersionId'], 'failed enrollment changed the profile') -# Claude Code would prefer the API key, so a profile cannot carry both Claude credentials. -(tools/'gh').write_text('#!/bin/sh\nprintf "alice\\n"\n') -both = dict(profile, models={'anthropic_api_key':'synthetic-profile-anthropic', 'claude_oauth_token':'synthetic-profile-claude-oauth'}) -path.write_text(json.dumps(both)) -failed = subprocess.run(['buzz-agent-profile', 'enroll', '--arn', value['ARN'], '--file', str(path)], - env=env, capture_output=True, text=True, timeout=60) -require(failed.returncode != 0 and 'synthetic-profile-' not in failed.stdout+failed.stderr, 'mixed Claude credentials were enrolled or leaked') -after = run('aws', 'secretsmanager', 'get-secret-value', '--secret-id', value['ARN']) -require(json.loads(before)['VersionId'] == json.loads(after)['VersionId'], 'refused enrollment changed the profile') -# Codex would read the access token first, so a profile cannot carry both Codex credentials. -for models in ({'openai_api_key':'synthetic-profile-openai', 'codex_access_token':'at-synthetic-profile-codex'}, - {'codex_access_token':'synthetic-profile-codex'}): - path.write_text(json.dumps(dict(profile, models=models))) - failed = subprocess.run(['buzz-agent-profile', 'enroll', '--arn', value['ARN'], '--file', str(path)], - env=env, capture_output=True, text=True, timeout=60) - require(failed.returncode != 0 and 'synthetic-profile-' not in failed.stdout+failed.stderr, 'invalid Codex credentials were enrolled or leaked') -after = run('aws', 'secretsmanager', 'get-secret-value', '--secret-id', value['ARN']) -require(json.loads(before)['VersionId'] == json.loads(after)['VersionId'], 'refused enrollment changed the profile') -print(json.dumps({'arn':value['ARN'], 'version':receipt['version_id']})) +value = json.loads(run('aws', 'secretsmanager', 'create-secret', '--name', + 'voltage/staging/agents/alice/profile', '--secret-string', 'file://'+str(path))) +print(json.dumps({'arn':value['ARN'], 'version':value['VersionId']})) ''' BOOT = COMMON + r''' @@ -88,52 +54,17 @@ def run(*args, **kwargs): + ('exit 1\n' if mode == 'no-depot' else '') + 'case "$3" in */organization) printf fixture-org;; */credential) printf synthetic-profile-depot;; *) exit 1;; esac\n') (tools/'op').chmod(0o700) -# The Codex setup gate requires the image's exact PATH, so its modes keep it. -if mode not in ('manual-gate', 'gate-api-key'): - env['PATH'] = str(tools)+':'+env['PATH'] -def rotate(models, via_profile=None): +env['PATH'] = str(tools)+':'+env['PATH'] +def rotate(models, git=None): stored = json.loads(run('aws', 'secretsmanager', 'get-secret-value', '--secret-id', env['BUZZ_DEVELOPER_PROFILE_ARN'])) profile = json.loads(stored['SecretString']) profile['models'] = models - if via_profile is not None: - profile['via']['profile'] = via_profile + if git is not None: + profile['git'] = git path = Path('/tmp/rotated-profile.json') path.write_text(json.dumps(profile)) path.chmod(0o600) return json.loads(run('aws', 'secretsmanager', 'put-secret-value', '--secret-id', stored['ARN'], '--secret-string', 'file://'+str(path))) -if mode in ('manual-gate', 'gate-api-key'): - # A subscription token may share the profile with the Codex gate; billed API keys may not. - # Without the fake op on PATH, a depot service would make the bootstrap call the image's op. - no_depot = '[providers.fixture]\ntype="fixture"\n[services.fixture]\nprovider="fixture"\n' - updated = (rotate({'claude_oauth_token':'synthetic-profile-claude-oauth'}, no_depot) - if mode == 'manual-gate' else None) - env.update(BUZZ_SETUP_MODE='codex-device-v1', BUZZ_SETUP_AGENT_PUBKEY='c'*64, - BUZZ_SETUP_POD_UID='11111111-2222-3333-4444-555555555555', BUZZ_SETUP_IMAGE_DIGEST='sha256:'+'d'*64, - BUZZ_SETUP_NONCE='e'*32, BUZZ_SETUP_DEADLINE=str(int(time.time())+120), - BUZZ_MANAGED_AGENT_START_NONCE='f'*32, BUZZ_ACP_AGENT_COMMAND='codex-acp', - BUZZ_ACP_MCP_COMMAND='buzz-dev-mcp', BUZZ_ACP_PERMISSION_MODE='agent-full-access', - CODEX_HOME='/home/agent/.codex', CODEX_CONFIG='{"cli_auth_credentials_store":"file"}', NO_BROWSER='1') - process = subprocess.Popen(['sprig-entrypoint'], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: - if mode == 'gate-api-key': - require(process.wait(timeout=60) != 0, 'account gate accepted profile API keys') - require(not Path('/home/agent/.agent-setup').exists(), 'refused profile reached the account gate') - print('PASS: refused gate-api-key') - raise SystemExit(0) - for attempt in range(100): - if Path('/home/agent/.agent-setup/pending.json').exists(): break - require(process.poll() is None, 'profile did not reach the account gate') - time.sleep(0.1) - else: raise RuntimeError('profile did not reach the account gate') - receipt = json.loads(Path('/home/agent/.config/buzz-agent/profile.json').read_text()) - require(receipt['version_id'] == updated['VersionId'], 'new session did not load the rotated profile') - require(not Path('/home/agent/.codex/auth.json').exists(), 'account mode silently became API-key mode') - finally: - if process.poll() is None: process.terminate() - output, error = process.communicate(timeout=10) - require('synthetic-profile-' not in output+error, 'account gate leaked a credential') - print('PASS: rotated shared profile with a Claude subscription token reaches manual account-login gate') - raise SystemExit(0) expected = {'OPENAI_API_KEY':'synthetic-profile-openai', 'ANTHROPIC_API_KEY':'synthetic-profile-anthropic', 'CLAUDE_CODE_OAUTH_TOKEN':None, 'OPENROUTER_API_KEY':None, 'BUZZ_AGENT_PROVIDER':None, 'SCCACHE_WEBDAV_PASSWORD':'synthetic-profile-depot', 'BUZZ_GIT_IDENTITY':'user'} @@ -154,6 +85,11 @@ def rotate(models, via_profile=None): rotate({'openai_api_key':'synthetic-profile-openai', 'anthropic_api_key':'synthetic-profile-anthropic'}) expected.update(SCCACHE_WEBDAV_PASSWORD=None) if mode == 'inherited-codex': env['CODEX_ACCESS_TOKEN'] = 'at-synthetic-unselected-account' +# A stored profile the manager would not enroll is refused at startup too. +if mode == 'mixed-codex': + rotate({'openai_api_key':'synthetic-profile-openai', 'codex_access_token':'at-synthetic-profile-codex'}) +if mode == 'gpg-fingerprint': + rotate({}, {'name': 'Alice Fixture', 'email': 'alice@example.invalid', 'signing_key': 'A' * 40}) if mode == 'inherited-openrouter': env['OPENROUTER_API_KEY'] = 'synthetic-unselected-account' if mode == 'wrong-owner': env['BUZZ_ACP_AGENT_OWNER'] = 'b'*64 if mode == 'wrong-developer': env['BUZZ_DEVELOPER_ID'] = 'bob' @@ -233,10 +169,10 @@ def main(): input=SEED, text=True, capture_output=True, timeout=180) if result.returncode: raise RuntimeError('Enrollment fixture failed: '+result.stderr[-2000:]) receipt = json.loads(result.stdout) - print('PASS: one developer enrollment; wrong GitHub actor cannot replace it', flush=True) + print('PASS: seeded one enrolled developer profile', flush=True) modes = ['valid']*5 + ['wrong-owner', 'wrong-developer', 'mixed-model', 'inherited-claude', - 'inherited-codex', 'inherited-openrouter', 'symlink', 'missing', 'gate-api-key', 'claude-oauth', - 'subscriptions', 'no-depot', 'manual-gate'] + 'inherited-codex', 'inherited-openrouter', 'symlink', 'missing', 'claude-oauth', + 'subscriptions', 'no-depot', 'mixed-codex', 'gpg-fingerprint'] for mode in modes: result = subprocess.run([*base, '-e', 'BUZZ_DEVELOPER_PROFILE_ARN='+receipt['arn'], '-e', 'BUZZ_DEVELOPER_ID=alice', '-e', 'BUZZ_ACP_AGENT_OWNER='+OWNER, diff --git a/voltage/images/buzz-agent/test-restore.py b/voltage/images/buzz-agent/test-restore.py index ed7a19fa192..c242e7292eb 100644 --- a/voltage/images/buzz-agent/test-restore.py +++ b/voltage/images/buzz-agent/test-restore.py @@ -7,7 +7,7 @@ OWNER = 'a' * 64 PROFILE = { 'version': 1, 'developer': 'fixture', 'owner_pubkey': OWNER, - 'git': {'name': 'Restore Fixture', 'email': 'fixture@example.invalid', 'signing_key': 'A' * 40}, + 'git': {'name': 'Restore Fixture', 'email': 'fixture@example.invalid', 'ssh_signing_key': '-----BEGIN OPENSSH PRIVATE KEY-----\nc3ludGhldGljLWZpeHR1cmUta2V5\n-----END OPENSSH PRIVATE KEY-----\n'}, 'github': {'login': 'fixture', 'token': 'synthetic-profile-github'}, 'via': {'profile': '[providers.fixture]\ntype="fixture"\n[services.fixture]\nprovider="fixture"\n', 'op_token': 'synthetic-profile-op'}, diff --git a/voltage/images/buzz-agent/test-setup.py b/voltage/images/buzz-agent/test-setup.py deleted file mode 100644 index 782f595e85b..00000000000 --- a/voltage/images/buzz-agent/test-setup.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Synthetic setup-gate checks; no login, account state, model or relay access.""" -import json -import subprocess -import sys -import time -import unittest -import uuid - -IMAGE = sys.argv.pop(1) if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else 'buzz-agent:local' -PUBKEY = 'a' * 64 -POD_UID = '11111111-2222-3333-4444-555555555555' -DIGEST = 'sha256:' + 'b' * 64 -NONCE = 'c' * 32 -GENERATION = '1234abcd' -ACK = {'version': 1, 'agent_pubkey': PUBKEY, 'pod_uid': POD_UID, - 'generation': GENERATION, 'image_digest': DIGEST} - - -def docker(*args, **kwargs): - return subprocess.run(['docker', *args], text=True, capture_output=True, timeout=15, **kwargs) - - -class SetupTests(unittest.TestCase): - def setUp(self): - self.names = [] - - def tearDown(self): - for name in self.names: - docker('rm', '-f', name) - - def start(self, overrides=None, deadline=None, shell=None): - name = 'dev2900-setup-' + uuid.uuid4().hex[:12] - self.names.append(name) - env = {'BUZZ_SETUP_MODE': 'codex-device-v1', - 'BUZZ_SETUP_AGENT_PUBKEY': PUBKEY, 'BUZZ_SETUP_POD_UID': POD_UID, - 'BUZZ_SETUP_IMAGE_DIGEST': DIGEST, 'BUZZ_SETUP_NONCE': NONCE, - 'BUZZ_SETUP_DEADLINE': str(deadline or int(time.time()) + 120), - 'BUZZ_MANAGED_AGENT_START_NONCE': GENERATION, - 'BUZZ_ACP_AGENT_COMMAND': 'codex-acp', 'BUZZ_ACP_MCP_COMMAND': 'buzz-dev-mcp', - 'BUZZ_ACP_PERMISSION_MODE': 'agent-full-access', - 'CODEX_HOME': '/home/agent/.codex', 'NO_BROWSER': '1', - 'CODEX_CONFIG': '{"cli_auth_credentials_store":"file"}', - 'BUZZ_PRIVATE_KEY': '0' * 63 + '1', 'BUZZ_RELAY_URL': 'wss://relay.invalid'} - env.update(overrides or {}) - args = ['run', '-d', '--name', name, '--network', 'none', '--cap-drop', 'ALL', - '--security-opt', 'no-new-privileges', - '--tmpfs', '/home/agent:uid=10001,gid=10001,mode=0700'] - for key, value in env.items(): - if value is not None: - args += ['-e', key + '=' + value] - if shell: - args += ['--entrypoint', '/bin/bash', IMAGE, '-ceu', shell] - else: - args += [IMAGE] - result = docker(*args) - self.assertEqual(result.returncode, 0, result.stderr) - return name - - def pending(self, name): - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - result = docker('exec', name, 'test', '-f', '/home/agent/.agent-setup/pending.json') - if result.returncode == 0: - return - time.sleep(0.05) - self.fail('gate did not become pending') - - def complete(self, name, value=ACK): - text = value if isinstance(value, str) else json.dumps(value) - return docker('exec', '-i', name, '/usr/local/bin/buzz-setup-complete', input=text) - - def wait_exit(self, name, expected): - result = docker('wait', name) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), str(expected)) - self.assertEqual(docker('inspect', name, '--format', '{{.State.Running}}').stdout.strip(), 'false') - - def write_ack(self, name, text, suffix=''): - result = docker('exec', '-i', name, '/bin/bash', '-ceu', - 'umask 077; cat > /home/agent/.agent-setup/test-injected' + suffix + - '; mv /home/agent/.agent-setup/test-injected /home/agent/.agent-setup/complete.json', input=text) - self.assertEqual(result.returncode, 0, result.stderr) - - def full_ack(self, name): - result = docker('exec', name, 'cat', '/home/agent/.agent-setup/pending.json') - self.assertEqual(result.returncode, 0) - return json.loads(result.stdout) - - def test_sandbox_generation_is_accepted(self): - generation = 'a' * 32 - name = self.start(overrides={'BUZZ_MANAGED_AGENT_START_NONCE': generation}) - self.pending(name) - result = self.complete(name, dict(ACK, generation=generation)) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_success_private_startup_config_and_one_use(self): - name = self.start() - self.pending(name) - result = docker('exec', name, '/bin/bash', '-ceu', - 'test "$(stat -c %a /home/agent/.codex)" = 700; ' - 'test "$(stat -c %a /home/agent/.codex/config.toml)" = 600; ' - 'test "$(stat -c %a /home/agent/.agent-setup)" = 700; ' - 'test "$(stat -c %a /home/agent/.agent-setup/pending.json)" = 600; ' - 'cat /home/agent/.codex/config.toml') - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, 'cli_auth_credentials_store = "file"\n') - listing = docker('top', name, '-eo', 'pid,args') - self.assertEqual(listing.returncode, 0, listing.stderr) - processes = listing.stdout - for executable in ['buzz-acp', 'codex-acp', 'app-server']: - self.assertNotIn(executable, processes) - result = self.complete(name) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn(NONCE, result.stdout + result.stderr) - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - if docker('exec', name, 'test', '-f', '/home/agent/.agent-setup/consumed').returncode == 0: - break - time.sleep(0.05) - else: - self.fail('acknowledgement was not consumed') - listing = docker('top', name, '-eo', 'pid,args') - self.assertEqual(listing.returncode, 0, listing.stderr) - self.assertIn('buzz-acp', listing.stdout) - self.assertNotEqual(self.complete(name).returncode, 0) - for path in ['complete.json', 'pending.json', 'accepted']: - self.assertNotEqual(docker('exec', name, 'test', '-e', '/home/agent/.agent-setup/' + path).returncode, 0) - logs = docker('logs', name).stdout - self.assertEqual(logs.count('agent setup complete; starting harness'), 1) - self.assertNotIn(NONCE, logs) - self.assertEqual(docker('kill', '--signal', 'TERM', name).returncode, 0) - self.wait_exit(name, 0) - - def test_helper_rejects_wrong_bindings_unknown_and_duplicate_fields(self): - name = self.start() - self.pending(name) - bad = [dict(ACK, version=2), dict(ACK, pod_uid='other'), dict(ACK, generation='deadbeef'), - dict(ACK, image_digest='sha256:' + 'd' * 64), dict(ACK, agent_pubkey='d' * 64), - dict(ACK, extra='refused'), dict(ACK, setup_nonce=NONCE), - json.dumps(ACK)[:-1] + ', "version": 1}', '[]', 'x' * 4097] - for value in bad: - with self.subTest(value_type=type(value).__name__): - result = self.complete(name, value) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn(NONCE, result.stdout + result.stderr) - self.assertEqual(self.complete(name).returncode, 0) - - def test_gate_rejects_forged_seven_field_ack(self): - name = self.start() - self.pending(name) - value = self.full_ack(name) - value['setup_nonce'] = 'd' * 32 - self.write_ack(name, json.dumps(value)) - self.wait_exit(name, 1) - - def test_gate_rejects_public_ack_with_unsafe_mode(self): - name = self.start() - self.pending(name) - value = self.full_ack(name) - self.write_ack(name, json.dumps(value), '; chmod 644 /home/agent/.agent-setup/test-injected') - self.wait_exit(name, 1) - - def test_gate_rejects_symlink_ack(self): - name = self.start() - self.pending(name) - result = docker('exec', name, 'ln', '-s', '/home/agent/.agent-setup/pending.json', - '/home/agent/.agent-setup/complete.json') - self.assertEqual(result.returncode, 0) - self.wait_exit(name, 1) - - def test_gate_rejects_unwritten_fifo_ack_and_stops_enrollment(self): - name = self.start(deadline=int(time.time()) + 5) - self.pending(name) - self.assertEqual(docker('exec', '-d', name, '/bin/bash', '-c', 'sleep 300 & wait').returncode, 0) - started = time.monotonic() - result = docker('exec', name, 'mkfifo', '-m', '600', '/home/agent/.agent-setup/complete.json') - self.assertEqual(result.returncode, 0, result.stderr) - # Timeout shorter than the setup deadline proves immediate type refusal. - result = subprocess.run(['docker', 'wait', name], text=True, capture_output=True, timeout=3) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), '1') - self.assertLess(time.monotonic() - started, 3) - self.assertNotEqual(docker('exec', name, 'true').returncode, 0) - logs = docker('logs', name) - self.assertIn('agent setup refused', logs.stderr) - self.assertNotIn('starting harness', logs.stdout) - - def test_helper_rejects_unwritten_fifo_private_inputs(self): - for path in ['.agent-setup/pending.json', '.codex/config.toml']: - with self.subTest(path=path): - name = self.start() - self.pending(name) - target = '/home/agent/' + path - result = docker('exec', name, '/bin/bash', '-ceu', - 'rm "' + target + '"; mkfifo -m 600 "' + target + '"') - self.assertEqual(result.returncode, 0, result.stderr) - result = subprocess.run(['docker', 'exec', '-i', name, '/usr/local/bin/buzz-setup-complete'], - input=json.dumps(ACK), text=True, capture_output=True, timeout=3) - self.assertNotEqual(result.returncode, 0) - self.assertIn('agent setup refused', result.stderr) - self.assertEqual(docker('kill', '--signal', 'TERM', name).returncode, 0) - self.wait_exit(name, 143) - - def test_gate_rejects_modified_startup_config(self): - name = self.start() - self.pending(name) - self.assertEqual(docker('exec', name, '/bin/bash', '-ceu', - 'printf \'cli_auth_credentials_store = "ephemeral"\\n\' > /home/agent/.codex/config.toml').returncode, 0) - self.assertNotEqual(self.complete(name).returncode, 0) - self.write_ack(name, json.dumps(self.full_ack(name))) - self.wait_exit(name, 1) - - def test_completion_rechecks_codex_directory_symlink(self): - name = self.start() - self.pending(name) - value = self.full_ack(name) - self.assertEqual(docker('exec', name, '/bin/bash', '-ceu', - 'mv /home/agent/.codex /home/agent/.codex-moved; ' - 'ln -s /home/agent/.codex-moved /home/agent/.codex').returncode, 0) - self.assertNotEqual(self.complete(name).returncode, 0) - self.write_ack(name, json.dumps(value)) - self.wait_exit(name, 1) - - def test_invalid_descriptor_and_overrides_refuse(self): - for values in [{'CODEX_PATH': '/bin/true'}, {'CODEX_CONFIG': '{}'}, - {'CODEX_HOME': ''}, {'NO_BROWSER': ''}, - {'OPENAI_API_KEY': 'fictional'}, {'DEFAULT_AUTH_REQUEST': '{}'}, - {'BUZZ_ACP_AGENT_COMMAND': 'buzz-agent'}, {'BUZZ_ACP_AGENT_ARGS': '--version'}, - {'BUZZ_SETUP_EXTRA': 'unknown'}, {'BUZZ_SETUP_POD_UID': 'missing'}]: - with self.subTest(field=next(iter(values))): - self.wait_exit(self.start(overrides=values), 1) - - def test_expired_and_overlong_deadlines_refuse(self): - self.wait_exit(self.start(deadline=int(time.time()) - 1), 1) - self.wait_exit(self.start(deadline=int(time.time()) + 950), 1) - - def test_container_execution_mode_required_before_pending(self): - for values in [{'BUZZ_ACP_PERMISSION_MODE': None}, - {'BUZZ_ACP_PERMISSION_MODE': ''}, - {'BUZZ_ACP_PERMISSION_MODE': 'default'}, - {'BUZZ_ACP_PERMISSION_MODE': 'bypass-permissions'}]: - with self.subTest(values=values): - name = self.start(overrides=values) - self.wait_exit(name, 1) - logs = docker('logs', name) - self.assertIn('agent setup refused', logs.stderr) - self.assertNotIn('agent setup pending', logs.stdout) - self.assertNotIn('starting harness', logs.stdout) - - def test_timeout_and_term_stop_exec_created_enrollment_processes(self): - for signal in [None, 'TERM']: - with self.subTest(signal=signal): - name = self.start(deadline=int(time.time()) + (3 if signal is None else 120)) - self.pending(name) - self.assertEqual(docker('exec', '-d', name, '/bin/bash', '-c', 'sleep 300 & wait').returncode, 0) - started = time.monotonic() - if signal: - self.assertEqual(docker('kill', '--signal', signal, name).returncode, 0) - self.wait_exit(name, 143 if signal else 1) - self.assertLess(time.monotonic() - started, 5) - self.assertNotEqual(docker('exec', name, 'true').returncode, 0) - - def test_helper_requires_pending_and_gate_reentry_refuses(self): - name = self.start(shell='exec sleep 120') - self.assertNotEqual(self.complete(name).returncode, 0) - active = self.start() - self.pending(active) - result = docker('exec', active, '/usr/local/bin/buzz-agent-setup', 'gate') - self.assertNotEqual(result.returncode, 0) - - -if __name__ == '__main__': - unittest.main(verbosity=2) diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md new file mode 100644 index 00000000000..d51f9aa86b4 --- /dev/null +++ b/voltage/remote-agents.md @@ -0,0 +1,399 @@ +# Run Buzz agents in staging Sandboxes + +A Remote workspace agent runs each thread in its own Kubernetes Sandbox in the staging cluster. +You need only your Buzz identity. You do not need AWS, Kubernetes, or VPN access. +Your agents use your GitHub account, your model subscriptions, and a shared set of team tools. +Every commit an agent makes is signed with a key that is on your GitHub account, so GitHub shows it as **Verified**. + +- **Admins:** start at [Onboard a developer](#onboard-a-developer). +- **Developers:** start at [Set up your agents](#set-up-your-agents). + +## How it works + +```mermaid +flowchart LR + subgraph laptop["Developer's machine"] + app["Buzz app
(Voltage build)"] + relayprov["buzz-backend-relay"] + gh["GitHub CLI (gh)"] + app --> relayprov + app -- "at enroll: add signing key" --> gh + end + relay["Buzz relay
wss://buzz.voltage.cloud"] + subgraph staging["Staging cluster"] + manager["Sandbox manager
ns buzz-sandbox-manager"] + sandbox["Sandbox, one per thread
ns buzz-agent-staging-<id>"] + grafana["Grafana"] + argo["Argo CD"] + end + secrets[("Secrets Manager
developer profile")] + vault[("1Password
Buzz Agents Dev")] + github["GitHub"] + models["Model providers"] + + relayprov -- "kind 24210, NIP-44 encrypted" --> relay + relay --> manager + manager -- "write at enroll" --> secrets + manager -- "start, end, recover" --> sandbox + sandbox -- "read at start" --> secrets + sandbox -- "Depot, Grafana, Linear, Argo CD tokens" --> vault + sandbox -- "messages in the thread" --> relay + sandbox -- "push, PRs, signed commits" --> github + sandbox --> models + sandbox -- "via" --> grafana + sandbox -- "via" --> argo + gh --> github +``` + +- **The Buzz app** sends requests as you. Each request is a Nostr event of kind 24210. Its content is encrypted to the Sandbox manager with NIP-44. +- **The Sandbox manager** accepts requests only from owner keys in its developer list. It starts a Sandbox in your namespace for each thread. +- **The Sandbox** loads your profile, starts the harness you chose, and replies in the thread as your agent. +- **The reaper** removes Sandboxes after their sessions end. + +### Enroll once + +You enroll one profile. All of your Remote workspace agents use it. + +```mermaid +sequenceDiagram + actor Dev as Developer + participant App as Buzz app + participant GH as GitHub + participant Mgr as Sandbox manager + participant SM as Secrets Manager + + Dev->>App: Fill in the enroll form, press Enroll profile + App->>GH: GET /user with the GitHub token + GH-->>App: login and name + App->>App: ssh-keygen: new Ed25519 signing key + App->>Mgr: enroll request (profile, encrypted, signed as you) + Mgr->>GH: check the token's login + Mgr->>SM: store the profile + Mgr-->>App: done, profile version + alt gh is installed and signed in as the same account + App->>GH: gh adds the public key as a signing key + else gh cannot add it + App-->>Dev: show the public key and the steps to add it + end +``` + +### Start a session + +```mermaid +sequenceDiagram + actor Dev as Developer + participant App as Buzz app + participant Mgr as Sandbox manager + participant SB as Sandbox + participant SM as Secrets Manager + + Dev->>App: Mention the agent in a new thread + App->>Mgr: deploy request for that thread + Mgr->>SB: create the Sandbox in your namespace + SB->>SM: read the profile + SB->>SB: write credentials, configure Git signing, read Depot from the vault + SB-->>Dev: the agent replies in the thread + Dev->>App: End session + App->>Mgr: stop request + Mgr->>SB: stop the Sandbox + SB->>SB: save a checkpoint before it exits +``` + +### Where each credential lives + +| Credential | You enter it | Stored in | Used by | +|---|---|---|---| +| Buzz identity | Buzz app sign-in | Your machine | The app, to sign requests | +| GitHub token | Enroll form | Profile (Secrets Manager) | The Sandbox: clone, push, PRs | +| Agent signing key | Created by the app | Profile; public half on GitHub | The Sandbox: sign commits | +| 1Password service account token | Enroll form | Profile | The Sandbox: read the Buzz Agents Dev vault | +| Codex, Claude, OpenAI, Anthropic, OpenRouter credentials | Enroll form | Profile | The harness in the Sandbox | +| Depot, Grafana, Linear, Argo CD tokens | Nobody: admins keep them in the vault | 1Password Buzz Agents Dev | The Sandbox, through `via` | + +The app never saves your tokens. The profile is encrypted in transit and at rest. Checkpoints never include credentials. + +## Onboard a developer + +Do these steps once for each new developer. You need the staging VPN for step 2. + +1. **Add the developer to Terraform.** In voltage-terraform, add an entry to `developers` in `v2/environments/staging-agent/terraform.tfvars`: + + ```hcl + developers = { + = { owner_pubkey = "" } + } + ``` + + The developer ID has 1 to 32 lowercase letters, digits, or hyphens. Each owner key can appear only once. +2. **Apply it locally.** Merge the PR, then run `make plan` and `make apply` in `v2/environments/staging-agent`. + CI cannot apply this module, because its Kubernetes identity is read-only. + The apply creates the namespace `buzz-agent-staging-`, the profile secret, and the developer's permissions. +3. **Restart the manager and the reaper.** Both read the developer list only at startup: + + ```sh + kubectl --context voltage-staging -n buzz-sandbox-manager rollout restart deploy/buzz-sandbox-manager + kubectl --context voltage-staging -n buzz-sandbox-reaper rollout restart deploy/buzz-sandbox-reaper + ``` +4. **Give the developer vault access.** Make sure they are in the 1Password **Engineering** group, which can read **Buzz Agents Dev**. + If they cannot create a service account, create one for them. See [1Password service account](#1password-service-account). +5. **Check their model seats.** Codex access tokens need a ChatGPT Business or Enterprise seat. The workspace owner must allow access tokens. Claude needs a Claude seat. + +The developer's Buzz identity must be a member of the `buzz.voltage.cloud` community. + +## Set up your agents + +### 1. Install the Buzz app (Voltage build) + +Remote workspace agents need the `buzz-gpui-voltage` package. On NixOS, add the flake input and install the package: + +```nix +# flake.nix inputs +buzz-gpui.url = "github:voltagecloud/buzz/?dir=apps/buzz-gpui"; + +# configuration.nix +environment.systemPackages = [ buzz-gpui.packages.${system}.buzz-gpui-voltage ]; +``` + +Sign in with your Buzz identity and join the `buzz.voltage.cloud` community. +Install the GitHub CLI (`gh`) and sign in with `gh auth login`, so the app can add your signing key to GitHub. + +### 2. Get your credentials + +Paste credentials only into the enroll form. Never put them in chat, tickets, or Git. + +#### GitHub token + +Create a fine-grained token in GitHub under **Settings → Developer settings → Fine-grained tokens**. + +| Setting | Value | +|---|---| +| Resource owner | `voltagecloud` | +| Repository access | All repositories | +| Contents | Read and write | +| Pull requests | Read and write | +| Workflows | Read and write, only if agents can change `.github/workflows` | +| Issues | Read and write (optional) | +| Actions, Commit statuses | Read (optional) | +| Account permissions | None | + +If the organization requires approval, the token stays **pending** until an owner approves it. + +#### 1Password service account + +Agents read team secrets from the **Buzz Agents Dev** vault through a 1Password service account. A service account is a login for software, and its token can read only the vaults you give it. Create one for each developer, so that you can revoke each one separately. + +1Password owners and administrators can create service accounts. Other members need that permission from an admin. If you do not have it, ask an admin to create the account and share the token through 1Password. +If the menus below differ, use 1Password's [service account guide](https://developer.1password.com/docs/service-accounts/get-started/). + +To create it in the web app at [teamvoltage.1password.com](https://teamvoltage.1password.com): + +1. Open **Developer**, then **Directory**. +2. Under **Infrastructure Secrets Management**, select **Other**, then **Service Account**. +3. Enter a name, for example `buzz-agents-`. +4. Select only the **Buzz Agents Dev** vault, with **Read items**. +5. Set an expiry, for example 90 days. +6. Select **Create Account**. Then select **Save in 1Password**, because the token shows only once. + +To create it with the 1Password CLI instead: + +```sh +op service-account create "buzz-agents-" --expires-in 90d --vault "Buzz Agents Dev:read_items" +``` + +To check the token, run this command. The output must show **Buzz Agents Dev** and no other vault. + +```sh +OP_SERVICE_ACCOUNT_TOKEN='ops_…' op vault list +``` + +#### Codex access token (ChatGPT Business or Enterprise) + +1. On chatgpt.com, switch to the **Voltage** workspace. +2. Open [chatgpt.com/admin/access-tokens](https://chatgpt.com/admin/access-tokens) and select **Create**. +3. Enter a name, select the Codex scope if the dialog shows it, and select the longest validity. +4. Copy the token (`at-…`). It shows only once. + +The token does not refresh, so all of your Sandboxes can use it at the same time. Before it expires, create a new one and enroll again. +A personal ChatGPT Plus or Pro account cannot create these tokens. Use an OpenAI API key instead. + +#### Claude subscription token (Claude Pro, Max, Team, or Enterprise) + +Run this command in your own terminal: + +```sh +claude setup-token +``` + +In the browser, sign in to claude.ai with your Voltage account, then approve. The terminal shows the token (`sk-ant-oat01-…`). The token is valid for one year. + +#### OpenRouter API key (optional) + +Create a key at [openrouter.ai/keys](https://openrouter.ai/keys). The **Buzz agent** harness can use it to run any OpenRouter model. + +### 3. Create an agent + +1. In the Buzz app, open **Agents → Your agents**. +2. Select **Create agent**, then select **Remote workspace** in the agent type list. + To convert an existing Codex or Claude Code agent instead, open it and select **Remote workspace** under **Settings → Provider**. The agent keeps its instructions. +3. Fill in **Settings**, then select **Save**. + +| Field | Value | +|---|---| +| Harness | **Claude Code**, **Codex**, or **Buzz agent** | +| Model | Optional for Claude Code and Codex, for example `claude-opus-5-5`. Required for Buzz agent, for example `qwen/qwen3-coder`. | +| Effort | How hard the model thinks, for example **Extra high**. Models without effort levels ignore it. | +| Fast mode | **On** gives faster output with higher usage, on models that support it. | +| Instructions | The agent's system prompt | +| Environment | Leave empty: `{}`. Never put secrets here. | + +Every agent asks the Voltage Sandbox manager on its community, so there is nothing to choose about where it runs. + +### 4. Enroll your profile + +Do this once, on any of your Remote workspace agents. Enroll again only to change a credential. + +| Field | Value | +|---|---| +| GitHub token | The fine-grained token from step 2 | +| Git email | A verified email on your GitHub account. The app fills in `git config --global user.email`. | +| via profile | The [shared via profile](#shared-via-profile) | +| 1Password service account token | `ops_…` | +| Codex access token | `at-…` (optional) | +| Claude subscription token | `sk-ant-oat01-…` (optional) | +| OpenAI API key, Anthropic API key | Optional. Leave each empty if you use the matching subscription token. | +| OpenRouter API key | Optional | + +Select **Enroll profile**. The app shows **Enrolled. Profile version …**, then one of these results: + +- **Added your agents' signing key to your GitHub account.** `gh` added the key. Your agents' commits show as **Verified**. +- **Add your agents' signing key to GitHub.** The app tells you why `gh` could not add the key. Select **Copy key**. On GitHub, open **Settings → SSH and GPG keys → New SSH key**, set **Key type** to **Signing Key**, paste the key, and save it. + +The app finds your GitHub login and name from the token. The manager finds your developer ID from your Buzz identity. + +To stop your agents from signing commits as you, delete the **Buzz agents** key under **Settings → SSH and GPG keys** on GitHub. + +### 5. Start a session + +1. Mention the agent in a new thread, for example `@my-agent summarize the open PRs in voltagecloud/cd`. +2. Wait for the reply. The first session can take a few minutes, because a staging agent node starts. +3. Reply in the same thread to continue the session. +4. Select **End session** in the thread banner to save a checkpoint and stop the Sandbox. Select **Recover from checkpoint** to continue from that checkpoint later. + +Each thread gets its own Sandbox, so threads run in parallel. A developer can have five sessions at the same time. A session stops after 12 hours. + +## Reference + +### Shared via profile + +Paste this profile into the enroll form. It contains only `op://` references, not secret values. + +```toml +version = 1 + +[providers.onepassword] +type = "1password" +account = "teamvoltage.1password.com" + +[services.grafana-staging] +description = "Staging Grafana HTTP API access through a service account token" +hint = "via grafana-staging api /api/org" +provider = "onepassword" + +[services.grafana-staging.secrets] +token = "op://Buzz Agents Dev/Via Grafana Staging/via-token" + +[services.grafana-staging.commands.api] +description = "Call the staging Grafana HTTP API with a service account token." +mode = "rest" +base_url = "http://grafana.monitoring.svc.cluster.local" +method_default = "GET" + +[services.grafana-staging.commands.api.auth] +type = "bearer" +secret = "token" + +[services.grafana-staging.commands.api.headers] +Accept = "application/json" + +[services.linear] +description = "Linear app-actor API access through OAuth" +hint = "via linear api POST /graphql --json '{\"query\":\"{ viewer { id name } }\"}'" +provider = "onepassword" + +[services.linear.secrets] +oauth = "op://Buzz Agents Dev/Via Linear App Voltage/text" + +[services.linear.commands.api] +description = "Call configured Linear API endpoints with an app-actor OAuth bearer token." +mode = "rest" +base_url = "https://api.linear.app" +method_default = "GET" + +[services.linear.commands.api.auth] +type = "oauth" +credential = "oauth" + +[services.depot] +description = "Depot build cache (sccache, Turborepo, Go) shared by every developer's agents" +provider = "onepassword" + +[services.depot.secrets] +organization = "op://Buzz Agents Dev/Depot/organization" +token = "op://Buzz Agents Dev/Depot/credential" + +[services.argocd-staging] +description = "Staging Argo CD API: every app's status, resource tree and logs; sync and restart in application projects" +hint = "via argocd-staging api /api/v1/applications --query fields=items.metadata.name,items.spec.project,items.status.sync.status,items.status.health.status" +provider = "onepassword" + +[services.argocd-staging.secrets] +token = "op://Buzz Agents Dev/Argo CD Staging/credential" + +[services.argocd-staging.commands.api] +description = "Call the staging Argo CD API with the buzz-agents account token." +mode = "rest" +base_url = "http://argocd-server.argo.svc.cluster.local" +method_default = "GET" + +[services.argocd-staging.commands.api.auth] +type = "bearer" +secret = "token" +``` + +### What an agent can reach + +| An agent can | An agent cannot | +|---|---| +| Reach the public internet: GitHub, package registries, model APIs, Linear, Depot | Reach databases or other private services | +| Read staging logs and metrics in Grafana, with `via grafana-staging` | Reach internal ingresses, VPN-only hosts, or other pods | +| Read every staging Argo CD app and its logs, with `via argocd-staging` | Delete, edit, or exec into Argo CD apps | +| Sync and restart apps in the application projects: `admin-app`, `auth-service`, `backend`, `billing`, `feerate`, `frontend-turbo`, `nostr-server`, `sanctions-list`, `surge-coinprice`, `voltage-cash`, `webhook-proxy` | Sync or restart infrastructure projects | +| Push to your GitHub repositories, with your token's permissions. Branch protection still applies. | Use AWS or Kubernetes outside its own namespace | +| Use the shared Depot build cache | Reach production | + +CAUTION: An agent runs with full tool access in its Sandbox, and it reads untrusted content from repositories, issues, and the web. Give your tokens only the access an agent needs. + +## Troubleshooting + +| What you see | What to do | +|---|---| +| *This Buzz identity is not a configured developer.* | Ask an admin to do [Onboard a developer](#onboard-a-developer), including the restart in step 3. | +| *Join the community at the relay to enroll through it.* | Join `buzz.voltage.cloud` in the app, then enroll again. | +| *GitHub did not accept the token.* | Make a new fine-grained token, then enroll again. | +| *The GitHub token belongs to a different login.* | Use a token from your own GitHub account. | +| *Use a Codex access token (at-...)* | Put an OpenAI API key in **OpenAI API key**, not in **Codex access token**. | +| *Choose either an OpenAI API key or a Codex access token* | Clear one of the two fields. The same applies to the two Claude fields. | +| *via profile requires providers and services* | Paste the complete [shared via profile](#shared-via-profile). | +| *Could not run ssh-keygen. Install OpenSSH.* | Install OpenSSH, then enroll again. | +| The app says *gh needs the admin:ssh_signing_key scope* | Run `gh auth refresh -h github.com -s admin:ssh_signing_key`, then enroll again. Or add the key yourself with **Copy key**. | +| Commits show as **Unverified** on GitHub | Add the signing key on GitHub, and make sure **Git email** is a verified email on your account. | +| *The Sandbox manager did not answer within 60 seconds.* | Ask an admin. The admin checks `kubectl --context voltage-staging -n buzz-sandbox-manager logs deploy/buzz-sandbox-manager`. | +| The agent does not reply after 10 minutes | Check that you enrolled. If you did, an admin checks the Sandbox: `kubectl --context voltage-staging -n buzz-agent-staging- get sandboxes,pods`. | + +## Related + +- Design: [docs/remote-agents.md](../docs/remote-agents.md) +- Agent image: [voltage/images/buzz-agent](images/buzz-agent/README.md) +- Infrastructure: voltage-terraform `v2/environments/staging-agent` +- Deployment: voltagecloud/cd `charts/buzz-sandbox-manager` and `staging/values/infra-utils` +- Tracking: Linear DEV-2884 (remote agents), DEV-2948 (one ChatGPT login for all of a developer's agents), DEV-2949 (staging Argo CD config in Git)