From fc5938c2df283658cef838f151d8ee21a50dc9b7 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 22 Oct 2026 13:17:55 -0400 Subject: [PATCH 1/4] feat(agents): share Depot through the vault and add Codex access tokens Developer profiles no longer carry a Depot organization and token. The agent bootstrap reads them from the via profile's depot service with the profile's 1Password service account token, so every developer's agents share one Depot credential, and a missing or unreadable service only turns the remote cache off. Add codex_access_token, a ChatGPT Business or Enterprise Codex access token exported as CODEX_ACCESS_TOKEN. It has no refresh token, so any number of Sandboxes can use it at once, alongside claude_oauth_token. The image, the manager and the GPUI enroll form apply the same rules: no Depot section, an OpenAI API key or a Codex access token but not both, and the token must start with at-. --- .../agent_plugins/remote_workspace/enroll.rs | 48 +++++++-------- .../remote_workspace/enroll_form.rs | 9 +-- .../remote_workspace/enroll_tests.rs | 32 +++++++--- .../buzz-sandbox-reaper/src/manager_tests.rs | 2 +- crates/buzz-sandbox-reaper/src/profile.rs | 30 +++++----- .../buzz-sandbox-reaper/src/profile_tests.rs | 40 +++++++------ voltage/images/buzz-agent/README.md | 31 +++++++--- .../images/buzz-agent/profile.example.json | 4 -- voltage/images/buzz-agent/profile.py | 58 +++++++++++++++---- voltage/images/buzz-agent/test-profile.py | 48 ++++++++++++--- voltage/images/buzz-agent/test-restore.py | 1 - 11 files changed, 199 insertions(+), 104 deletions(-) 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 55a1d4701e3..30598283262 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 @@ -30,15 +30,14 @@ pub enum Field { GithubToken, ViaProfile, OpToken, - DepotOrganization, - DepotToken, + CodexAccessToken, + ClaudeOauthToken, OpenaiApiKey, AnthropicApiKey, - ClaudeOauthToken, } impl Field { - pub const ALL: [Field; 13] = [ + pub const ALL: [Field; 12] = [ Field::Developer, Field::GitName, Field::GitEmail, @@ -47,11 +46,10 @@ impl Field { Field::GithubToken, Field::ViaProfile, Field::OpToken, - Field::DepotOrganization, - Field::DepotToken, + Field::CodexAccessToken, + Field::ClaudeOauthToken, Field::OpenaiApiKey, Field::AnthropicApiKey, - Field::ClaudeOauthToken, ]; /// Masked, and cleared once the form is sent. @@ -60,7 +58,7 @@ impl Field { self, Field::GithubToken | Field::OpToken - | Field::DepotToken + | Field::CodexAccessToken | Field::OpenaiApiKey | Field::AnthropicApiKey | Field::ClaudeOauthToken @@ -77,8 +75,7 @@ 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", @@ -89,6 +86,7 @@ impl Field { 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"), _ => None, @@ -145,10 +143,6 @@ pub fn profile(values: &Values, owner: &str) -> Result { }, "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)?; @@ -174,7 +168,6 @@ pub fn validate(profile: &Value) -> Result<(), String> { "git", "github", "via", - "depot", "models", ], )?; @@ -222,23 +215,26 @@ 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", + ]; 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")), @@ -247,6 +243,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", 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 de3bc920b65..7f93a6515be 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 @@ -155,12 +155,9 @@ impl EnrollForm { c, )) .child(row(&[Field::OpToken])) - .child(row(&[Field::DepotOrganization, Field::DepotToken])) - .child(row(&[ - Field::OpenaiApiKey, - Field::AnthropicApiKey, - Field::ClaudeOauthToken, - ])) + // Subscriptions first: both can run in parallel in every Sandbox. + .child(row(&[Field::CodexAccessToken, Field::ClaudeOauthToken])) + .child(row(&[Field::OpenaiApiKey, Field::AnthropicApiKey])) .children(status) } } 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 f48fdc50cbb..44614bdd87f 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 @@ -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", ]; @@ -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"), ] { @@ -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", @@ -69,6 +65,17 @@ 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()); + assert_eq!( + profile(&subscriptions, OWNER).unwrap()["models"], + json!({ + "codex_access_token": "at-fixtureCodexToken", + "claude_oauth_token": "sk-ant-oat01-fixture", + }) + ); } #[test] @@ -98,11 +105,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(), @@ -117,6 +123,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!( diff --git a/crates/buzz-sandbox-reaper/src/manager_tests.rs b/crates/buzz-sandbox-reaper/src/manager_tests.rs index 118116aa8fb..9fe479e676d 100644 --- a/crates/buzz-sandbox-reaper/src/manager_tests.rs +++ b/crates/buzz-sandbox-reaper/src/manager_tests.rs @@ -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"); diff --git a/crates/buzz-sandbox-reaper/src/profile.rs b/crates/buzz-sandbox-reaper/src/profile.rs index ed3c0a626db..e6c39179588 100644 --- a/crates/buzz-sandbox-reaper/src/profile.rs +++ b/crates/buzz-sandbox-reaper/src/profile.rs @@ -16,10 +16,14 @@ 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", +]; /// Why a profile was refused: one of `profile.py`'s messages. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -139,21 +143,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")), @@ -162,6 +160,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", diff --git a/crates/buzz-sandbox-reaper/src/profile_tests.rs b/crates/buzz-sandbox-reaper/src/profile_tests.rs index b037b03e917..57dcf65bbb3 100644 --- a/crates/buzz-sandbox-reaper/src/profile_tests.rs +++ b/crates/buzz-sandbox-reaper/src/profile_tests.rs @@ -12,7 +12,6 @@ pub(crate) fn valid(developer: &str, owner: &str) -> Value { "github": {"login": "ada-l", "token": "ghp_fixtureGitHubTokenDoNotLeak"}, "via": {"profile": "[providers.op]\nkind = \"1password\"\n\n[services.github]\nprovider = \"op\"\n", "op_token": "ops_fixtureOnePasswordTokenDoNotLeak"}, - "depot": {"organization": "voltage_org-1", "token": "depot_fixtureTokenDoNotLeak"}, "models": {"anthropic_api_key": "sk-ant-fixtureModelKeyDoNotLeak"} }) } @@ -36,6 +35,9 @@ fn accepts_a_complete_profile() { validate(&value).unwrap(); value["models"] = json!({"openai_api_key": "sk-x", "claude_oauth_token": "t"}); validate(&value).unwrap(); + // Both subscriptions, for Codex and Claude Code in parallel. + value["models"] = json!({"codex_access_token": "at-x", "claude_oauth_token": "t"}); + validate(&value).unwrap(); } #[test] @@ -45,14 +47,13 @@ fn each_section_needs_exactly_its_fields() { extra["extra"] = json!(true); assert_eq!(validate(&extra), Err(Invalid(FIELDS))); let mut missing = profile(); - missing.as_object_mut().unwrap().remove("depot"); + missing.as_object_mut().unwrap().remove("via"); assert_eq!(validate(&missing), Err(Invalid(FIELDS))); assert_eq!(validate(&json!([])), Err(Invalid(FIELDS))); for (pointer, bad) in [ ("/git", json!({"name": "a", "email": "b"})), ("/github", json!({"login": "a", "token": "b", "scope": "c"})), ("/via", json!("profile")), - ("/depot", json!({"organization": "a"})), ] { assert_eq!(refused(pointer, bad), Err(Invalid(FIELDS)), "{pointer}"); } @@ -122,22 +123,12 @@ fn refuses_each_rule_with_its_message() { json!("services = []\nproviders = 1\n"), "via profile requires providers and services", ), + // The vault, not the profile, holds the shared Depot credential. ( - "/depot/organization", - json!(""), - "Invalid Depot organization", - ), - ( - "/depot/organization", - json!("a.b"), - "Invalid Depot organization", - ), - ( - "/depot/organization", - json!("a".repeat(65)), - "Invalid Depot organization", + "/depot", + json!({"organization": "voltage", "token": "t"}), + "Profile fields are incomplete or unsupported", ), - ("/depot/token", json!("x\ty"), "Invalid credential"), ("/models", json!([]), "Unsupported model credential type"), ( "/models", @@ -149,6 +140,21 @@ fn refuses_each_rule_with_its_message() { json!({"anthropic_api_key": "k", "claude_oauth_token": "t"}), "Choose either a Claude API key or a Claude subscription token", ), + ( + "/models", + json!({"openai_api_key": "k", "codex_access_token": "at-t"}), + "Choose either an OpenAI API key or a Codex access token", + ), + ( + "/models", + json!({"codex_access_token": "sk-proj-k"}), + "Use a Codex access token (at-...)", + ), + ( + "/models", + json!({"codex_access_token": "at t"}), + "Invalid credential", + ), ( "/models", json!({"openai_api_key": ""}), diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index 5d6e36ec916..0e6862190f0 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -89,7 +89,8 @@ Developer tools and configured MCP servers remain available; the previous text-o ## Developer accounts -Enroll GitHub, development `via`, and Depot access once per developer. Each of their five Sandboxes loads the same owner-bound profile. +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. On the developer workstation, copy [profile.example.json](profile.example.json) to a private file outside the repository. @@ -114,7 +115,7 @@ 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 Depot in private workspace files. Missing profiles, owner mismatches, and unsafe paths refuse startup. +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. @@ -123,14 +124,19 @@ Credentials are excluded from checkpoints. Profile access through IRSA still nee | Field | Account | Runtime | | --- | --- | --- | | `openai_api_key` | OpenAI API billing | `OPENAI_API_KEY`; Codex login through its CLI | +| `codex_access_token` | ChatGPT Business or Enterprise seat | `CODEX_ACCESS_TOKEN` | | `anthropic_api_key` | Anthropic API billing | `ANTHROPIC_API_KEY` | | `claude_oauth_token` | Claude subscription seat | `CLAUDE_CODE_OAUTH_TOKEN` | -To use a Claude subscription, run `claude setup-token` on the developer workstation and store the long-lived token as `claude_oauth_token`. +To use both subscriptions in parallel, enroll `codex_access_token` and `claude_oauth_token` together. Neither has a refresh token, so any number of Sandboxes can use them at once. +- **Codex:** a workspace owner allows [Codex access tokens](https://learn.chatgpt.com/docs/enterprise/access-tokens); the developer creates one (`at-…`) at `chatgpt.com/admin/access-tokens`, within the workspace's expiry limit. Re-enroll before it expires. +- **Claude:** run `claude setup-token` on the developer workstation and store the one-year token. + +A profile can contain `openai_api_key` or `codex_access_token`, not both: Codex reads the access token first, so mixing them would hide which account runs. 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. -The bootstrap refuses an inherited `OPENAI_API_KEY`, `CODEX_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `CLAUDE_CODE_OAUTH_TOKEN`. +The bootstrap refuses an inherited `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_ACCESS_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `CLAUDE_CODE_OAUTH_TOKEN`. -Keep the API keys out of `models` to use the Codex setup gate for manual model-account enrollment; the gate refuses profiles with API keys. +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. @@ -160,13 +166,20 @@ Deleting the workspace removes local copies; upstream revocation remains separat ### Depot build cache -Enroll a Depot user or organization token for the developer's approved organization: +Every developer's agents share one Depot organization token, kept in the development vault. +The via profile names it with a `depot` service; at startup the bootstrap reads both values with the profile's 1Password service account token: -```bash -buzz-agent-account depot --organization +```toml +[services.depot] +description = "Depot build cache" +provider = "onepassword" +secrets.organization = "op://Buzz Agents Dev/Depot/organization" +secrets.token = "op://Buzz Agents Dev/Depot/credential" ``` -Complete this before releasing the setup gate. The harness and its tools inherit the cache configuration. +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`. diff --git a/voltage/images/buzz-agent/profile.example.json b/voltage/images/buzz-agent/profile.example.json index 3cd97b9e11e..08dcfd92199 100644 --- a/voltage/images/buzz-agent/profile.example.json +++ b/voltage/images/buzz-agent/profile.example.json @@ -15,9 +15,5 @@ "profile": "REPLACE_WITH_DEVELOPMENT_VIA_TOML", "op_token": "REPLACE_WITH_DEVELOPMENT_VAULT_SERVICE_ACCOUNT_TOKEN" }, - "depot": { - "organization": "REPLACE_WITH_DEPOT_ORGANIZATION", - "token": "REPLACE_WITH_DEPOT_USER_OR_ORGANIZATION_TOKEN" - }, "models": {} } diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index 492b9bdc13b..3a41c1aaa9e 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -13,13 +13,15 @@ MAX_BYTES = 65536 DIRECTORY = Path('/home/agent/.config/buzz-agent') -FIELDS = {'version', 'developer', 'owner_pubkey', 'git', 'github', 'via', 'depot', 'models'} -MODEL_FIELDS = {'openai_api_key', 'anthropic_api_key', 'claude_oauth_token'} -# Billed API keys; the Codex device-login gate refuses them. -API_KEY_FIELDS = {'openai_api_key', 'anthropic_api_key'} +FIELDS = {'version', 'developer', 'owner_pubkey', 'git', 'github', 'via', 'models'} +MODEL_FIELDS = {'openai_api_key', 'codex_access_token', 'anthropic_api_key', 'claude_oauth_token'} +# 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'} # Inherited values would override or mix with the profile's selected accounts. -INHERITED_MODEL_VARIABLES = ('OPENAI_API_KEY', 'CODEX_API_KEY', 'ANTHROPIC_API_KEY', +INHERITED_MODEL_VARIABLES = ('OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN') +# The shared Depot cache credential: the via profile's `depot` service, read from the vault. +DEPOT_SERVICE = 'depot' def require(condition, message): @@ -57,17 +59,18 @@ def validate(profile): require(isinstance(profile['via']['profile'], str), 'Invalid via profile') config = tomllib.loads(profile['via']['profile']) require(config.get('services') and config.get('providers'), 'via profile requires providers and services') - fields(profile['depot'], {'organization', 'token'}) - require(isinstance(profile['depot']['organization'], str) - and re.fullmatch(r'[a-zA-Z0-9_-]{1,64}', profile['depot']['organization']), 'Invalid Depot organization') - token(profile['depot']['token']) require(isinstance(profile['models'], dict) and set(profile['models']) <= MODEL_FIELDS, 'Unsupported model credential type') + # Codex reads the access token before its saved API-key login, so allow only one. + require(not {'openai_api_key', 'codex_access_token'} <= set(profile['models']), + '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(not {'anthropic_api_key', 'claude_oauth_token'} <= set(profile['models']), 'Choose either a Claude API key or a Claude subscription token') for value in profile['models'].values(): token(value) + require('codex_access_token' not in profile['models'] + or profile['models']['codex_access_token'].startswith('at-'), 'Use a Codex access token (at-...)') require(len(json.dumps(profile).encode()) <= MAX_BYTES, 'Profile exceeds Secrets Manager size limit') @@ -136,6 +139,32 @@ def write_file(path, value): stream.write(value) +def depot(profile): + """The Depot organization and token the via profile's `depot` service names, or None. + + The vault holds one Depot credential for every developer's agents. A missing, + malformed, or unreadable service only turns the remote build cache off.""" + services = tomllib.loads(profile['via']['profile'])['services'] + if not isinstance(services, dict) or DEPOT_SERVICE not in services: + return None + try: + secrets = services[DEPOT_SERVICE]['secrets'] + references = [secrets['organization'], secrets['token']] + require(all(isinstance(value, str) and value.startswith('op://') for value in references), + 'Depot references must be op:// references') + env = {k: v for k, v in os.environ.items() if not k.startswith('OP_')} + env['OP_SERVICE_ACCOUNT_TOKEN'] = profile['via']['op_token'] + organization, value = (command('op', 'read', '--no-newline', reference, env=env).strip() + for reference in references) + require(re.fullmatch(r'[a-zA-Z0-9_-]{1,64}', organization), 'Invalid Depot organization') + token(value) + except (ValueError, KeyError, TypeError, AttributeError, subprocess.TimeoutExpired): + print('Depot cache unavailable: the via profile depot service could not be read; building without it', + file=sys.stderr) + return None + return organization, value + + def bootstrap(): developer = os.environ.get('BUZZ_DEVELOPER_ID', '') arn = os.environ.get('BUZZ_DEVELOPER_PROFILE_ARN', '') @@ -149,7 +178,7 @@ def bootstrap(): 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') + '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') @@ -158,9 +187,11 @@ def bootstrap(): 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'], 'depot-token': profile['depot']['token'], - 'depot.json': json.dumps({'organization': profile['depot']['organization']}), + 'via.toml': profile['via']['profile'], 'profile.json': json.dumps({'developer': developer, 'version_id': result['VersionId']})} + cache = depot(profile) + if cache: + files.update({'depot-token': cache[1], 'depot.json': json.dumps({'organization': cache[0]})}) for name, value in files.items(): write_file(DIRECTORY / name, value) if 'openai_api_key' in profile['models']: @@ -169,6 +200,9 @@ def bootstrap(): env['CODEX_CONFIG'] = '{"cli_auth_credentials_store":"file"}' write_file(Path(env['CODEX_HOME']) / 'config.toml', 'cli_auth_credentials_store = "file"\n') command('codex', 'login', '--with-api-key', input=profile['models']['openai_api_key']+'\n', env=env) + if 'codex_access_token' in profile['models']: + # A workspace access token: no refresh token, so concurrent Sandboxes share it safely. + env['CODEX_ACCESS_TOKEN'] = profile['models']['codex_access_token'] if 'anthropic_api_key' in profile['models']: env['ANTHROPIC_API_KEY'] = profile['models']['anthropic_api_key'] # The Codex setup session cannot use the Claude token, and its gate refuses CLAUDE_* variables. diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 964306ac657..2c638ffa7c4 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -9,9 +9,11 @@ 'version': 1, 'developer': 'alice', 'owner_pubkey': OWNER, 'git': {'name': 'Alice Fixture', 'email': 'alice@example.invalid', 'signing_key': 'A' * 40}, 'github': {'login': 'alice', 'token': 'synthetic-profile-github'}, - 'via': {'profile': '[providers.fixture]\ntype="fixture"\n[services.fixture]\nprovider="fixture"\n', + 'via': {'profile': '[providers.fixture]\ntype="fixture"\n[services.fixture]\nprovider="fixture"\n' + '[services.depot]\nprovider="fixture"\n' + 'secrets.organization="op://Fixture/Depot/organization"\n' + 'secrets.token="op://Fixture/Depot/credential"\n', 'op_token': 'synthetic-profile-op'}, - 'depot': {'organization': 'fixture-org', 'token': 'synthetic-profile-depot'}, 'models': {'openai_api_key': 'synthetic-profile-openai', 'anthropic_api_key': 'synthetic-profile-anthropic'}, } @@ -64,12 +66,29 @@ def run(*args, **kwargs): 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']})) ''' BOOT = COMMON + r''' mode = os.environ['FIXTURE_MODE'] env = dict(os.environ) +# The shared Depot credential comes from the vault through the via profile's depot service. +tools = Path('/tmp/boot-tools') +tools.mkdir() +(tools/'op').write_text('#!/bin/sh\n[ "$OP_SERVICE_ACCOUNT_TOKEN" = synthetic-profile-op ] || exit 1\n' + + ('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) +env['PATH'] = str(tools)+':'+env['PATH'] def rotate(models): stored = json.loads(run('aws', 'secretsmanager', 'get-secret-value', '--secret-id', env['BUZZ_DEVELOPER_PROFILE_ARN'])) profile = json.loads(stored['SecretString']) @@ -113,6 +132,15 @@ def rotate(models): if mode == 'claude-oauth': rotate({'openai_api_key':'synthetic-profile-openai', 'claude_oauth_token':'synthetic-profile-claude-oauth'}) expected.update(ANTHROPIC_API_KEY=None, CLAUDE_CODE_OAUTH_TOKEN='synthetic-profile-claude-oauth') +if mode == 'subscriptions': + # Both subscriptions in one profile, for Codex and Claude Code in parallel. + rotate({'codex_access_token':'at-synthetic-profile-codex', 'claude_oauth_token':'synthetic-profile-claude-oauth'}) + expected.update(OPENAI_API_KEY=None, ANTHROPIC_API_KEY=None, CODEX_ACCESS_TOKEN='at-synthetic-profile-codex', + CLAUDE_CODE_OAUTH_TOKEN='synthetic-profile-claude-oauth') +if mode == 'no-depot': + # An unreadable Depot credential turns the remote cache off; the session still starts. + expected.update(SCCACHE_WEBDAV_PASSWORD=None) +if mode == 'inherited-codex': env['CODEX_ACCESS_TOKEN'] = 'at-synthetic-unselected-account' if mode == 'wrong-owner': env['BUZZ_ACP_AGENT_OWNER'] = 'b'*64 if mode == 'wrong-developer': env['BUZZ_DEVELOPER_ID'] = 'bob' if mode == 'mixed-model': env['OPENAI_API_KEY'] = 'synthetic-unselected-account' @@ -140,7 +168,7 @@ def rotate(models): result = subprocess.run(['sprig-entrypoint', 'auth-methods', '--agent-command', str(probe), '--agent-args', '', '--json'], env=env, capture_output=True, text=True, timeout=45) require('synthetic-profile-' not in result.stdout+result.stderr, 'credential reached output') -if mode not in ('valid', 'claude-oauth'): +if mode not in ('valid', 'claude-oauth', 'subscriptions', 'no-depot'): require(result.returncode != 0 and not Path('/tmp/profile-adapter.ran').exists(), 'invalid profile started a runtime') print('PASS: refused '+mode) else: @@ -151,12 +179,11 @@ def rotate(models): for path in directory.iterdir(): require(path.stat().st_mode & 0o777 == 0o600, 'credential file permissions too broad') require(not list(Path('/home/agent/.gnupg').glob('private-keys-v1.d/*.key')), 'signing key copied to workspace') - def contains_claude_token(path): + def contains(path, marker): # Codex creates helper symlinks to its binary outside the workspace. # Scan workspace files without following those links or loading whole files. if path.is_symlink() or not path.is_file(): return False - marker = b'synthetic-profile-claude-oauth' tail = b'' with path.open('rb') as stream: while chunk := stream.read(65536): @@ -164,8 +191,10 @@ def contains_claude_token(path): return True tail = chunk[-len(marker) + 1:] return False - require(not any(contains_claude_token(p) for p in Path('/home/agent').rglob('*')), - 'Claude token written to the workspace') + for marker in (b'synthetic-profile-claude-oauth', b'at-synthetic-profile-codex'): + require(not any(contains(p, marker) for p in Path('/home/agent').rglob('*')), + 'subscription token written to the workspace') + require((mode == 'no-depot') != (directory/'depot-token').exists(), 'Depot credential not loaded from the vault') print('PASS: fresh Sandbox bootstrap through packaged entrypoint and ACP adapter ('+mode+')') ''' @@ -191,8 +220,9 @@ def main(): 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) - modes = ['valid']*5 + ['wrong-owner', 'wrong-developer', 'mixed-model', 'inherited-claude', 'symlink', - 'missing', 'gate-api-key', 'claude-oauth', 'manual-gate'] + modes = ['valid']*5 + ['wrong-owner', 'wrong-developer', 'mixed-model', 'inherited-claude', + 'inherited-codex', 'symlink', 'missing', 'gate-api-key', 'claude-oauth', + 'subscriptions', 'no-depot', 'manual-gate'] 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 2e29174db15..ed7a19fa192 100644 --- a/voltage/images/buzz-agent/test-restore.py +++ b/voltage/images/buzz-agent/test-restore.py @@ -11,7 +11,6 @@ 'github': {'login': 'fixture', 'token': 'synthetic-profile-github'}, 'via': {'profile': '[providers.fixture]\ntype="fixture"\n[services.fixture]\nprovider="fixture"\n', 'op_token': 'synthetic-profile-op'}, - 'depot': {'organization': 'fixture-org', 'token': 'synthetic-profile-depot'}, 'models': {}, } From 41f4b54dbba745bd2925e67413e2308d31d0b9dd Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 22 Oct 2026 13:42:57 -0400 Subject: [PATCH 2/4] test(agents): restore the fixture profile before the no-depot bootstrap The subscriptions mode rotates the shared fixture profile to the two subscription tokens, so no-depot loaded those and its probe refused the environment. Rotate back to the original models first. --- voltage/images/buzz-agent/test-profile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 2c638ffa7c4..d8270178056 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -139,6 +139,8 @@ def rotate(models): CLAUDE_CODE_OAUTH_TOKEN='synthetic-profile-claude-oauth') if mode == 'no-depot': # An unreadable Depot credential turns the remote cache off; the session still starts. + # Earlier modes rotated the shared fixture profile, so restore its original models. + 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' if mode == 'wrong-owner': env['BUZZ_ACP_AGENT_OWNER'] = 'b'*64 From fdd4a23d10d3972a9ab329ff2e8acab21c9917e1 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 22 Oct 2026 13:51:29 -0400 Subject: [PATCH 3/4] feat(agents): add an OpenRouter key for the Buzz agent runtime Profiles accept openrouter_api_key, exported as OPENROUTER_API_KEY with BUZZ_AGENT_PROVIDER=openrouter unless the agent sets a provider, so the Buzz agent runtime can run any OpenRouter model. The bootstrap refuses an inherited key, and the Codex setup gate refuses it like other API keys. The Buzz agent reads its model at startup and refuses to start without one, so the Remote workspace launch now sends the agent's model as BUZZ_AGENT_MODEL for that runtime, as it sends ANTHROPIC_MODEL for Claude. --- .../src/agent_plugins/remote_workspace/config.rs | 11 ++++++----- .../src/agent_plugins/remote_workspace/enroll.rs | 8 +++++++- .../agent_plugins/remote_workspace/enroll_form.rs | 6 +++++- .../remote_workspace/enroll_tests.rs | 2 ++ .../src/agent_plugins/remote_workspace/tests.rs | 10 ++++++++++ crates/buzz-sandbox-reaper/src/profile.rs | 1 + crates/buzz-sandbox-reaper/src/profile_tests.rs | 2 ++ voltage/images/buzz-agent/README.md | 4 +++- voltage/images/buzz-agent/profile.py | 12 +++++++++--- voltage/images/buzz-agent/test-profile.py | 15 ++++++++++----- 10 files changed, 55 insertions(+), 16 deletions(-) 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 a6914ae75bd..91ffc2ee386 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 @@ -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()); } 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 30598283262..419fd31bf84 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 @@ -34,10 +34,11 @@ pub enum Field { ClaudeOauthToken, OpenaiApiKey, AnthropicApiKey, + OpenrouterApiKey, } impl Field { - pub const ALL: [Field; 12] = [ + pub const ALL: [Field; 13] = [ Field::Developer, Field::GitName, Field::GitEmail, @@ -50,6 +51,7 @@ impl Field { Field::ClaudeOauthToken, Field::OpenaiApiKey, Field::AnthropicApiKey, + Field::OpenrouterApiKey, ]; /// Masked, and cleared once the form is sent. @@ -62,6 +64,7 @@ impl Field { | Field::OpenaiApiKey | Field::AnthropicApiKey | Field::ClaudeOauthToken + | Field::OpenrouterApiKey ) } @@ -79,6 +82,7 @@ impl Field { Field::OpenaiApiKey => "OpenAI API key", Field::AnthropicApiKey => "Anthropic API key", Field::ClaudeOauthToken => "Claude subscription token", + Field::OpenrouterApiKey => "OpenRouter API key", } } @@ -89,6 +93,7 @@ impl Field { 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, } } @@ -223,6 +228,7 @@ pub fn validate(profile: &Value) -> Result<(), String> { "codex_access_token", "anthropic_api_key", "claude_oauth_token", + "openrouter_api_key", ]; require( models 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 7f93a6515be..3b02a614805 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 @@ -157,7 +157,11 @@ impl EnrollForm { .child(row(&[Field::OpToken])) // Subscriptions first: both can run in parallel in every Sandbox. .child(row(&[Field::CodexAccessToken, Field::ClaudeOauthToken])) - .child(row(&[Field::OpenaiApiKey, Field::AnthropicApiKey])) + .child(row(&[ + Field::OpenaiApiKey, + Field::AnthropicApiKey, + Field::OpenrouterApiKey, + ])) .children(status) } } 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 44614bdd87f..23b2aa1f552 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 @@ -69,11 +69,13 @@ fn the_form_builds_a_profile_matching_profile_py() { 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", }) ); } 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 241f73da0fc..26d016840b8 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 @@ -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")); } diff --git a/crates/buzz-sandbox-reaper/src/profile.rs b/crates/buzz-sandbox-reaper/src/profile.rs index e6c39179588..00b77d4651c 100644 --- a/crates/buzz-sandbox-reaper/src/profile.rs +++ b/crates/buzz-sandbox-reaper/src/profile.rs @@ -23,6 +23,7 @@ const MODEL_FIELDS: &[&str] = &[ "codex_access_token", "anthropic_api_key", "claude_oauth_token", + "openrouter_api_key", ]; /// Why a profile was refused: one of `profile.py`'s messages. diff --git a/crates/buzz-sandbox-reaper/src/profile_tests.rs b/crates/buzz-sandbox-reaper/src/profile_tests.rs index 57dcf65bbb3..2f7fec2b297 100644 --- a/crates/buzz-sandbox-reaper/src/profile_tests.rs +++ b/crates/buzz-sandbox-reaper/src/profile_tests.rs @@ -38,6 +38,8 @@ fn accepts_a_complete_profile() { // Both subscriptions, for Codex and Claude Code in parallel. value["models"] = json!({"codex_access_token": "at-x", "claude_oauth_token": "t"}); validate(&value).unwrap(); + value["models"] = json!({"openrouter_api_key": "sk-or-x", "anthropic_api_key": "k"}); + validate(&value).unwrap(); } #[test] diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index 0e6862190f0..310407e29fd 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -127,6 +127,7 @@ Credentials are excluded from checkpoints. Profile access through IRSA still nee | `codex_access_token` | ChatGPT Business or Enterprise seat | `CODEX_ACCESS_TOKEN` | | `anthropic_api_key` | Anthropic API billing | `ANTHROPIC_API_KEY` | | `claude_oauth_token` | Claude subscription seat | `CLAUDE_CODE_OAUTH_TOKEN` | +| `openrouter_api_key` | OpenRouter billing | `OPENROUTER_API_KEY`; `BUZZ_AGENT_PROVIDER=openrouter` unless the agent sets one | To use both subscriptions in parallel, enroll `codex_access_token` and `claude_oauth_token` together. Neither has a refresh token, so any number of Sandboxes can use them at once. - **Codex:** a workspace owner allows [Codex access tokens](https://learn.chatgpt.com/docs/enterprise/access-tokens); the developer creates one (`at-…`) at `chatgpt.com/admin/access-tokens`, within the workspace's expiry limit. Re-enroll before it expires. @@ -134,7 +135,8 @@ To use both subscriptions in parallel, enroll `codex_access_token` and `claude_o A profile can contain `openai_api_key` or `codex_access_token`, not both: Codex reads the access token first, so mixing them would hide which account runs. 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. -The bootstrap refuses an inherited `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_ACCESS_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `CLAUDE_CODE_OAUTH_TOKEN`. +`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. diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index 3a41c1aaa9e..f686466b146 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -14,12 +14,13 @@ MAX_BYTES = 65536 DIRECTORY = Path('/home/agent/.config/buzz-agent') FIELDS = {'version', 'developer', 'owner_pubkey', 'git', 'github', 'via', 'models'} -MODEL_FIELDS = {'openai_api_key', 'codex_access_token', 'anthropic_api_key', 'claude_oauth_token'} +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'} +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') + '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' @@ -205,6 +206,11 @@ def bootstrap(): env['CODEX_ACCESS_TOKEN'] = profile['models']['codex_access_token'] if 'anthropic_api_key' in profile['models']: env['ANTHROPIC_API_KEY'] = profile['models']['anthropic_api_key'] + if 'openrouter_api_key' in profile['models']: + # The Buzz agent runtime routes any OpenRouter model; an agent's own + # 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: env['CLAUDE_CODE_OAUTH_TOKEN'] = profile['models']['claude_oauth_token'] diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index d8270178056..7e902c96111 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -128,21 +128,26 @@ def rotate(models): 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, 'SCCACHE_WEBDAV_PASSWORD':'synthetic-profile-depot', 'BUZZ_GIT_IDENTITY':'user'} + 'CLAUDE_CODE_OAUTH_TOKEN':None, 'OPENROUTER_API_KEY':None, 'BUZZ_AGENT_PROVIDER':None, + 'SCCACHE_WEBDAV_PASSWORD':'synthetic-profile-depot', 'BUZZ_GIT_IDENTITY':'user'} if mode == 'claude-oauth': rotate({'openai_api_key':'synthetic-profile-openai', 'claude_oauth_token':'synthetic-profile-claude-oauth'}) expected.update(ANTHROPIC_API_KEY=None, CLAUDE_CODE_OAUTH_TOKEN='synthetic-profile-claude-oauth') if mode == 'subscriptions': - # Both subscriptions in one profile, for Codex and Claude Code in parallel. - rotate({'codex_access_token':'at-synthetic-profile-codex', 'claude_oauth_token':'synthetic-profile-claude-oauth'}) + # Both subscriptions in one profile, for Codex and Claude Code in parallel, and + # OpenRouter for the Buzz agent runtime. + rotate({'codex_access_token':'at-synthetic-profile-codex', 'claude_oauth_token':'synthetic-profile-claude-oauth', + 'openrouter_api_key':'synthetic-profile-openrouter'}) expected.update(OPENAI_API_KEY=None, ANTHROPIC_API_KEY=None, CODEX_ACCESS_TOKEN='at-synthetic-profile-codex', - CLAUDE_CODE_OAUTH_TOKEN='synthetic-profile-claude-oauth') + CLAUDE_CODE_OAUTH_TOKEN='synthetic-profile-claude-oauth', + OPENROUTER_API_KEY='synthetic-profile-openrouter', BUZZ_AGENT_PROVIDER='openrouter') if mode == 'no-depot': # An unreadable Depot credential turns the remote cache off; the session still starts. # Earlier modes rotated the shared fixture profile, so restore its original models. 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' +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' if mode == 'mixed-model': env['OPENAI_API_KEY'] = 'synthetic-unselected-account' @@ -223,7 +228,7 @@ def main(): receipt = json.loads(result.stdout) print('PASS: one developer enrollment; wrong GitHub actor cannot replace it', flush=True) modes = ['valid']*5 + ['wrong-owner', 'wrong-developer', 'mixed-model', 'inherited-claude', - 'inherited-codex', 'symlink', 'missing', 'gate-api-key', 'claude-oauth', + 'inherited-codex', 'inherited-openrouter', 'symlink', 'missing', 'gate-api-key', 'claude-oauth', 'subscriptions', 'no-depot', 'manual-gate'] for mode in modes: result = subprocess.run([*base, '-e', 'BUZZ_DEVELOPER_PROFILE_ARN='+receipt['arn'], From 4d5c2aff9263155d731a2cee54384ad1dfe3b856 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 22 Oct 2026 13:57:29 -0400 Subject: [PATCH 4/4] fix(agents): say why the Depot cache is off, and survive an unrunnable op Log Depot cache unavailable when the via profile has no depot service, as the README documents, and treat an op that cannot run (OSError) like a failed read: the session starts without the remote cache. --- voltage/images/buzz-agent/profile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index f686466b146..001da05c5ae 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -147,6 +147,7 @@ def depot(profile): malformed, or unreadable service only turns the remote build cache off.""" services = tomllib.loads(profile['via']['profile'])['services'] if not isinstance(services, dict) or DEPOT_SERVICE not in services: + print('Depot cache unavailable: the via profile has no depot service; building without it', file=sys.stderr) return None try: secrets = services[DEPOT_SERVICE]['secrets'] @@ -159,7 +160,8 @@ def depot(profile): for reference in references) require(re.fullmatch(r'[a-zA-Z0-9_-]{1,64}', organization), 'Invalid Depot organization') token(value) - except (ValueError, KeyError, TypeError, AttributeError, subprocess.TimeoutExpired): + # command() reports a failed read as ValueError; OSError covers an op that cannot run. + except (ValueError, KeyError, TypeError, AttributeError, OSError, subprocess.TimeoutExpired): print('Depot cache unavailable: the via profile depot service could not be read; building without it', file=sys.stderr) return None