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
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,12 @@ impl Config {
set("BUZZ_ACP_SYSTEM_PROMPT", self.instructions.clone());
}
if !self.model.trim().is_empty() {
// Claude reads its startup model from ANTHROPIC_MODEL.
let key = if claude {
"ANTHROPIC_MODEL"
} else {
"BUZZ_ACP_MODEL"
// Claude reads its startup model from ANTHROPIC_MODEL, and the Buzz
// agent from BUZZ_AGENT_MODEL: it refuses to start without one.
let key = match runtime.id {
"claude" => "ANTHROPIC_MODEL",
"buzz-agent" => "BUZZ_AGENT_MODEL",
_ => "BUZZ_ACP_MODEL",
};
set(key, self.model.trim().into());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ pub enum Field {
GithubToken,
ViaProfile,
OpToken,
DepotOrganization,
DepotToken,
CodexAccessToken,
ClaudeOauthToken,
OpenaiApiKey,
AnthropicApiKey,
ClaudeOauthToken,
OpenrouterApiKey,
}

impl Field {
Expand All @@ -47,11 +47,11 @@ impl Field {
Field::GithubToken,
Field::ViaProfile,
Field::OpToken,
Field::DepotOrganization,
Field::DepotToken,
Field::CodexAccessToken,
Field::ClaudeOauthToken,
Field::OpenaiApiKey,
Field::AnthropicApiKey,
Field::ClaudeOauthToken,
Field::OpenrouterApiKey,
];

/// Masked, and cleared once the form is sent.
Expand All @@ -60,10 +60,11 @@ impl Field {
self,
Field::GithubToken
| Field::OpToken
| Field::DepotToken
| Field::CodexAccessToken
| Field::OpenaiApiKey
| Field::AnthropicApiKey
| Field::ClaudeOauthToken
| Field::OpenrouterApiKey
)
}

Expand All @@ -77,20 +78,22 @@ impl Field {
Field::GithubToken => "GitHub token",
Field::ViaProfile => "via profile",
Field::OpToken => "1Password service account token",
Field::DepotOrganization => "Depot organization",
Field::DepotToken => "Depot token",
Field::CodexAccessToken => "Codex access token",
Field::OpenaiApiKey => "OpenAI API key",
Field::AnthropicApiKey => "Anthropic API key",
Field::ClaudeOauthToken => "Claude subscription token",
Field::OpenrouterApiKey => "OpenRouter API key",
}
}

/// The models entry it fills, for the optional model keys.
fn model(self) -> Option<&'static str> {
match self {
Field::OpenaiApiKey => Some("openai_api_key"),
Field::CodexAccessToken => Some("codex_access_token"),
Field::AnthropicApiKey => Some("anthropic_api_key"),
Field::ClaudeOauthToken => Some("claude_oauth_token"),
Field::OpenrouterApiKey => Some("openrouter_api_key"),
_ => None,
}
}
Expand Down Expand Up @@ -145,10 +148,6 @@ pub fn profile(values: &Values, owner: &str) -> Result<Value, String> {
},
"github": {"login": text(Field::GithubLogin), "token": text(Field::GithubToken)},
"via": {"profile": values.get(Field::ViaProfile), "op_token": text(Field::OpToken)},
"depot": {
"organization": text(Field::DepotOrganization),
"token": text(Field::DepotToken),
},
"models": models,
});
validate(&profile)?;
Expand All @@ -174,7 +173,6 @@ pub fn validate(profile: &Value) -> Result<(), String> {
"git",
"github",
"via",
"depot",
"models",
],
)?;
Expand Down Expand Up @@ -222,23 +220,27 @@ pub fn validate(profile: &Value) -> Result<(), String> {
truthy(config.get("services")) && truthy(config.get("providers")),
"via profile requires providers and services",
)?;
let depot = &profile["depot"];
fields(depot, &["organization", "token"])?;
require(
matches(&depot["organization"], "[a-zA-Z0-9_-]{1,64}"),
"Invalid Depot organization",
)?;
token(&depot["token"])?;
let models = profile["models"]
.as_object()
.ok_or("Unsupported model credential type")?;
let model_fields = ["openai_api_key", "anthropic_api_key", "claude_oauth_token"];
let model_fields = [
"openai_api_key",
"codex_access_token",
"anthropic_api_key",
"claude_oauth_token",
"openrouter_api_key",
];
require(
models
.keys()
.all(|key| model_fields.contains(&key.as_str())),
"Unsupported model credential type",
)?;
// Codex reads the access token before its saved API-key login, so allow only one.
require(
!(models.contains_key("openai_api_key") && models.contains_key("codex_access_token")),
"Choose either an OpenAI API key or a Codex access token",
)?;
// Claude Code prefers an API key over the subscription token, so allow only one.
require(
!(models.contains_key("anthropic_api_key") && models.contains_key("claude_oauth_token")),
Expand All @@ -247,6 +249,12 @@ pub fn validate(profile: &Value) -> Result<(), String> {
for value in models.values() {
token(value)?;
}
require(
models
.get("codex_access_token")
.is_none_or(|value| value.as_str().is_some_and(|value| value.starts_with("at-"))),
"Use a Codex access token (at-...)",
)?;
require(
profile.to_string().len() <= MAX_PROFILE_BYTES,
"Profile exceeds Secrets Manager size limit",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,12 @@ impl EnrollForm {
c,
))
.child(row(&[Field::OpToken]))
.child(row(&[Field::DepotOrganization, Field::DepotToken]))
// Subscriptions first: both can run in parallel in every Sandbox.
.child(row(&[Field::CodexAccessToken, Field::ClaudeOauthToken]))
.child(row(&[
Field::OpenaiApiKey,
Field::AnthropicApiKey,
Field::ClaudeOauthToken,
Field::OpenrouterApiKey,
]))
.children(status)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ use buzz_session_control::Reply;
use core::prelude::v1::test;

const OWNER: &str = "aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11";
const TOKENS: [&str; 5] = [
const TOKENS: [&str; 4] = [
"ghp_fixtureGithubToken",
"ops_fixtureOpToken",
"depot_fixtureToken",
"sk-ant-fixtureKey",
"sk-fixtureOpenaiKey",
];
Expand All @@ -29,8 +28,6 @@ fn values() -> Values {
"[providers.op]\nkind = \"1password\"\n\n[services.github]\nprovider = \"op\"\n",
),
(Field::OpToken, "ops_fixtureOpToken"),
(Field::DepotOrganization, "voltage"),
(Field::DepotToken, "depot_fixtureToken"),
(Field::AnthropicApiKey, "sk-ant-fixtureKey"),
(Field::OpenaiApiKey, "sk-fixtureOpenaiKey"),
] {
Expand Down Expand Up @@ -58,7 +55,6 @@ fn the_form_builds_a_profile_matching_profile_py() {
"profile": values().get(Field::ViaProfile),
"op_token": "ops_fixtureOpToken",
},
"depot": {"organization": "voltage", "token": "depot_fixtureToken"},
"models": {
"anthropic_api_key": "sk-ant-fixtureKey",
"openai_api_key": "sk-fixtureOpenaiKey",
Expand All @@ -69,6 +65,19 @@ fn the_form_builds_a_profile_matching_profile_py() {
no_models.set(Field::AnthropicApiKey, String::new());
no_models.set(Field::OpenaiApiKey, " ".into());
assert_eq!(profile(&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"],
json!({
"codex_access_token": "at-fixtureCodexToken",
"claude_oauth_token": "sk-ant-oat01-fixture",
"openrouter_api_key": "sk-or-fixture",
})
);
}

#[test]
Expand Down Expand Up @@ -98,11 +107,10 @@ fn the_profile_is_refused_as_profile_py_refuses_it() {
"via profile requires providers and services",
),
(
Field::DepotOrganization,
"volt age",
"Invalid Depot organization",
Field::CodexAccessToken,
"at-fixtureCodexToken",
"Choose either an OpenAI API key or a Codex access token",
),
(Field::DepotToken, "", "Invalid credential"),
(
Field::OpToken,
"x".repeat(8193).as_str(),
Expand All @@ -117,6 +125,14 @@ 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");
// 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(),
"Use a Codex access token (at-...)"
);
let mut extra = profile(&values(), OWNER).unwrap();
extra["models"]["gemini_api_key"] = json!("x");
assert_eq!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,14 @@ fn the_launch_follows_the_desktops_policy() {
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"));

// The Buzz agent reads its startup model, such as an OpenRouter model ID, itself.
config.runtime = "buzz-agent".into();
config.model = "qwen/qwen3-coder".into();
let buzz = config.spec("Ada", None);
assert_eq!(
buzz.launch.policy_env["BUZZ_AGENT_MODEL"],
"qwen/qwen3-coder"
);
assert!(!buzz.launch.policy_env.contains_key("BUZZ_ACP_MODEL"));
}
2 changes: 1 addition & 1 deletion crates/buzz-sandbox-reaper/src/manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ async fn enroll_is_refused_unless_the_profile_is_the_signing_owners() {
}
// The refusal is the image's message, which names no value.
let mut incomplete = mine();
incomplete.as_object_mut().unwrap().remove("depot");
incomplete.as_object_mut().unwrap().remove("via");
let invalid = manager.admit(&enroll(owner(), None, incomplete).event(), now());
let Admission::Done { response, .. } = invalid else {
panic!("not refused");
Expand Down
31 changes: 18 additions & 13 deletions crates/buzz-sandbox-reaper/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ const FIELDS: &[&str] = &[
"git",
"github",
"via",
"depot",
"models",
];
const MODEL_FIELDS: &[&str] = &["openai_api_key", "anthropic_api_key", "claude_oauth_token"];
const MODEL_FIELDS: &[&str] = &[
"openai_api_key",
"codex_access_token",
"anthropic_api_key",
"claude_oauth_token",
"openrouter_api_key",
];

/// Why a profile was refused: one of `profile.py`'s messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -139,21 +144,15 @@ pub fn validate(profile: &Value) -> Result<(), Invalid> {
truthy(config.get("services")) && truthy(config.get("providers")),
"via profile requires providers and services",
)?;
let depot = fields(&top["depot"], &["organization", "token"])?;
require(
depot["organization"].as_str().is_some_and(|org| {
(1..=64).contains(&org.len())
&& org
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}),
"Invalid Depot organization",
)?;
token(&depot["token"])?;
let models = top["models"]
.as_object()
.filter(|models| models.keys().all(|k| MODEL_FIELDS.contains(&k.as_str())))
.ok_or(Invalid("Unsupported model credential type"))?;
// Codex reads the access token before its saved API-key login, so allow only one.
require(
!(models.contains_key("openai_api_key") && models.contains_key("codex_access_token")),
"Choose either an OpenAI API key or a Codex access token",
)?;
// Claude Code prefers an API key over the subscription token, so allow only one.
require(
!(models.contains_key("anthropic_api_key") && models.contains_key("claude_oauth_token")),
Expand All @@ -162,6 +161,12 @@ pub fn validate(profile: &Value) -> Result<(), Invalid> {
for value in models.values() {
token(value)?;
}
require(
models
.get("codex_access_token")
.is_none_or(|value| value.as_str().is_some_and(|value| value.starts_with("at-"))),
"Use a Codex access token (at-...)",
)?;
require(
python_json_len(profile) <= MAX_BYTES,
"Profile exceeds Secrets Manager size limit",
Expand Down
Loading
Loading