Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4b2350d
Merge upstream main into VarVik hosted community branch
Cvv9 Jul 30, 2026
bcc4ef3
feat: add hosted VarVik browser community
Cvv9 Jul 30, 2026
fe61258
fix: satisfy formatting checks
Cvv9 Jul 30, 2026
06e1b21
feat: support hosted Codex subscription auth
Cvv9 Jul 30, 2026
554496e
fix: honor exported agent credentials
Cvv9 Jul 30, 2026
d28adcc
fix: persist hosted Codex authentication
Cvv9 Jul 30, 2026
5559776
fix: satisfy repository display guards
Cvv9 Jul 30, 2026
7a19dcb
fix: build imported avatar profile in desktop test
Cvv9 Jul 30, 2026
582b99f
fix: bootstrap hosted agent credentials safely
Cvv9 Jul 31, 2026
cb6de0a
fix: preserve community host for local agent
Cvv9 Jul 31, 2026
2a9eef2
feat: provision VarVik hosted agent fleet
Cvv9 Jul 31, 2026
9b03bd3
feat: add collapsible channel sections
Cvv9 Jul 31, 2026
cde307e
feat: add safe tiered hosted agents
Cvv9 Jul 31, 2026
9aae839
Merge remote-tracking branch 'upstream/main' into codex/varvik-hosted…
Cvv9 Jul 31, 2026
fd72cb3
Merge remote-tracking branch 'upstream/main' into codex/varvik-hosted…
Cvv9 Jul 31, 2026
492c04e
Merge remote-tracking branch 'origin/main' into codex/varvik-hosted-c…
Cvv9 Jul 31, 2026
3e04955
Merge remote-tracking branch 'origin/main' into codex/varvik-hosted-c…
Cvv9 Jul 31, 2026
adb5af2
feat(web): add integrated Buzz agent guide
Cvv9 Jul 31, 2026
7759ac5
fix(agents): support pre-registered hosted identities
Cvv9 Jul 31, 2026
60bf696
fix(build): normalize embedded migration line endings
Cvv9 Jul 31, 2026
2c5bace
feat(web): require employee sign-in
Cvv9 Aug 1, 2026
dfc91e0
fix(web): isolate workspace pane scrolling
Cvv9 Aug 1, 2026
884b08c
Guard managed profile inputs before relay access
Cvv9 Aug 1, 2026
9bd8405
Merge repaired main into hosted Buzz
Cvv9 Aug 1, 2026
cc93e56
Restore desktop snapshot import size ratchet
Cvv9 Aug 1, 2026
9d2770e
Remove obsolete snapshot profile test helper
Cvv9 Aug 1, 2026
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
37 changes: 32 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,24 @@ COPY --from=planner /build/recipe.json recipe.json
# scoping to -p buzz-relay misses transitive deps and re-builds them later.
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
# Windows checkouts may send CRLF migration files into the Docker context.
# SQLx hashes the embedded bytes, so normalize them before compilation to keep
# checksums identical to release images built from Linux checkouts.
RUN find migrations -type f -name '*.sql' -exec sed -i 's/\r$//' {} +
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
-p buzz-admin --bin buzz-admin \
-p buzz-pair-relay --bin buzz-pair-relay
-p buzz-pair-relay --bin buzz-pair-relay \
-p buzz-acp --bin buzz-acp \
-p buzz-cli --bin buzz

# Derive the normal release binaries from the same optimized ELF files as the
# debug image so the two variants cannot drift at code-generation time.
FROM builder AS stripped-binaries
RUN strip target/release/buzz-relay \
&& strip target/release/buzz-admin \
&& strip target/release/buzz-pair-relay
&& strip target/release/buzz-pair-relay \
&& strip target/release/buzz-acp \
&& strip target/release/buzz

# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
Expand Down Expand Up @@ -145,9 +153,9 @@ RUN apt-get update \
COPY --from=web-builder /build/web/dist /srv/buzz/web
COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-web

# The invite landing page is always served from the bundled web UI. Repository
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. The
# admin bundle is inert until BUZZ_ADMIN_HOST is configured.
# The invite landing page is always served from the bundled web UI. The browser
# workspace and repository browser have separate opt-ins. The admin bundle is
# inert until BUZZ_ADMIN_HOST is configured.
ENV BUZZ_WEB_DIR=/srv/buzz/web \
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web

Expand All @@ -170,6 +178,25 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay

# Hosted agent runtime. This is an opt-in Compose profile and is not included in
# the normal relay image. It connects to Buzz over the same public protocol as
# any other agent and runs Codex through the Agent Client Protocol adapter.
FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS agent-runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global @agentclientprotocol/codex-acp@1.1.7
COPY --from=stripped-binaries /build/target/release/buzz-acp /usr/local/bin/buzz-acp
COPY --from=stripped-binaries /build/target/release/buzz /usr/local/bin/buzz
COPY --from=stripped-binaries /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --chmod=0755 deploy/compose/agent-entrypoint.sh /usr/local/bin/agent-entrypoint
COPY --chmod=0444 deploy/compose/agent-safety-policy.md /etc/buzz/agent-safety-policy.md
# The entrypoint initializes the named Codex state volume as root, then drops
# permanently to the image's unprivileged `node` identity before Buzz or Codex.
USER root
WORKDIR /home/node
ENTRYPOINT ["/usr/local/bin/agent-entrypoint"]

# Keep the stripped runtime as the final/default Dockerfile target so existing
# `docker build .` callers and release tags retain their current behavior.
FROM runtime-base AS runtime
Expand Down
9 changes: 9 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_HEARTBEAT_INTERVAL", default_value_t = 0)]
pub heartbeat_interval: u64,

/// Seconds to wait before the first heartbeat. Defaults to the heartbeat
/// interval. Deployments can use this to align a daily brief to local time.
#[arg(long, env = "BUZZ_ACP_HEARTBEAT_INITIAL_DELAY")]
pub heartbeat_initial_delay: Option<u64>,

/// Seconds between per-turn liveness pings (the crash backstop signal —
/// distinct from heartbeat self-prompting). 0 = disabled.
#[arg(long, env = "BUZZ_ACP_TURN_LIVENESS_SECS", default_value_t = 10)]
Expand Down Expand Up @@ -499,6 +504,8 @@ pub struct Config {
pub max_turn_duration_secs: u64,
pub agents: u32,
pub heartbeat_interval_secs: u64,
/// Optional delay before the first heartbeat tick.
pub heartbeat_initial_delay_secs: Option<u64>,
/// Seconds between per-turn liveness pings. 0 = disabled. Distinct from
/// `heartbeat_interval_secs` (agent self-prompting) — this is the desktop
/// crash-backstop signal.
Expand Down Expand Up @@ -1063,6 +1070,7 @@ impl Config {
max_turn_duration_secs,
agents: args.agents,
heartbeat_interval_secs: heartbeat_interval,
heartbeat_initial_delay_secs: args.heartbeat_initial_delay,
turn_liveness_secs,
heartbeat_prompt,
system_prompt,
Expand Down Expand Up @@ -1441,6 +1449,7 @@ mod tests {
max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS,
agents: 1,
heartbeat_interval_secs: 0,
heartbeat_initial_delay_secs: None,
turn_liveness_secs: 10,
heartbeat_prompt: None,
system_prompt: None,
Expand Down
9 changes: 8 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1570,8 +1570,13 @@ async fn tokio_main() -> Result<()> {

let mut heartbeat = if config.heartbeat_interval_secs > 0 {
let interval = Duration::from_secs(config.heartbeat_interval_secs);
let initial_delay = Duration::from_secs(
config
.heartbeat_initial_delay_secs
.unwrap_or(config.heartbeat_interval_secs),
);
Some(tokio::time::interval_at(
tokio::time::Instant::now() + interval,
tokio::time::Instant::now() + initial_delay,
interval,
))
} else {
Expand Down Expand Up @@ -5004,6 +5009,7 @@ mod build_mcp_servers_tests {
max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS,
agents: 1,
heartbeat_interval_secs: 0,
heartbeat_initial_delay_secs: None,
turn_liveness_secs: 10,
heartbeat_prompt: None,
system_prompt: None,
Expand Down Expand Up @@ -5225,6 +5231,7 @@ mod error_outcome_emission_tests {
max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS,
agents: 1,
heartbeat_interval_secs: 0,
heartbeat_initial_delay_secs: None,
turn_liveness_secs: 10,
heartbeat_prompt: None,
system_prompt: None,
Expand Down
79 changes: 78 additions & 1 deletion crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,90 @@ use nostr::PublicKey;
use serde_json::json;

use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft};
use crate::client::BuzzClient;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{read_or_stdin, validate_hex64};
use crate::{AgentsCmd, RespondToArg};

pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> {
match command {
AgentsCmd::PublishProfile {
display_name,
about,
audience,
owner_pubkey,
access_tier,
channel_add_policy,
} => {
let display_name = display_name.trim();
if display_name.is_empty() {
return Err(CliError::Usage(
"--display-name must not be empty".to_string(),
));
}
let audience = audience.trim();
if !matches!(audience, "community" | "owner") {
return Err(CliError::Usage(
"--audience must be 'community' or 'owner'".to_string(),
));
}
let access_tier = access_tier.trim();
if !matches!(access_tier, "shared" | "personal" | "admin") {
return Err(CliError::Usage(
"--access-tier must be 'shared', 'personal', or 'admin'".to_string(),
));
}
let channel_add_policy = channel_add_policy.trim();
if !matches!(channel_add_policy, "anyone" | "owner_only" | "nobody") {
return Err(CliError::Usage(
"--channel-add-policy must be 'anyone', 'owner_only', or 'nobody'".to_string(),
));
}
if let Ok(allowed_raw) = std::env::var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES") {
let allowed = allowed_raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if !allowed.is_empty() && !allowed.contains(&channel_add_policy) {
return Err(CliError::Usage(format!(
"channel addition policy '{channel_add_policy}' is not permitted on this deployment"
)));
}
}
let owner_pubkey = owner_pubkey
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if audience == "owner" && owner_pubkey.is_none() {
return Err(CliError::Usage(
"--owner-pubkey is required when --audience=owner".to_string(),
));
}
if let Some(pubkey) = owner_pubkey {
validate_hex64(pubkey)?;
}
let content = serde_json::json!({
"name": display_name,
"display_name": display_name,
"about": about,
"agent_type": "agent",
"status": "online",
"audience": audience,
"owner_pubkey": owner_pubkey,
"access_tier": access_tier,
"channel_add_policy": channel_add_policy,
})
.to_string();
let builder = nostr::EventBuilder::new(
nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_PROFILE as u16),
content,
);
let event = client.sign_event(builder)?;
let response = client.submit_event(event).await?;
println!("{}", normalize_write_response(&response));
Ok(())
}
AgentsCmd::DraftCreate {
channel,
display_name,
Expand Down
25 changes: 24 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,28 @@ impl RespondToArg {

#[derive(Subcommand)]
pub enum AgentsCmd {
/// Publish this identity's relay-discoverable agent profile
#[command(name = "publish-profile")]
PublishProfile {
/// Human-readable name shown in Buzz clients
#[arg(long)]
display_name: String,
/// Short description of the hosted agent
#[arg(long)]
about: Option<String>,
/// Directory audience: community (everyone) or owner (only the named owner)
#[arg(long, default_value = "community")]
audience: String,
/// Owner pubkey for an owner-only directory entry
#[arg(long)]
owner_pubkey: Option<String>,
/// Access tier shown by clients: shared, personal, or admin
#[arg(long, default_value = "shared")]
access_tier: String,
/// Who may add this agent to channels: anyone, owner_only, or nobody
#[arg(long, default_value = "anyone")]
channel_add_policy: String,
},
/// Open a prefilled create-agent form in the owner's Buzz Desktop
DraftCreate {
/// Current channel UUID; the new agent is added here after save
Expand Down Expand Up @@ -1937,6 +1959,7 @@ mod tests {
"archived",
"draft-create",
"draft-update",
"publish-profile",
"unarchive"
]
);
Expand Down Expand Up @@ -2063,7 +2086,7 @@ mod tests {
#[test]
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
("agents", 5),
("agents", 6),
("canvas", 2),
("channels", 16),
("dms", 4),
Expand Down
11 changes: 11 additions & 0 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ pub struct Config {
/// When set, the relay serves the invite landing page and its static assets.
/// When unset, no static file serving happens (relay behaves as before).
pub web_dir: Option<std::path::PathBuf>,
/// Whether the configured web bundle serves the browser workspace at `/`.
/// Defaults to false so relay-only deployments retain NIP-11 at the root.
pub serve_web_workspace: bool,
/// Whether the configured web bundle serves Git browser routes in addition
/// to the public invite landing page. Defaults to false.
pub serve_git_web_gui: bool,
Expand Down Expand Up @@ -975,6 +978,9 @@ impl Config {
let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI")
.map(|value| value == "true" || value == "1")
.unwrap_or(false);
let serve_web_workspace = std::env::var("BUZZ_SERVE_WEB_WORKSPACE")
.map(|value| value == "true" || value == "1")
.unwrap_or(false);

if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
Expand Down Expand Up @@ -1051,6 +1057,7 @@ impl Config {
join_policy,
admin,
web_dir,
serve_web_workspace,
serve_git_web_gui,
})
}
Expand Down Expand Up @@ -1100,6 +1107,10 @@ mod tests {
!config.allow_nip_oa_auth,
"allow_nip_oa_auth should default to false"
);
assert!(
!config.serve_web_workspace,
"serve_web_workspace should default to false"
);
assert!(
!config.serve_git_web_gui,
"serve_git_web_gui should default to false"
Expand Down
31 changes: 18 additions & 13 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
if web_dir.is_some() {
let web_index = web_dir.as_ref().map(|dir| dir.join("index.html"));
let web_files = web_dir.map(ServeDir::new);
let serve_web_workspace = state.config.serve_web_workspace;
let serve_git_web_gui = state.config.serve_git_web_gui;
let spa_fallback = tower::service_fn(move |req: axum::extract::Request| {
let web_index = web_index.clone();
Expand All @@ -149,7 +150,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
if path.starts_with("/assets/") {
return files.oneshot(req).await.map(IntoResponse::into_response);
}
if should_serve_spa(path, serve_git_web_gui) {
if should_serve_spa(path, serve_web_workspace, serve_git_web_gui) {
return Ok(read_spa_index(&index).await);
}
}
Expand Down Expand Up @@ -232,8 +233,10 @@ fn is_invite_landing_path(path: &str) -> bool {
.is_some_and(|code| !code.is_empty() && !code.contains('/'))
}

fn should_serve_spa(path: &str, serve_git_web_gui: bool) -> bool {
is_invite_landing_path(path) || (serve_git_web_gui && is_git_web_gui_path(path))
fn should_serve_spa(path: &str, serve_web_workspace: bool, serve_git_web_gui: bool) -> bool {
is_invite_landing_path(path)
|| (serve_web_workspace && path == "/")
|| (serve_git_web_gui && is_git_web_gui_path(path))
}

fn is_git_web_gui_path(path: &str) -> bool {
Expand Down Expand Up @@ -323,8 +326,8 @@ async fn nip11_or_ws_handler(
.into_response()
}
Err(_) => {
// Browser requesting HTML and Git web GUI is enabled → serve SPA.
if state.config.serve_git_web_gui {
// Browser requesting HTML and the workspace is enabled → serve SPA.
if state.config.serve_web_workspace || state.config.serve_git_web_gui {
if let Some(ref dir) = state.config.web_dir {
if accept.contains("text/html") {
let index = dir.join("index.html");
Expand Down Expand Up @@ -502,14 +505,16 @@ mod tests {
}

#[test]
fn invite_is_always_served_but_git_gui_requires_opt_in() {
assert!(should_serve_spa("/invite/payload.mac", false));
assert!(should_serve_spa("/invite/payload.mac", true));
assert!(!should_serve_spa("/", false));
assert!(!should_serve_spa("/repos/example", false));
assert!(should_serve_spa("/", true));
assert!(should_serve_spa("/repos/example", true));
assert!(!should_serve_spa("/arbitrary", true));
fn invite_is_always_served_but_workspace_and_git_gui_require_opt_in() {
assert!(should_serve_spa("/invite/payload.mac", false, false));
assert!(should_serve_spa("/invite/payload.mac", true, false));
assert!(!should_serve_spa("/", false, false));
assert!(!should_serve_spa("/repos/example", false, false));
assert!(should_serve_spa("/", true, false));
assert!(should_serve_spa("/", false, true));
assert!(should_serve_spa("/repos/example", false, true));
assert!(!should_serve_spa("/repos/example", true, false));
assert!(!should_serve_spa("/arbitrary", true, true));
}

#[tokio::test(flavor = "current_thread")]
Expand Down
Loading
Loading