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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions apps/buzz-gpui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 117 additions & 1 deletion crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
79 changes: 79 additions & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
tenant: &TenantContext,
event: Event,
auth_pubkey: nostr::PublicKey,
) -> Result<super::ingest::IngestResult, IngestError> {
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,
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions voltage/images/buzz-agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
# Build from the repository root so the image packages the checked-out Buzz
# source; Dockerfile.dockerignore next to this file selects the context.
FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS source
RUN apk add --no-cache bash build-base cmake git musl-dev openssl-dev \

Check warning on line 5 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
openssl-libs-static perl pkgconf protoc python3 procps
WORKDIR /build
ENV CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=2
COPY . .
FROM source AS package-checks
RUN apk add --no-cache tini \

Check warning on line 11 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
&& tini -s -- cargo test --locked -p sprig -p buzz-acp -p buzz-agent -p buzz-dev-mcp -- --test-threads=1

FROM source AS builder
Expand All @@ -19,7 +19,7 @@

FROM node:22-trixie-slim@sha256:154ba2f4d6fec323d28e4f4bb86bba4677f1223391a1979cf521304e03a98dfa
LABEL maintainer="Voltage <hello@voltage.cloud>"
RUN apt-get update \

Check warning on line 22 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
&& apt-get install -y --no-install-recommends bash ca-certificates curl git gh gnupg openssh-client socat tini \
build-essential clang cmake pkg-config libssl-dev protobuf-compiler \
python3 python3-venv python3-pip golang-go postgresql-client sqlite3 \
Expand All @@ -33,7 +33,7 @@
# Release assets are public and pinned by checksum per architecture;
# credentials enter only at runtime.
ARG TARGETARCH
RUN case "$TARGETARCH" in \

Check warning on line 36 in voltage/images/buzz-agent/Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Dockerfiles

Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check
amd64) via=x86_64 gitleaks=x64 sccache=x86_64 \
via_sha=9e5a7f9384cacda29061d814e9397e7b6d90a72ee4663775362978ac9b65d8b7 \
op_sha=198b05dcf9a0972778ce5a4e262c459979b0c837257b5da65e2fba6187734226 \
Expand Down Expand Up @@ -81,6 +81,9 @@
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
Expand Down
33 changes: 29 additions & 4 deletions voltage/images/buzz-agent/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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"}'
Expand All @@ -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

Expand All @@ -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')
Expand All @@ -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)
Expand All @@ -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)
2 changes: 2 additions & 0 deletions voltage/images/buzz-agent/test-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
'
Expand Down
5 changes: 5 additions & 0 deletions voltage/images/buzz-agent/test-profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Loading