From 9486595b2bacebe8bacad609ed5c8e9dd45b5d5a Mon Sep 17 00:00:00 2001 From: Tee8z Date: Fri, 23 Oct 2026 07:50:40 -0400 Subject: [PATCH 1/4] fix(agents): let the agent read the sub-agent guide COPY --chmod=0644 also gave the /etc/buzz-agent directory it created mode 0644, which the agent user cannot enter. Every Sandbox with the sub-agent token, which the Sandbox manager always enables, failed at startup with "Developer profile refused" when it appended the guide to the harness instructions. Create the directory 0755 first, and check in test-image.sh that the agent can read the guide. --- voltage/images/buzz-agent/Dockerfile | 3 +++ voltage/images/buzz-agent/test-image.sh | 2 ++ 2 files changed, 5 insertions(+) diff --git a/voltage/images/buzz-agent/Dockerfile b/voltage/images/buzz-agent/Dockerfile index 8c9a6a9a194..a7e744934d6 100644 --- a/voltage/images/buzz-agent/Dockerfile +++ b/voltage/images/buzz-agent/Dockerfile @@ -81,6 +81,9 @@ COPY --chmod=0755 voltage/images/buzz-agent/checkpoint.py /usr/local/bin/buzz-ag COPY --chmod=0755 voltage/images/buzz-agent/heavy.py /usr/local/bin/buzz-heavy COPY --chmod=0755 voltage/images/buzz-agent/worker.py /usr/local/bin/buzz-agent-worker COPY --chmod=0755 voltage/images/buzz-agent/subagent.py /usr/local/bin/buzz-subagent +# COPY --chmod also sets the mode of a directory it creates, so create this one first: +# a 0644 /etc/buzz-agent cannot be entered, and every Sandbox with sub-agents failed to start. +RUN install -d -m 0755 /etc/buzz-agent COPY --chmod=0644 voltage/images/buzz-agent/subagents.md /etc/buzz-agent/subagents.md 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 diff --git a/voltage/images/buzz-agent/test-image.sh b/voltage/images/buzz-agent/test-image.sh index 6a7dfece3ad..2bab2800d2a 100755 --- a/voltage/images/buzz-agent/test-image.sh +++ b/voltage/images/buzz-agent/test-image.sh @@ -15,6 +15,8 @@ docker run --rm --entrypoint /bin/bash "$IMAGE" -ceu ' claude --version codex login --help | grep -q -- --device-auth claude setup-token --help >/dev/null + # Startup appends the sub-agent guide to the harness instructions as this user. + test -r /etc/buzz-agent/subagents.md ! test -e /home/agent/.codex/auth.json ! test -e /home/agent/.claude/.credentials.json ' From 8f030adb61905957d4fd75f5a99759fcd61e25a7 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Fri, 23 Oct 2026 08:15:27 -0400 Subject: [PATCH 2/4] fix(relay): accept typing indicators over the HTTP bridge The Buzz app posts its agents' events through the HTTP bridge, which sent every event to ingest and refused ephemeral kinds with "restricted: unknown event kind". Agents never showed as typing. The bridge now publishes kind 20002 the way the WebSocket path does: it checks the signer, write restrictions, the community fence and channel access, then fans the event out without storing it. Other ephemeral kinds are still refused. --- crates/buzz-relay/src/api/bridge.rs | 118 +++++++++++++++++++++++- crates/buzz-relay/src/handlers/event.rs | 79 ++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5cb515a5b23..6f5af91153b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1158,7 +1158,15 @@ async fn submit_event_authed( auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, }; - match crate::handlers::ingest::ingest_event(state, tenant, event, auth).await { + // Typing indicators are ephemeral: the HTTP bridge fans them out as a + // WebSocket does, without storing them, so agents the Buzz app runs show + // typing. Every other ephemeral kind is still refused by ingest. + let submitted = if kind_u32 == buzz_core::kind::KIND_TYPING_INDICATOR { + crate::handlers::event::publish_http_typing(state, tenant, event, pubkey).await + } else { + crate::handlers::ingest::ingest_event(state, tenant, event, auth).await + }; + match submitted { Ok(result) => { let response = Json(serde_json::json!({ "event_id": result.event_id, @@ -7419,6 +7427,114 @@ pub(crate) mod postgres_tests { /// Discriminating: restoring `status = 400u16` in bridge.rs's `Rejected` logging /// arm causes the CAS `status=409` assertion to fail while the 400 control /// continues to pass — the test is split so the regression direction is unambiguous. + /// Typing indicators (kind:20002) over the bridge are fanned out like a + /// WebSocket ephemeral event and never stored, and get its checks: + /// channel access, a channel `h` tag, and the authenticated signer. + #[test] + #[ignore = "requires Postgres"] + fn typing_over_the_bridge_is_published_not_stored_and_gated() { + use buzz_core::kind::KIND_TYPING_INDICATOR; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use uuid::Uuid; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + let state = rt + .block_on(bridge_handler_test_state()) + .expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + + let creator = Keys::generate(); + let (host, community, open, private) = rt.block_on(async { + let h = format!("typing-{}.local", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&h) + .await + .expect("ensure community"); + let mut ids = Vec::new(); + for visibility in [ChannelVisibility::Open, ChannelVisibility::Private] { + let (channel, _) = state + .db + .create_channel_with_id( + community.id, + Uuid::new_v4(), + &format!("typing-{}", Uuid::new_v4().simple()), + ChannelType::Stream, + visibility, + None, + creator.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + ids.push(channel.id.to_string()); + } + (h, community.id, ids[0].clone(), ids[1].clone()) + }); + + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let typing = |channel: Option<&str>, keys: &Keys| { + let mut builder = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), ""); + if let Some(channel) = channel { + builder = builder.tag(Tag::parse(["h", channel]).expect("h tag")); + } + builder.sign_with_keys(keys).expect("sign typing") + }; + + // Open channel: accepted with its id, and not stored. + let event = typing(Some(&open), &agent); + let body = serde_json::to_vec(&event).expect("serialize"); + let (status, text) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &agent_hex, + &body, + )); + assert_eq!(status, axum::http::StatusCode::OK, "{text}"); + let receipt: serde_json::Value = serde_json::from_str(&text).expect("receipt"); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["event_id"], event.id.to_hex()); + let stored = rt + .block_on(state.db.get_event_by_id(community, &event.id.to_bytes())) + .expect("lookup"); + assert!(stored.is_none(), "typing must not be stored"); + + // A private channel the signer is not a member of. + let body = serde_json::to_vec(&typing(Some(&private), &agent)).expect("serialize"); + let (status, text) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &agent_hex, + &body, + )); + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{text}"); + assert!(text.contains("not a channel member"), "{text}"); + + // No channel. + let body = serde_json::to_vec(&typing(None, &agent)).expect("serialize"); + let (status, text) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &agent_hex, + &body, + )); + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{text}"); + assert!(text.contains("h tag"), "{text}"); + + // Signed by a key other than the authenticated one. + let body = serde_json::to_vec(&typing(Some(&open), &Keys::generate())).expect("serialize"); + let (status, _) = rt.block_on(post_events_with_body( + state.clone(), + &host, + &agent_hex, + &body, + )); + assert_eq!(status, axum::http::StatusCode::FORBIDDEN); + } + #[test] #[ignore = "requires Postgres"] fn canvas_cas_conflict_logs_status_409_not_400() { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index d2a6619e66b..be8b9a76f63 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1041,6 +1041,85 @@ async fn handle_ephemeral_event( Ok(()) } +/// A typing indicator (kind:20002) submitted over the HTTP bridge, which the +/// Buzz app uses to publish as an agent it runs. It gets the checks and the +/// fan-out of a WebSocket ephemeral event, and like one it is never stored: +/// the signer is the authenticated key, the signature verifies, the signer +/// may write, the community is not fenced, and the signer is a member of the +/// channel its `h` tag names. +pub(crate) async fn publish_http_typing( + state: &Arc, + tenant: &TenantContext, + event: Event, + auth_pubkey: nostr::PublicKey, +) -> Result { + if event.pubkey != auth_pubkey { + return Err(IngestError::AuthFailed( + "invalid: event pubkey does not match authenticated identity".into(), + )); + } + let checked = event.clone(); + match tokio::task::spawn_blocking(move || verify_event(&checked)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(IngestError::Rejected(format!("invalid: {e}"))), + Err(_) => return Err(IngestError::Internal("error: internal error".to_string())), + } + super::ingest::enforce_write_restriction( + state, + tenant, + buzz_core::kind::KIND_TYPING_INDICATOR, + &auth_pubkey, + ) + .await?; + match buzz_deletion::store(&state.db) + .is_serving_active(tenant.community()) + .await + { + Ok(true) => {} + Ok(false) => { + return Err(IngestError::Rejected( + "restricted: community writes are fenced".into(), + )) + } + Err(error) => { + warn!(%error, "failed to check typing-event community lifecycle"); + return Err(IngestError::Internal("error: internal server error".into())); + } + } + let channel = super::ingest::extract_channel_id(&event).ok_or_else(|| { + IngestError::Rejected("invalid: a typing indicator needs a channel h tag".into()) + })?; + super::ingest::check_channel_membership( + tenant, + state, + channel, + auth_pubkey.to_bytes().as_ref(), + None, + ) + .await + .map_err(IngestError::Rejected)?; + + let event_id = event.id.to_hex(); + state.mark_local_event(tenant.community(), &event.id); + if let Err(e) = state + .pubsub + .publish_event(tenant, EventTopic::Channel(channel), &event) + .await + { + state + .local_event_ids + .invalidate(&(tenant.community(), event.id.to_bytes())); + warn!(event_id = %event_id, "Typing publish failed: {e}"); + } + let stored_event = StoredEvent::new(event, Some(channel)); + fan_out_event_to_local_subscribers(state, tenant.community(), &stored_event).await; + Ok(super::ingest::IngestResult { + event_id, + accepted: true, + message: String::new(), + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AgentObserverDirection { Telemetry, From ce8ba6aa595d35bfff5c0310080c27014acacfb3 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Fri, 23 Oct 2026 08:15:27 -0400 Subject: [PATCH 3/4] fix(agents): name the startup step when a developer profile is refused "Developer profile refused" gave no cause. The message now names the step that failed, plus either a fixed refusal message or the error type, so a Sandbox log can be diagnosed without printing any value. The guide adds this message and the 1Password 403 to troubleshooting, and creates service accounts without an expiry. --- voltage/images/buzz-agent/profile.py | 33 ++++++++++++++++++++--- voltage/images/buzz-agent/test-profile.py | 5 ++++ voltage/remote-agents.md | 8 ++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index b7bde99a077..3717de5f8d2 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -24,9 +24,21 @@ SSH_SIGNING_KEY = re.compile(r'-----BEGIN OPENSSH PRIVATE KEY-----\n[A-Za-z0-9+/=\n]{1,4000}-----END OPENSSH PRIVATE KEY-----\n?') +class Refused(ValueError): + """A refusal whose message is a fixed string, never a value, so startup may print it.""" + + def require(condition, message): if not condition: - raise ValueError(message) + raise Refused(message) + + +# The startup step in progress, named in the refusal so a failed Sandbox says where it stopped. +STEP = ['starting'] + + +def step(name): + STEP[0] = name def fields(value, expected): @@ -139,9 +151,11 @@ def depot(profile): def bootstrap(): developer = os.environ.get('BUZZ_DEVELOPER_ID', '') arn = os.environ.get('BUZZ_DEVELOPER_PROFILE_ARN', '') + step('reading the profile secret') result = aws(arn, developer, 'get-secret-value') raw = result['SecretString'] require(len(raw.encode()) <= MAX_BYTES, 'Profile is too large') + step('validating the profile') profile = json.loads(raw) validate(profile) require(profile['developer'] == developer @@ -160,12 +174,15 @@ def bootstrap(): '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']})} + step('reading the Depot cache credential') cache = depot(profile) if cache: files.update({'depot-token': cache[1], 'depot.json': json.dumps({'organization': cache[0]})}) + step('writing credential files') for name, value in files.items(): write_file(DIRECTORY / name, value) if 'openai_api_key' in profile['models']: + step('signing in to Codex') env['OPENAI_API_KEY'] = profile['models']['openai_api_key'] env['CODEX_HOME'] = '/home/agent/.codex' env['CODEX_CONFIG'] = '{"cli_auth_credentials_store":"file"}' @@ -183,6 +200,7 @@ def bootstrap(): env.setdefault('BUZZ_AGENT_PROVIDER', 'openrouter') if 'claude_oauth_token' in profile['models']: env['CLAUDE_CODE_OAUTH_TOKEN'] = profile['models']['claude_oauth_token'] + step('configuring Git commit signing') command('/usr/local/bin/buzz-agent-account', 'init', env=env) return env @@ -197,7 +215,7 @@ def restore_checkpoint(): stdin=subprocess.DEVNULL, stdout=sys.stderr) if result.returncode: print('Checkpoint restore failed; refusing to start with an empty workspace', file=sys.stderr) - raise ValueError('Checkpoint restore failed') + raise Refused('Checkpoint restore failed') SUBAGENT_CREDENTIALS = Path('/var/run/secrets/buzz-subagents') @@ -224,12 +242,16 @@ def main(): args = parser.parse_args() if args.action == 'work': env = bootstrap() + step('starting the worker') worker = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-agent-worker'] os.execvpe(worker[0], worker, env) else: env = bootstrap() + step('restoring the checkpoint') restore_checkpoint() + step('installing the sub-agent guide') install_subagent_guide(env) + step('starting the harness') arguments = args.arguments[1:] if args.arguments[:1] == ['--'] else args.arguments harness = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-acp', *arguments] os.execvpe(harness[0], harness, env) @@ -239,6 +261,9 @@ def main(): try: os.umask(0o077) main() - except (ValueError, KeyError, TypeError, OSError, subprocess.TimeoutExpired): - print('Developer profile refused; credentials and command output suppressed', file=sys.stderr) + except (ValueError, KeyError, TypeError, OSError, subprocess.TimeoutExpired) as error: + # Only a fixed refusal message or the error's type: other errors can quote a value. + reason = str(error) if type(error) is Refused else type(error).__name__ + print('Developer profile refused while '+STEP[0]+': '+reason + +'; credentials and command output suppressed', file=sys.stderr) sys.exit(1) diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 68df4f81b5e..d00435f9710 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -120,6 +120,11 @@ def rotate(models, git=None): require('synthetic-profile-' not in result.stdout+result.stderr, 'credential reached output') 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') + # The refusal names the step that failed, so a failed Sandbox can be diagnosed from its log. + steps = {'wrong-owner': 'while validating the profile: Developer profile owner does not match', + 'missing': 'while reading the profile secret: ', + 'symlink': 'while writing credential files: Unsafe credential directory'} + require(mode not in steps or steps[mode] in result.stderr, 'refusal does not name the failed step') print('PASS: refused '+mode) else: require(result.returncode == 0 and Path('/tmp/profile-adapter.ran').exists(), 'runtime bootstrap failed') diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 75310fcec5c..b8e6b324cb6 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -192,15 +192,17 @@ To create it in the web app at [teamvoltage.1password.com](https://teamvoltage.1 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. +5. Leave the expiry at **Never**. When a developer leaves, delete their service account. 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" +op service-account create "buzz-agents-" --vault "Buzz Agents Dev:read_items" ``` +Without `--expires-in`, the token does not expire. + To check the token, run this command. The output must show **Buzz Agents Dev** and no other vault. ```sh @@ -385,11 +387,13 @@ CAUTION: An agent runs with full tool access in its Sandbox, and it reads untrus | *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). | +| `op service-account create` fails with *(403) Forbidden* | You are not a 1Password owner or administrator. Ask one to create the service account and share the token with you through 1Password. | | *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`. | +| The Sandbox log shows *Developer profile refused while …* | The text after *while* names the startup step that failed, and the text after the colon names the reason. Credentials are never printed. Ask an admin, and include that line. | ## Related From e2007abe44eb6fc445ca57ec438501f6538ffd1b Mon Sep 17 00:00:00 2001 From: Tee8z Date: Fri, 23 Oct 2026 08:21:43 -0400 Subject: [PATCH 4/4] fix(deps): update ctutils to 0.4.5 for RUSTSEC-2026-0385 ctutils before 0.4.4 generates masks and compares u128 values in non-constant time. cargo-deny now fails the Security check on 0.4.2. --- Cargo.lock | 4 ++-- apps/buzz-gpui/Cargo.lock | 4 ++-- desktop/src-tauri/Cargo.lock | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26af9441408..3d25d7086b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2947,9 +2947,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] diff --git a/apps/buzz-gpui/Cargo.lock b/apps/buzz-gpui/Cargo.lock index 532319c2a00..b8c401a688e 100644 --- a/apps/buzz-gpui/Cargo.lock +++ b/apps/buzz-gpui/Cargo.lock @@ -2140,9 +2140,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3a8ddbcbae3..9146bf7e296 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2202,9 +2202,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ]