mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
Add just goose for one-command agent setup + fix API token auth
Adds an idempotent setup script and justfile recipe so that connecting
a Goose agent to a local Sprout relay is a single command:
just goose
What this does:
- scripts/setup-goose-agent.sh: mints a Nostr keypair + API token on
first run, persists to .sprout-agent.env (gitignored, chmod 600).
Subsequent runs are a no-op.
- justfile: 'just goose' sources the agent env and launches goose with
the sprout-mcp-server extension.
- .gitignore: excludes .sprout-agent.env.
Also fixes a bug where API token authentication was not wired up in the
relay's NIP-42 auth handler. The AuthService intentionally has no DB
access, so sprout_ tokens need to be intercepted in handle_auth before
calling verify_auth_event. The handler now:
1. Extracts the auth_token tag from the NIP-42 event
2. If it starts with sprout_, hashes it, looks it up via
Db::get_api_token_by_hash, and delegates to
AuthService::verify_api_token_against_hash
3. Updates last_used_at on success
4. Falls through to the existing JWT/no-token paths otherwise
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.sprout-agent.env
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
//! NIP-42 AUTH handler — verify challenge response, transition auth state.
|
||||
//!
|
||||
//! API token authentication (`sprout_*` tokens) is intercepted here before
|
||||
//! reaching [`AuthService::verify_auth_event`], because token verification
|
||||
//! requires a database lookup that `sprout-auth` intentionally does not own.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use sprout_auth::{hash_token, verify_nip42_event, AuthContext, AuthMethod};
|
||||
|
||||
use crate::connection::{AuthState, ConnectionState};
|
||||
use crate::protocol::RelayMessage;
|
||||
use crate::state::AppState;
|
||||
@@ -37,16 +43,33 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
};
|
||||
|
||||
let relay_url = state.config.relay_url.clone();
|
||||
let auth_svc = Arc::clone(&state.auth);
|
||||
let event_id_hex = event.id.to_hex();
|
||||
|
||||
match auth_svc
|
||||
.verify_auth_event(event, &challenge, &relay_url)
|
||||
.await
|
||||
{
|
||||
// ── Check for a `sprout_` API token in the auth event ────────────────
|
||||
// API tokens require a DB lookup, so the relay intercepts them here
|
||||
// rather than inside AuthService (which has no database access).
|
||||
let api_token = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.kind().to_string() == "auth_token")
|
||||
.and_then(|t| t.content())
|
||||
.filter(|v| v.starts_with("sprout_"))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let result = if let Some(raw_token) = api_token {
|
||||
verify_api_token_auth(&event, &challenge, &relay_url, &raw_token, &state).await
|
||||
} else {
|
||||
// JWT or no-token path — delegate entirely to AuthService.
|
||||
let auth_svc = Arc::clone(&state.auth);
|
||||
auth_svc
|
||||
.verify_auth_event(event, &challenge, &relay_url)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(auth_ctx) => {
|
||||
let pubkey = auth_ctx.pubkey;
|
||||
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
|
||||
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), method = ?auth_ctx.auth_method, "NIP-42 auth successful");
|
||||
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
|
||||
conn.send(RelayMessage::ok(&event_id_hex, true, ""));
|
||||
}
|
||||
@@ -61,3 +84,58 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a NIP-42 AUTH event that carries a `sprout_` API token.
|
||||
///
|
||||
/// 1. Verify the NIP-42 event structure + Schnorr signature.
|
||||
/// 2. Hash the raw token and look it up in the database.
|
||||
/// 3. Delegate to [`AuthService::verify_api_token_against_hash`] for
|
||||
/// constant-time hash comparison, expiry, pubkey, and scope resolution.
|
||||
/// 4. Update `last_used_at` on success.
|
||||
async fn verify_api_token_auth(
|
||||
event: &nostr::Event,
|
||||
challenge: &str,
|
||||
relay_url: &str,
|
||||
raw_token: &str,
|
||||
state: &AppState,
|
||||
) -> Result<AuthContext, sprout_auth::AuthError> {
|
||||
// Step 1: verify NIP-42 signature, challenge, relay URL, timestamp.
|
||||
let event_clone = event.clone();
|
||||
let challenge_owned = challenge.to_string();
|
||||
let relay_owned = relay_url.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
verify_nip42_event(&event_clone, &challenge_owned, &relay_owned)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| sprout_auth::AuthError::Internal("spawn_blocking panicked".into()))??;
|
||||
|
||||
// Step 2: look up the token in the database by its SHA-256 hash.
|
||||
let token_hash = hash_token(raw_token);
|
||||
let record = state
|
||||
.db
|
||||
.get_api_token_by_hash(&token_hash)
|
||||
.await
|
||||
.map_err(|_| sprout_auth::AuthError::TokenInvalid)?;
|
||||
|
||||
// Step 3: constant-time verify + expiry + pubkey + scope resolution.
|
||||
let owner_pubkey = nostr::PublicKey::from_slice(&record.owner_pubkey)
|
||||
.map_err(|_| sprout_auth::AuthError::TokenInvalid)?;
|
||||
|
||||
let (pubkey, scopes) = state.auth.verify_api_token_against_hash(
|
||||
raw_token,
|
||||
&record.token_hash,
|
||||
&owner_pubkey,
|
||||
&event.pubkey,
|
||||
record.expires_at,
|
||||
&record.scopes,
|
||||
)?;
|
||||
|
||||
// Step 4: update last_used_at (best-effort, don't fail auth on this).
|
||||
let _ = state.db.update_token_last_used(&token_hash).await;
|
||||
|
||||
Ok(AuthContext {
|
||||
pubkey,
|
||||
scopes,
|
||||
auth_method: AuthMethod::Nip42ApiToken,
|
||||
})
|
||||
}
|
||||
|
||||
13
justfile
13
justfile
@@ -153,6 +153,19 @@ migrate:
|
||||
echo "Migrations applied."
|
||||
fi
|
||||
|
||||
# ─── Goose (AI agent) ─────────────────────────────────────────────────────────
|
||||
|
||||
# Set up agent identity and start a Goose session with Sprout tools
|
||||
goose:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
./scripts/setup-goose-agent.sh
|
||||
set -o allexport
|
||||
source .sprout-agent.env
|
||||
set +o allexport
|
||||
exec goose session \
|
||||
--with-extension "SPROUT_RELAY_URL=$SPROUT_RELAY_URL SPROUT_PRIVATE_KEY=$SPROUT_PRIVATE_KEY SPROUT_API_TOKEN=$SPROUT_API_TOKEN $(pwd)/target/debug/sprout-mcp-server"
|
||||
|
||||
# ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Remove build artifacts
|
||||
|
||||
102
scripts/setup-goose-agent.sh
Executable file
102
scripts/setup-goose-agent.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# setup-goose-agent.sh — Idempotent setup for a Goose agent identity
|
||||
# =============================================================================
|
||||
# Creates .sprout-agent.env with a minted API token and Nostr keypair.
|
||||
# If the file already exists, does nothing. Safe to run repeatedly.
|
||||
#
|
||||
# Usage: ./scripts/setup-goose-agent.sh
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
AGENT_ENV="${REPO_ROOT}/.sprout-agent.env"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[setup-agent]${NC} $*"; }
|
||||
success(){ echo -e "${GREEN}[setup-agent]${NC} ✅ $*"; }
|
||||
warn() { echo -e "${YELLOW}[setup-agent]${NC} ⚠️ $*"; }
|
||||
error() { echo -e "${RED}[setup-agent]${NC} ❌ $*" >&2; }
|
||||
|
||||
# ---- Already set up? --------------------------------------------------------
|
||||
|
||||
if [[ -f "${AGENT_ENV}" ]]; then
|
||||
success "Agent identity already exists at .sprout-agent.env — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Preflight ---------------------------------------------------------------
|
||||
|
||||
# Need the relay DB to mint a token
|
||||
if ! docker inspect --format='{{.State.Health.Status}}' sprout-mysql 2>/dev/null | grep -q healthy; then
|
||||
error "MySQL is not running. Run 'just setup' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build sprout-admin if needed
|
||||
ADMIN_BIN="${REPO_ROOT}/target/debug/sprout-admin"
|
||||
if [[ ! -x "${ADMIN_BIN}" ]]; then
|
||||
log "Building sprout-admin..."
|
||||
cargo build -p sprout-admin 2>&1 | tail -3
|
||||
fi
|
||||
|
||||
# Build sprout-mcp-server if needed
|
||||
MCP_BIN="${REPO_ROOT}/target/debug/sprout-mcp-server"
|
||||
if [[ ! -x "${MCP_BIN}" ]]; then
|
||||
log "Building sprout-mcp-server..."
|
||||
cargo build -p sprout-mcp 2>&1 | tail -3
|
||||
fi
|
||||
|
||||
# ---- Mint token --------------------------------------------------------------
|
||||
|
||||
log "Minting agent token..."
|
||||
|
||||
# Source .env for DATABASE_URL
|
||||
if [[ -f "${REPO_ROOT}/.env" ]]; then
|
||||
set -o allexport
|
||||
source "${REPO_ROOT}/.env"
|
||||
set +o allexport
|
||||
fi
|
||||
export DATABASE_URL="${DATABASE_URL:-mysql://sprout:sprout_dev@localhost:3306/sprout}"
|
||||
|
||||
OUTPUT=$("${ADMIN_BIN}" mint-token \
|
||||
--name "goose-agent" \
|
||||
--scopes "messages:read,messages:write,channels:read,channels:write" \
|
||||
2>&1)
|
||||
|
||||
# Parse the nsec and token from the box-drawing output
|
||||
NSEC=$(echo "${OUTPUT}" | grep -oE 'nsec1[a-z0-9]+')
|
||||
TOKEN=$(echo "${OUTPUT}" | grep -oE 'sprout_[0-9a-f]+')
|
||||
|
||||
if [[ -z "${NSEC}" || -z "${TOKEN}" ]]; then
|
||||
error "Failed to parse credentials from mint-token output:"
|
||||
echo "${OUTPUT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- Write .sprout-agent.env -------------------------------------------------
|
||||
|
||||
cat > "${AGENT_ENV}" <<EOF
|
||||
# Sprout agent credentials — generated by setup-goose-agent.sh
|
||||
# DO NOT commit this file.
|
||||
SPROUT_RELAY_URL=ws://localhost:3000
|
||||
SPROUT_PRIVATE_KEY=${NSEC}
|
||||
SPROUT_API_TOKEN=${TOKEN}
|
||||
EOF
|
||||
|
||||
chmod 600 "${AGENT_ENV}"
|
||||
|
||||
success "Agent identity created at .sprout-agent.env"
|
||||
echo ""
|
||||
echo -e " ${BLUE}Relay:${NC} ws://localhost:3000"
|
||||
echo -e " ${BLUE}Private key:${NC} ${NSEC:0:20}..."
|
||||
echo -e " ${BLUE}API token:${NC} ${TOKEN:0:20}..."
|
||||
echo ""
|
||||
echo -e " Run ${GREEN}just goose${NC} to start a Goose session with Sprout."
|
||||
echo ""
|
||||
Reference in New Issue
Block a user