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/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, 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", ] 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/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-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 ' 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