Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/voltage-agent-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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] = &[
Expand All @@ -36,27 +47,23 @@ 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",
label: "Codex",
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",
label: "Buzz agent",
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.",
},
];

Expand All @@ -72,18 +79,24 @@ 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,
}

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,
}
Expand Down Expand Up @@ -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")
Expand All @@ -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,
})
Expand All @@ -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."));
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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<Value, String> {
/// `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<Value, String> {
let text = |field| values.get(field).trim();
let models: Map<String, Value> = Field::ALL
.into_iter()
Expand All @@ -139,14 +136,14 @@ pub fn profile(values: &Values, owner: &str) -> Result<Value, String> {
.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,
});
Expand Down Expand Up @@ -180,28 +177,33 @@ 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(
matches(&profile["owner_pubkey"], "[0-9a-f]{64}"),
"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"])?;
Expand Down Expand Up @@ -390,6 +392,36 @@ pub async fn done<T>(
/// 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<Session>,
identity: Arc<Identity>,
manager: String,
values: Values,
) -> Result<(Outcome, Option<signing::Registration>), 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<Session>,
identity: Arc<Identity>,
Expand Down
Loading
Loading