Files
buzz/TESTING.md
T

2184 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sprout Testing Guide
This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 43 MCP tools against a live relay.
## Two Test Modes
| Mode | What It Does | When to Use |
|------|-------------|-------------|
| **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions |
| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 43 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features |
Run both modes for a complete regression check. Run automated-only for a fast sanity check.
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Quick Start: Automated Tests Only](#2-quick-start-automated-tests-only)
3. [Multi-Agent E2E Testing](#3-multi-agent-e2e-testing)
- [3.1 Architecture](#31-architecture)
- [3.2 Infrastructure Setup](#32-infrastructure-setup)
- [3.3 Mint Agent Keys](#33-mint-agent-keys)
- [3.4 Launch Harness Instances](#34-launch-harness-instances)
- [3.5 Test Exercises](#35-test-exercises)
- [3.6 Monitoring & Verification](#36-monitoring--verification)
- [3.7 Expected Results](#37-expected-results)
4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios)
5. [Workflow YAML Reference](#5-workflow-yaml-reference)
6. [The 43 MCP Tools](#6-the-43-mcp-tools)
7. [Cleanup](#7-cleanup)
8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting)
9. [Proxy Tests](#9-proxy-tests)
---
## 1. Prerequisites
Verify each requirement before proceeding. All commands must succeed.
### Docker
```bash
docker --version
# Required: any recent version
docker compose version
# Required: v2+ (uses "docker compose", not "docker-compose")
```
### Rust 1.88+
```bash
# From the sprout repo root — use Hermit if system Rust is older than 1.88
. bin/activate-hermit
rustc --version
# Required: rustc 1.88.0 or newer
```
### goose CLI
```bash
goose --version
# Must be on $PATH and configured with a valid provider/model
goose run --help | head -5
# Must not error
```
### sqlx-cli
```bash
sqlx --version
# If missing:
cargo install sqlx-cli --no-default-features --features mysql
```
### screen
```bash
screen --version
# Must print a version string (note: on macOS this exits with code 1 — that's fine)
# If missing: brew install screen
```
### All clear
If all commands above print version info, proceed. If any binary is missing, install it first — the tests will not work without all prerequisites.
---
## 2. Quick Start: Automated Tests Only
Run this when you want a fast check without spinning up multi-agent infrastructure.
```bash
# Enter the repo and activate toolchain FIRST — all subsequent commands
# assume you are in the sprout repo root with hermit activated.
cd /path/to/sprout # e.g. ~/Development/goosetown_oss/REPOS/sprout
. bin/activate-hermit
```
### Check for existing infrastructure
If Docker services or a relay are already running from a previous session, you can
leave the Docker services up and just reset the database and relay:
```bash
# Kill any existing relay
screen -S relay -X quit 2>/dev/null
lsof -ti :3000 | xargs kill -9 2>/dev/null
# Check Docker services — if already running, skip `docker compose up`
docker compose ps --format '{{.Name}} {{.Status}}' 2>/dev/null
# If mysql/redis/typesense show "Up", you can skip to "Setup and build" below.
# If not running:
docker compose up -d
```
> **Port conflicts:** If `docker compose up -d` fails with "port already allocated",
> a container from another project may be using the port. Find it with
> `docker ps --format '{{.Names}} {{.Ports}}'` and stop it manually.
> **Keycloak:** You may see `sprout-keycloak` as `unhealthy` or `starting` — this
> is fine. Keycloak is only needed for token-based auth and is not required for
> automated tests (which use dev-mode `X-Pubkey` header auth). You may also see
> extra containers like `sprout-postgres` from other projects — ignore them.
### Setup and build
```bash
# Configure environment
[ -f .env ] || cp .env.example .env
# Load env vars — ALWAYS required, even if .env already existed
export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null
# Reset database (fresh state for tests)
docker exec sprout-mysql mysql -u root -psprout_dev -e \
"DROP DATABASE IF EXISTS sprout; CREATE DATABASE sprout;" 2>/dev/null
sqlx migrate run --database-url "$DATABASE_URL"
# Build the full workspace (relay, MCP server, ACP harness, test client, etc.)
cargo build --release --workspace
# Run unit tests
cargo test --workspace
```
### Integration Tests (require running relay)
Start the relay (kill any stale instance first):
```bash
screen -S relay -X quit 2>/dev/null
lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1
screen -dmS relay bash -c \
'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
sleep 3 && curl -s http://localhost:3000/health
# Must print: ok
```
Then run the integration suites:
```bash
# REST API integration tests (40 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_rest_api -- --ignored
# WebSocket relay integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_relay -- --ignored
# MCP server integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_mcp -- --ignored
```
### Expected Results
```
test result: ok. 40 passed; 0 failed; 0 ignored ← REST API
test result: ok. 14 passed; 0 failed; 0 ignored ← relay
test result: ok. 14 passed; 0 failed; 0 ignored ← MCP
```
All 68 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.
---
## 3. Multi-Agent E2E Testing
### 3.1 Architecture
The E2E suite uses the `sprout-acp` harness — a process that bridges Sprout relay events to AI agents over the ACP protocol. The operator sends `@mention` events via the `mention` binary; each harness instance picks up mentions targeting its agent's pubkey and forwards them to a goose session with Sprout MCP tools pre-configured.
```
Operator (you)
│
│ mention <channel> <pubkey> "task instructions"
▼
Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose
│
sprout-mcp-server
(43 MCP tools)
│
Sprout Relay
(send_message, etc.)
```
Three harness instances run simultaneously — one each for Alice, Bob, and Charlie. Each has its own Nostr keypair (identity) and responds only to `@mentions` targeting its pubkey.
**Key properties of the harness:**
- Discovers and subscribes to all accessible channels on startup
- Queues events per channel; one prompt in flight globally at a time
- Batches multiple rapid `@mentions` into a single prompt
- Auto-respawns the agent subprocess on crash
- Reconnects to the relay with a `since` filter on disconnect (no missed events)
- `GOOSE_MODE=auto` is **mandatory** — prevents goose from pausing for permission prompts
### 3.2 Infrastructure Setup
Run all commands from the sprout repo root.
```bash
cd /path/to/sprout
. bin/activate-hermit
# 1. Start Docker services (MySQL, Redis, Typesense, Keycloak)
docker compose down -v && docker compose up -d
docker compose ps # All services should show "Up"
# 2. Configure environment
[ -f .env ] || cp .env.example .env
export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null
# 3. Run database migrations
sqlx migrate run --database-url "$DATABASE_URL"
# 4. Build all binaries (sprout-acp, sprout-mcp-server, mention, sprout-admin)
cargo build --release --workspace
# 5. Add release binaries to PATH
export PATH="$PWD/target/release:$PATH"
# 6. Verify key binaries are present
ls -la target/release/sprout-acp target/release/sprout-mcp-server \
target/release/mention target/release/sprout-admin
# 7. Start the relay
lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1
screen -dmS relay bash -c \
'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
sleep 3
# 8. Verify relay is up
curl -s http://localhost:3000/health
# Expected: {"status":"ok"} or similar
```
### 3.3 Mint Agent Keys
Each agent needs its own Nostr keypair. Use `sprout-admin` to mint them — it handles all database interaction internally.
```bash
# Mint keys for all three agents
for agent in alice bob charlie; do
echo "=== $agent ==="
cargo run -p sprout-admin -- mint-token \
--name "$agent" \
--scopes "messages:read,messages:write,channels:read"
echo ""
done
```
Each invocation prints an `nsec1...` private key, the corresponding pubkey hex, and an API token. **Save all three sets immediately — they are shown only once.**
Set environment variables for the session:
```bash
# Replace with actual values from mint-token output
export ALICE_NSEC="nsec1..."
export ALICE_PUBKEY="<alice-pubkey-hex>"
export BOB_NSEC="nsec1..."
export BOB_PUBKEY="<bob-pubkey-hex>"
export CHARLIE_NSEC="nsec1..."
export CHARLIE_PUBKEY="<charlie-pubkey-hex>"
```
> **Tip:** Pipe the mint output to a temp file during setup:
> `cargo run -p sprout-admin -- mint-token --name alice ... | tee /tmp/alice-keys.txt`
### 3.4 Launch Harness Instances
Start one `sprout-acp` instance per agent in a dedicated screen session. `GOOSE_MODE=auto` is required on all three.
```bash
# Alice's harness
SPROUT_PRIVATE_KEY="$ALICE_NSEC" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS agent-alice bash -c \
'sprout-acp 2>&1 | tee /tmp/agent-alice.log'
# Bob's harness
SPROUT_PRIVATE_KEY="$BOB_NSEC" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS agent-bob bash -c \
'sprout-acp 2>&1 | tee /tmp/agent-bob.log'
# Charlie's harness
SPROUT_PRIVATE_KEY="$CHARLIE_NSEC" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS agent-charlie bash -c \
'sprout-acp 2>&1 | tee /tmp/agent-charlie.log'
```
Wait ~5 seconds for all three to connect, then verify:
```bash
sleep 5
for agent in alice bob charlie; do
echo "=== agent-$agent ==="
grep -E "connected|discovered|subscribed|error" /tmp/agent-$agent.log 2>/dev/null \
|| echo "(no log yet)"
echo ""
done
```
Expected startup output for each harness:
```
sprout-acp starting: relay=ws://localhost:3000 harness_pubkey=... agent_pubkey=<hex>
agent initialized: ...
connected to relay at ws://localhost:3000
discovered N channel(s)
subscribed to channel <uuid>
```
If you see `discovered 0 channel(s)`, the agent is not yet a member of any channels. Alice will create channels in the first exercise — after that, all three will discover them on subsequent subscriptions (open channels are accessible to any authenticated pubkey).
> **Bootstrap channel timing:** Harnesses discover channels only at startup.
> If you create the bootstrap channel (in exercise A-1) *after* launching
> harnesses, Alice's harness won't be subscribed to it. Two options:
> 1. Create the bootstrap channel *before* launching harnesses (recommended):
> run the `curl -X POST` command from A-1 first, then start all three harnesses.
> 2. Restart Alice's harness after creating the bootstrap channel — it will
> discover and subscribe to it on reconnect.
---
### 3.5 Test Exercises
All exercises are delivered via `@mention` events using the `mention` binary:
```
mention <channel_uuid> <target_pubkey_hex> "task instructions"
```
The `mention` binary generates ephemeral sender keys — it does not need its own nsec. It requires `SPROUT_RELAY_URL` (defaults to `ws://localhost:3000`).
**Important:** Channel UUIDs are dynamic. Alice creates the channels in Exercise A-1. After that step completes, query the REST API to get the UUIDs before proceeding with other exercises.
```bash
# Helper: get channel UUID by name (run after Alice creates channels)
get_channel_uuid() {
local name="$1"
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" \
| jq -r ".[] | select(.name == \"$name\") | .id"
}
```
---
#### Alice — Infrastructure Creator
Alice sets up the shared environment that Bob and Charlie will use.
**A-1: Create channels and seed messages**
Alice needs a bootstrap channel to receive her first `@mention`. Use the default test channel from the relay, or create one via the REST API first:
```bash
# Create a bootstrap channel for Alice's first mention
BOOTSTRAP_CHANNEL=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" \
-d '{"name":"bootstrap","channel_type":"stream","visibility":"open"}' \
| jq -r '.id')
echo "Bootstrap channel: $BOOTSTRAP_CHANNEL"
```
Then send Alice her first task:
```bash
mention "$BOOTSTRAP_CHANNEL" "$ALICE_PUBKEY" \
"Create 3 channels: 'general' (stream/open), 'alice-testing' (stream/open), and 'private-ops' (stream/private). Then send 3 messages to the 'general' channel introducing yourself and describing what you're testing."
```
Wait for Alice to complete (~30–60s), then capture channel UUIDs:
```bash
sleep 60
export GENERAL=$(get_channel_uuid "general")
export ALICE_TESTING=$(get_channel_uuid "alice-testing")
export PRIVATE_OPS=$(get_channel_uuid "private-ops")
echo "general=$GENERAL alice-testing=$ALICE_TESTING private-ops=$PRIVATE_OPS"
```
**A-2: Channel metadata and canvas**
```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Set the topic on the 'general' channel to 'Sprout E2E Testing'. Set the purpose to 'Multi-agent integration test run'. Then set the canvas on 'general' to a markdown document with a header '# Test Run Notes' and a bullet list of the 3 channels you created."
```
**A-3: Thread and reactions**
```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Get the history of the 'general' channel. Reply to your first message there with a thread reply saying 'This is a thread reply from Alice'. Then add a 👍 reaction and a 🚀 reaction to your own first message."
```
**A-4: Workflow creation**
```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Create a workflow named 'alice-notify' with a message_posted trigger on the 'general' channel. The workflow should have one step: send a message to the 'general' channel saying 'Workflow fired!'. Save the workflow ID and report it back."
```
**A-5: Profile, NIP-05 identity, and presence**
```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use set_presence to set your status to 'online'. Finally, use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY) and confirm it shows 'online'."
```
**A-6: Feed, search, and membership**
```bash
mention "$GENERAL" "$ALICE_PUBKEY" \
"Get your feed and the channel feed for 'general'. Search for messages containing the word 'Alice'. List the members of the 'general' channel. Then invite Bob (pubkey: $BOB_PUBKEY) to the 'private-ops' channel."
```
---
#### Bob — Discoverer and Reactor
Bob explores the environment Alice created and interacts with her content.
**B-1: Discovery and history**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"List all channels you have access to. Get the message history from the 'general' channel (last 20 messages). Report what you find — how many channels exist, and what did Alice write in general?"
```
**B-2: Reactions and DM**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"React to the first message in the 'general' channel with a ❤️ reaction. Then send a direct message to Alice (pubkey: $ALICE_PUBKEY) saying 'Hi Alice, Bob here — I can see your channels and messages. The setup looks great!'"
```
**B-3: DM history and canvas**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Get your DM conversation history with Alice. Read the canvas on the 'general' channel and report what it says. List all your DM conversations."
```
**B-4: Thread participation**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Find Alice's thread in the 'general' channel (a message that has replies). Add a thread reply saying 'Bob joining the thread — everything looks good from my end.' Then get the thread replies and report how many there are."
```
**B-5: Channel join and profile**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Join the 'alice-testing' channel. Get your own profile and set your display name to 'Bob (Test Agent)'. Search for any messages mentioning 'workflow' or 'canvas'."
```
**B-6: Private channel access test**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Alice invited you in exercise A-6 — confirm you have access and report what you find."
```
**B-7: Get presence**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Use set_presence to set your status to 'away'. Then get the presence status for Alice (pubkey: $ALICE_PUBKEY) and yourself. Report both statuses — Alice should be 'online' (from A-5) and you should be 'away'."
```
**B-8: Profile resolution (public profiles)**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list."
```
**B-8: Profile resolution (public profiles)**
```bash
mention "$GENERAL" "$BOB_PUBKEY" \
"Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list."
```
---
#### Charlie — Edge Case Specialist
Charlie tests error handling, idempotency, and lifecycle operations.
**C-1: Non-existent channel error**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Try to send a message to channel UUID '00000000-0000-0000-0000-000000000000'. Report the exact error you receive."
```
**C-2: Unauthorized archive attempt**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Try to archive the 'general' channel (ID: $GENERAL). You did not create it — report what error you get."
```
**C-3: Canvas overwrite**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Set the canvas on the 'general' channel to a new markdown document: '# Charlie Was Here\n\nCharlie overwrote the canvas on $(date -u +%Y-%m-%dT%H:%M:%SZ)'. Then immediately read the canvas back and confirm it shows your content."
```
**C-4: Reaction idempotency**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Find the first message in the 'general' channel. Add a 🎉 reaction to it. Then try to add the same 🎉 reaction again. Report what happens the second time — does it error or succeed silently?"
```
**C-5: Channel lifecycle (create → archive → send → unarchive → send)**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Create a new channel named 'charlie-lifecycle' (stream/open). Send a message to it saying 'Before archive'. Archive the channel. Try to send another message — report the error. Unarchive the channel. Send a message saying 'After unarchive'. Confirm the final message was accepted."
```
**C-6: Join, send, leave, verify**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Join the 'alice-testing' channel. Send a message there saying 'Charlie was here'. Then leave the channel. Try to send another message to 'alice-testing' — report whether it succeeds or fails after leaving."
```
**C-7: Workflow trigger and cross-agent summary**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Get the list of workflows. Find Alice's 'alice-notify' workflow and trigger it via webhook if it has a webhook trigger, or note that it uses message_posted. Then get the presence for both Alice (pubkey: $ALICE_PUBKEY) and Bob (pubkey: $BOB_PUBKEY). Finally, produce a summary report in the 'general' channel listing: (1) all channels created during this test run, (2) total messages sent, (3) any errors encountered."
```
---
**C-8: NIP-05 identity verification**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Verify the NIP-05 endpoint. Alice set her NIP-05 handle to 'alice@localhost' in exercise A-5. Use curl or an HTTP request to GET http://localhost:3000/.well-known/nostr.json?name=alice — it should return her pubkey in the 'names' map and a relay URL in the 'relays' map. Also try ?name=nonexistent and confirm it returns empty names/relays. Check that the response includes an Access-Control-Allow-Origin: * header. Report your findings."
```
---
**C-9: Profile edge cases**
```bash
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Test profile edge cases. Use get_user_profile with a pubkey that doesn't exist — report the error. Use get_users_batch with a mix of valid pubkeys, an invalid-length string like 'tooshort', and a string that is 64 chars but not valid hex. Report what ends up in the profiles map vs the missing list."
```
---
### 3.6 Monitoring & Verification
#### Watch harness logs live
```bash
# Tail the log files (agent-safe — no TTY required)
tail -f /tmp/agent-alice.log &
tail -f /tmp/agent-bob.log &
tail -f /tmp/agent-charlie.log &
# Or view recent output from a specific agent
tail -50 /tmp/agent-alice.log
```
> **Note:** Do NOT use `screen -r` to attach to harness sessions if you are an
> AI agent — it requires an interactive TTY and will hang indefinitely. Always
> use `tail` on the log files or `grep` for specific patterns instead.
Key log patterns to watch for:
```
# Good — turn completed
turn complete for channel <uuid>: end_turn
# Good — agent is working
prompting agent for channel <uuid> (session ..., N event(s))
# Investigate — agent had trouble
turn complete for channel <uuid>: max_tokens
turn timeout (300s) for channel <uuid> — cancelling
# Bad — needs attention
agent process exited — respawning
relay connection lost — reconnecting
```
#### Tail log files
```bash
# All three agents at once
tail -f /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log
# Filter for completions only
grep "turn complete\|turn timeout\|turn cancelled" \
/tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log
```
#### REST API verification
```bash
# List all channels (use any agent's pubkey)
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" \
| jq '.[] | {id: .id, name: .name, visibility: .visibility}'
# Recent messages in general
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels/$GENERAL/messages?limit=20" \
| jq '.[] | {sender: .pubkey[:16], body: .content[:120]}'
# Messages from a specific agent
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels/$GENERAL/messages?limit=50" \
| jq --arg pk "$CHARLIE_PUBKEY" \
'[.[] | select(.pubkey == $pk)] | {count: length, messages: [.[] | .content[:100]]}'
# Channel members
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels/$GENERAL/members" \
| jq '.[] | {pubkey: .pubkey[:16], role: .role}'
# Check private-ops membership (Bob should be there after A-6)
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels/$PRIVATE_OPS/members" \
| jq '.[] | .pubkey[:16]'
```
#### Screen session management
```bash
# List all active sessions
screen -ls
# Check if a session is still running
screen -ls | grep agent-alice
# Capture current screen contents to file (non-destructive)
screen -S agent-alice -X hardcopy /tmp/alice-snapshot.txt
cat /tmp/alice-snapshot.txt
```
### 3.7 Expected Results
After all exercises complete, the following should be true:
| Check | Expected |
|-------|----------|
| Channels created | At least 5: general, alice-testing, private-ops, charlie-lifecycle, bootstrap |
| Messages in general | 10+ messages from Alice, Bob, and Charlie |
| Thread replies | At least 2 replies on Alice's first message |
| Reactions | 👍 🚀 (Alice), ❤️ (Bob), 🎉 (Charlie) on general messages |
| Canvas | Charlie's content (last writer wins) |
| DM conversation | Alice ↔ Bob DM exists |
| Bob in private-ops | Yes (Alice invited him in A-6) |
| Workflow | alice-notify created with message_posted trigger |
| Display names | Alice and Bob have display names set |
| Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob with display names, Charlie with null display name (all in profiles map) |
| NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) |
| Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys |
| Presence | Alice is 'online' (A-5), Bob is 'away' (B-7); Charlie can read both via get_presence (C-7) |
| Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors |
| charlie-lifecycle | Unarchived and final message sent successfully |
**Verify the full picture:**
```bash
# Channel count
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" | jq 'length'
# Message count in general
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels/$GENERAL/messages?limit=200" | jq 'length'
# Workflow list
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/workflows" \
| jq '.[] | {name: .name, trigger: .trigger.on}'
```
---
## 4. Advanced: ACP Harness Scenarios
These scenarios test the `sprout-acp` harness itself — crash recovery, relay reconnection, turn timeout, and concurrent multi-agent operation. Run them independently from the main E2E suite.
### Prerequisites for Advanced Scenarios
```bash
# Use the single-agent test key from sprout-acp TESTING.md
export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
export AGENT_PUBKEY=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb
export TEST_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301
# Start a single harness
SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
sleep 5
```
### Scenario A: Basic @mention → Agent Replies
```bash
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "What is 2 + 2? Reply with just the number."
sleep 30
# Verify reply via REST API
curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
"http://localhost:3000/api/channels/$TEST_CHANNEL/messages?limit=5" \
| jq --arg pk "$AGENT_PUBKEY" \
'.[] | select(.pubkey == $pk) | {body: .content[:200]}'
```
Expected: agent replies with "4" via `send_message`.
### Scenario B: Multi-Event Batch
```bash
# Send 3 mentions in rapid succession
for i in 1 2 3; do
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Batch message $i" &
done
wait
sleep 30
# Check harness log for batch size
grep "prompting agent" /tmp/harness.log | tail -5
# Look for "(session ..., N event(s))" where N > 1
```
### Scenario C: Agent Crash Recovery
```bash
# Kill the agent subprocess
kill -9 $(pgrep -f "goose acp") 2>/dev/null
# Send a new mention
sleep 2
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Are you still alive after the crash?"
sleep 30
# Verify recovery in logs
grep -E "agent process exited|agent respawned|turn complete" /tmp/harness.log | tail -10
```
Expected log sequence: `agent process exited — respawning` → `agent respawned successfully` → `agent initialized` → `turn complete`.
### Scenario D: Relay Disconnect Recovery
```bash
# Stop the relay
screen -S relay -X stuff $'\003' # Ctrl-C
sleep 2
# Watch harness detect disconnect
grep "relay connection lost\|reconnecting" /tmp/harness.log
# Restart relay
screen -dmS relay bash -c \
'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
sleep 5
# Verify reconnection
grep "relay reconnected\|subscribed" /tmp/harness.log | tail -5
# Confirm harness is functional again
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Post-reconnect test — reply with OK"
sleep 30
grep "turn complete" /tmp/harness.log | tail -3
```
### Scenario E: Turn Timeout
```bash
# Restart harness with 5-second timeout
screen -S harness -X quit
SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
SPROUT_RELAY_URL="ws://localhost:3000" \
SPROUT_ACP_TURN_TIMEOUT=5 \
GOOSE_MODE=auto \
screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
sleep 3
# Send a prompt that will take longer than 5 seconds
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" \
"Write a detailed 500-word essay on the history of computing, then list 50 prime numbers."
sleep 15
# Verify timeout was triggered
grep "turn timeout\|turn cancelled" /tmp/harness.log | tail -5
# Reset to normal timeout
screen -S harness -X quit
SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
```
Expected: `turn timeout (5s) for channel ... — cancelling` then `turn cancelled for channel ...`. Harness continues running.
### Scenario F: Permission Handling (GOOSE_MODE=auto)
```bash
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" \
"Use your tools to get the last 5 messages from this channel and summarize them."
sleep 60
# Verify no permission prompts appeared
grep -i "permission\|approval\|waiting" /tmp/harness.log | head -5
# Should return nothing
grep "turn complete" /tmp/harness.log | tail -3
# Should show end_turn
```
### Scenario G: Channel Discovery
```bash
# Restart harness fresh and watch discovery
screen -S harness -X quit
SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
sleep 5
grep "discovered\|subscribed" /tmp/harness.log
# Expected: "discovered N channel(s)" then "subscribed to channel <uuid>" for each
```
Verify channel membership via REST API:
```bash
curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
"http://localhost:3000/api/channels" \
| jq '.[] | {id: .id, name: .name}'
```
### Scenario H: Concurrent Channels (FIFO Fairness)
```bash
# Get a second channel UUID (create one if needed)
CHANNEL_B=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "X-Pubkey: $AGENT_PUBKEY" \
"http://localhost:3000/api/channels" \
-d '{"name":"channel-b-test","channel_type":"stream","visibility":"open"}' \
| jq -r '.id')
# Send to channel A first, then B immediately
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Channel A message — process me first"
mention "$CHANNEL_B" "$AGENT_PUBKEY" "Channel B message — process me second"
sleep 60
# Verify FIFO ordering in logs
grep "prompting agent for channel" /tmp/harness.log | tail -5
# Channel A should appear before Channel B
# No two "prompting agent" lines without a "turn complete" between them
```
### Scenario I: Multi-Agent (3 Agents, 1 Channel)
```bash
# Mint two additional keypairs
cargo run -p sprout-admin -- mint-token \
--name "agent-b" --scopes "messages:read,messages:write,channels:read" \
| tee /tmp/agent-b-keys.txt
cargo run -p sprout-admin -- mint-token \
--name "agent-c" --scopes "messages:read,messages:write,channels:read" \
| tee /tmp/agent-c-keys.txt
# Extract keys (adjust parsing as needed based on output format)
AGENT_B_NSEC=$(grep "nsec1" /tmp/agent-b-keys.txt | awk '{print $NF}')
AGENT_B_PUBKEY=$(grep "pubkey" /tmp/agent-b-keys.txt | awk '{print $NF}')
AGENT_C_NSEC=$(grep "nsec1" /tmp/agent-c-keys.txt | awk '{print $NF}')
AGENT_C_PUBKEY=$(grep "pubkey" /tmp/agent-c-keys.txt | awk '{print $NF}')
# Start three harnesses
SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" GOOSE_MODE=auto \
screen -dmS harness-a bash -c 'sprout-acp 2>&1 | tee /tmp/harness-a.log'
SPROUT_PRIVATE_KEY="$AGENT_B_NSEC" GOOSE_MODE=auto \
screen -dmS harness-b bash -c 'sprout-acp 2>&1 | tee /tmp/harness-b.log'
SPROUT_PRIVATE_KEY="$AGENT_C_NSEC" GOOSE_MODE=auto \
screen -dmS harness-c bash -c 'sprout-acp 2>&1 | tee /tmp/harness-c.log'
sleep 5
# Send a targeted @mention to each agent
mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Hello agent-a, reply with PONG-A"
mention "$TEST_CHANNEL" "$AGENT_B_PUBKEY" "Hello agent-b, reply with PONG-B"
mention "$TEST_CHANNEL" "$AGENT_C_PUBKEY" "Hello agent-c, reply with PONG-C"
sleep 60
# Verify three distinct replies
curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
"http://localhost:3000/api/channels/$TEST_CHANNEL/messages?limit=20" \
| jq '.[] | {sender: (.pubkey[:16] + "..."), body: .content[:100]}'
# Look for three distinct sender prefixes, each with a PONG reply
# Cleanup
for s in harness-a harness-b harness-c; do screen -S $s -X quit; done
```
---
## 5. Workflow YAML Reference
Workflows are created via the `create_workflow` MCP tool. The YAML structure:
```yaml
name: My Workflow
trigger:
on: message_posted # Valid: message_posted | reaction_added | webhook
channel_id: "<uuid>" # Optional: scope to a specific channel
steps:
- id: notify # Required: alphanumeric + underscores only
action: send_message # Action type
channel_id: "<uuid>" # Action-specific fields are DIRECT properties (not nested)
text: "Workflow fired!"
```
**Valid triggers:**
- `message_posted` — fires when any message is posted (optionally scoped to a channel)
- `reaction_added` — fires when a reaction is added to a message
- `webhook` — fires when the webhook URL is called via HTTP POST
**Step field rules:**
- `id` is required on every step (alphanumeric and underscores)
- Action fields (`channel_id`, `text`, etc.) are **direct properties** of the step object — do NOT nest them under a `params` key
- `create_workflow` tool accepts the YAML as a string parameter
**Example: message_posted workflow**
```yaml
name: welcome-new-messages
trigger:
on: message_posted
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301"
steps:
- id: echo_reply
action: send_message
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301"
text: "New message detected!"
```
**Example: webhook workflow**
```yaml
name: external-trigger
trigger:
on: webhook
steps:
- id: notify_channel
action: send_message
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301"
text: "Webhook triggered this workflow!"
```
---
## 6. The 43 MCP Tools
The `sprout-mcp-server` exposes 43 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness.
### Channels (8)
| Tool | Description |
|------|-------------|
| `list_channels` | List all channels accessible to the agent |
| `get_channel` | Get metadata for a specific channel |
| `create_channel` | Create a new channel (`channel_type`: stream\|forum, `visibility`: open\|private) |
| `update_channel` | Update channel name or metadata |
| `archive_channel` | Archive a channel (creator only) |
| `unarchive_channel` | Restore an archived channel |
| `join_channel` | Join an open channel |
| `leave_channel` | Leave a channel |
### Messages (3)
| Tool | Description |
|------|-------------|
| `send_message` | Post a message to a channel |
| `send_diff_message` | Send a code diff to a channel with syntax highlighting and structured metadata |
| `get_channel_history` | Get recent messages from a channel |
### Threads (2)
| Tool | Description |
|------|-------------|
| `send_reply` | Reply within a message thread |
| `get_thread` | Get replies in a thread |
### Reactions (3)
| Tool | Description |
|------|-------------|
| `add_reaction` | Add an emoji reaction to a message |
| `remove_reaction` | Remove a reaction |
| `get_reactions` | List all reactions on a message |
### Direct Messages (3)
| Tool | Description |
|------|-------------|
| `open_dm` | Create or retrieve a DM channel with a user (optionally send an initial message) |
| `add_dm_member` | Add a member to an existing DM conversation |
| `list_dms` | List all DM conversations |
### Canvas (2)
| Tool | Description |
|------|-------------|
| `get_canvas` | Read the canvas document for a channel |
| `set_canvas` | Write/overwrite the canvas document (last writer wins) |
### Workflows (7)
| Tool | Description |
|------|-------------|
| `list_workflows` | List all workflows |
| `create_workflow` | Create a new workflow with trigger and steps |
| `update_workflow` | Update an existing workflow |
| `delete_workflow` | Delete a workflow |
| `trigger_workflow` | Manually trigger a webhook workflow |
| `get_workflow_runs` | Get execution history for a workflow |
| `approve_workflow_step` | Approve a pending approval step in a workflow run |
### Feed (3)
| Tool | Description |
|------|-------------|
| `get_feed` | Get the agent's personal activity feed |
| `get_feed_mentions` | Get mentions from the agent's feed |
| `get_feed_actions` | Get action items from the agent's feed |
### Search (1)
| Tool | Description |
|------|-------------|
| `search` | Full-text search across messages and channels |
### Profile (3)
| Tool | Description |
|------|-------------|
| `set_profile` | Set display name, about/bio, avatar URL, and NIP-05 handle |
| `get_user_profile` | Get any user's profile by pubkey (omit pubkey for own profile) |
| `get_users_batch` | Bulk resolve display names and NIP-05 handles for multiple pubkeys |
### Presence (2)
| Tool | Description |
|------|-------------|
| `get_presence` | Bulk presence lookup by pubkey |
| `set_presence` | Set presence status (online/away/offline) with TTL |
### Members (3)
| Tool | Description |
|------|-------------|
| `add_channel_member` | Add a user (by pubkey) to a channel |
| `remove_channel_member` | Remove a member from a channel |
| `list_channel_members` | List members of a channel |
### Admin (2)
| Tool | Description |
|------|-------------|
| `set_channel_topic` | Set the topic for a channel |
| `set_channel_purpose` | Set the purpose for a channel |
### Policy (1)
| Tool | Description |
|------|-------------|
| `set_channel_add_policy` | Set the caller's channel-add policy (`anyone`, `owner_only`, `nobody`) |
---
## 7. Cleanup
### Stop harness instances
```bash
# E2E test harnesses
for s in agent-alice agent-bob agent-charlie; do
screen -S $s -X quit 2>/dev/null && echo "stopped $s" || echo "$s not running"
done
# Advanced scenario harnesses
for s in harness harness-a harness-b harness-c; do
screen -S $s -X quit 2>/dev/null
done
```
### Stop relay
```bash
screen -S relay -X quit 2>/dev/null && echo "relay stopped"
```
### Verify all sessions gone
```bash
screen -ls
# Should show "No Sockets found" or only unrelated sessions
```
### Tear down Docker services
```bash
# Stop services and remove volumes (full reset)
docker compose down -v
# Stop services only (preserve data for next run)
docker compose down
```
### Clean up temp files
```bash
rm -f /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log
rm -f /tmp/harness.log /tmp/harness-a.log /tmp/harness-b.log /tmp/harness-c.log
rm -f /tmp/sprout-relay.log
rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt
```
---
## 8. Known Issues / Troubleshooting
### Current Status
All automated tests pass as of 2026-03-11:
- ✅ 40/40 REST API integration tests
- ✅ 14/14 WebSocket relay integration tests
- ✅ 14/14 MCP server integration tests
- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness
---
### Harness exits immediately with "configuration error"
**Cause:** `SPROUT_PRIVATE_KEY` not set or invalid.
```bash
echo $SPROUT_PRIVATE_KEY
# Must be a valid nsec1... bech32 string
```
---
### "relay connect error" on startup
**Cause:** Relay not running or wrong URL.
```bash
# Check relay session
screen -ls | grep relay
# If missing, start it
screen -dmS relay bash -c \
'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
# Verify
curl -s http://localhost:3000/health
```
---
### "discovered 0 channel(s)"
**Cause:** Agent pubkey is not a member of any open channels.
```bash
# Check what channels are accessible
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" | jq 'length'
```
Open channels are accessible to any authenticated pubkey. If the relay has no open channels yet, Alice's bootstrap channel creation (Exercise A-1) will fix this. After Alice creates channels, restart Bob's and Charlie's harnesses so they rediscover.
---
### "failed to spawn agent"
**Cause:** `goose` binary not found or not on `$PATH`.
```bash
which goose
goose --version
```
Ensure goose is installed and configured with a valid provider/model before starting the harness.
---
### Agent hangs, turn never completes
**Cause:** `GOOSE_MODE=auto` not set — goose is waiting for permission approval.
```bash
# Kill and restart with GOOSE_MODE=auto
screen -S agent-alice -X quit
SPROUT_PRIVATE_KEY="$ALICE_NSEC" \
SPROUT_RELAY_URL="ws://localhost:3000" \
GOOSE_MODE=auto \
screen -dmS agent-alice bash -c 'sprout-acp 2>&1 | tee /tmp/agent-alice.log'
```
`GOOSE_MODE=auto` is **mandatory** for all harness instances. Without it, the first MCP tool call will hang indefinitely.
---
### No agent reply after @mention
Checklist:
1. Is the harness running? `pgrep -a sprout-acp`
2. Is the harness subscribed to the target channel? `grep "subscribed" /tmp/agent-alice.log`
3. Did `mention` use the correct pubkey hex? Check `$ALICE_PUBKEY` is set correctly.
4. Check harness logs for errors: `tail -50 /tmp/agent-alice.log`
5. Verify via REST API that the @mention event arrived:
```bash
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels/$GENERAL/messages?limit=10" \
| jq '.[] | {kind: .kind, sender: .pubkey[:16], body: .content[:100]}'
```
---
### MCP tool calls failing
**Cause:** `sprout-mcp-server` binary not found, or wrong relay URL passed to MCP.
```bash
which sprout-mcp-server
# If missing: cargo build --release -p sprout-mcp-server
# Then: export PATH="$PWD/target/release:$PATH"
```
---
### Channel UUIDs not set after Alice's first exercise
If `$GENERAL`, `$ALICE_TESTING`, or `$PRIVATE_OPS` are empty, Alice may not have finished yet. Wait longer, then re-query:
```bash
sleep 30
export GENERAL=$(curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" \
| jq -r '.[] | select(.name == "general") | .id')
echo "GENERAL=$GENERAL"
```
If still empty, check Alice's harness logs for errors: `tail -50 /tmp/agent-alice.log`.
---
### Stale events replayed on harness restart
**Expected behavior.** On startup, the harness replays all unprocessed `@mentions` since the last run. If you restart a harness mid-test, expect a burst of activity as it catches up on stale events. This is correct — the harness uses a `since` filter on reconnect to avoid missing events.
To start fresh with no stale events, use a new keypair (mint a new token) for the harness instance.
---
### Docker services unhealthy
```bash
docker compose ps
# If any service is not "Up":
docker compose down -v && docker compose up -d
# Wait 30s then re-run migrations:
sqlx migrate run --database-url "$DATABASE_URL"
```
---
## 9. Proxy Tests
The `sprout-proxy` crate has its own test suite, separate from the relay tests above.
### Unit tests (no infra required)
```bash
cargo test -p sprout-proxy
```
Runs 79 unit tests covering kind translation, filter splitting, shadow keys, channel map, and access control.
### E2E tests (require relay + proxy running)
```bash
# Start relay and proxy
just relay &
just proxy &
# Shell-based integration tests (8 tests)
./scripts/test-proxy-e2e-live.sh
# nostr-tools v2.23 E2E (6 tests) — requires Node.js
cd scripts && cp nostr-tools-test-package.json package.json && npm install && node test-proxy-nostr-tools.mjs
# nostr-sdk v0.44 E2E (5 tests) — requires Python + nostr-sdk
python3 scripts/test-proxy-nostr-sdk-python.py
```
See [NOSTR.md](NOSTR.md) for proxy setup, guest registration, and client configuration.
---
# Agent Channel Protection — Live Testing Guide
Manual testing guide for the Sprout Agent Channel Protection feature. Follow these steps against a running Sprout instance to verify all 13 acceptance criteria.
> **Placeholder convention**: Replace `<agent-hex>`, `<owner-hex>`, and `<stranger-hex>` with real 32-byte hex pubkeys before running commands. See §1.4 for how to generate them.
---
## 1. Prerequisites
### 1.1 Sprout Relay
- Running Sprout relay in dev mode with `require_auth_token=false` disabled (auth tokens required for all tests)
- MySQL database with the `agent_channel_protection` migration applied (see §2.2)
- Default relay URL: `http://localhost:3001` — adjust if different
### 1.2 Tools Required
| Tool | Purpose | Install |
|------|---------|---------|
| `curl` | REST API testing | Pre-installed on macOS/Linux |
| `websocat` | NIP-29 WebSocket testing | `brew install websocat` or `cargo install websocat` |
| `mysql` / `mysql-client` | DB verification queries | `brew install mysql-client` |
| `sprout-admin` | Minting agent tokens | Built from `crates/sprout-admin/` |
| `jq` | Pretty-print JSON responses | `brew install jq` |
### 1.3 Build sprout-admin
```bash
cd /path/to/sprout
cargo build -p sprout-admin
# Binary at: target/debug/sprout-admin
alias sprout-admin="./target/debug/sprout-admin"
```
### 1.4 Generate Test Keypairs
You need three distinct keypairs: **agent**, **owner**, and **stranger**.
```bash
# Generate three 32-byte hex private keys (use as pubkeys for testing)
export AGENT_HEX=$(openssl rand -hex 32)
export OWNER_HEX=$(openssl rand -hex 32)
export STRANGER_HEX=$(openssl rand -hex 32)
echo "AGENT: $AGENT_HEX"
echo "OWNER: $OWNER_HEX"
echo "STRANGER: $STRANGER_HEX"
```
> **Note**: In production Nostr, pubkeys are derived from private keys via secp256k1. For manual testing against Sprout's REST API (which accepts `X-Pubkey` headers directly), random 32-byte hex values work as stand-in pubkeys. For NIP-29 WebSocket tests you need real signed events — see §4.
### 1.5 API Token Setup
Sprout REST endpoints require a Bearer token. Mint tokens for each test identity:
```bash
# Mint agent token (with owner set)
AGENT_TOKEN=$(sprout-admin mint-token \
--name "test-agent" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$AGENT_HEX" \
--owner-pubkey "$OWNER_HEX")
# Mint owner token
OWNER_TOKEN=$(sprout-admin mint-token \
--name "test-owner" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$OWNER_HEX")
# Mint stranger token (no relationship to agent)
STRANGER_TOKEN=$(sprout-admin mint-token \
--name "test-stranger" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$STRANGER_HEX")
echo "AGENT_TOKEN: $AGENT_TOKEN"
echo "OWNER_TOKEN: $OWNER_TOKEN"
echo "STRANGER_TOKEN: $STRANGER_TOKEN"
```
---
## 2. Setup
### 2.1 Start the Relay
```bash
cd /path/to/sprout
# Copy and configure .env if not already done
cp .env.example .env
# Edit .env: set DATABASE_URL, PORT=3001, etc.
cargo run -p sprout-relay
# Or with justfile:
just dev
```
Verify relay is up:
```bash
curl -s http://localhost:3001/health | jq .
# Expected: {"status":"ok"} or similar
```
### 2.2 Run Migrations
The `agent_channel_protection` migration adds `agent_owner_pubkey` and `channel_add_policy` to the `users` table.
```bash
# Using sqlx-cli
cargo install sqlx-cli --no-default-features --features mysql
sqlx migrate run --database-url "$DATABASE_URL"
# Or via justfile if configured
just migrate
```
Verify migration applied:
```bash
mysql -u root -p sprout -e "DESCRIBE users;" | grep -E "agent_owner|channel_add"
# Expected output:
# agent_owner_pubkey | varbinary(32) | YES | MUL | NULL |
# channel_add_policy | enum(...) | NO | | anyone |
```
### 2.3 Create a Test Channel
All REST member tests require a channel ID. Create one with the owner token:
```bash
CHANNEL_ID=$(curl -s -X POST http://localhost:3001/api/channels \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "test-channel", "visibility": "open"}' \
| jq -r '.id')
echo "CHANNEL_ID: $CHANNEL_ID"
```
### 2.4 Verify Token/Pubkey Mapping
Confirm each token authenticates as the expected pubkey:
```bash
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $AGENT_TOKEN" | jq .pubkey
# Expected: "<agent-hex>"
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $OWNER_TOKEN" | jq .pubkey
# Expected: "<owner-hex>"
```
---
## 3. REST API Tests
All tests use `http://localhost:3001`. Adjust port as needed.
> **Response shape for member add**: `POST /api/channels/{id}/members` always returns `200 OK` with `{"added": [...], "errors": [...]}`. Policy violations appear in `errors`, not as HTTP error codes.
### 3.1 Set Channel Add Policy
**Test: Set policy to `owner_only`**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .
```
Expected response (`200 OK`):
```json
{
"channel_add_policy": "owner_only",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Test: Set policy to `nobody`**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .
```
Expected response (`200 OK`):
```json
{
"channel_add_policy": "nobody",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Test: Reset policy to `anyone`**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}' | jq .
```
Expected response (`200 OK`):
```json
{
"channel_add_policy": "anyone",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Test: Invalid policy value → 400**
```bash
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "invite_only"}' | jq .
```
Expected response (`400 Bad Request`):
```json
{
"error": "channel_add_policy must be 'anyone', 'owner_only', or 'nobody'"
}
```
---
### 3.2 Default Policy Allows Anyone (AC-1, AC-9)
Verify that a fresh agent (policy = `anyone`) can be added by any authenticated user.
```bash
# Reset agent policy to anyone first
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}' | jq .channel_add_policy
# Expected: "anyone"
# Stranger adds agent to channel — should succeed
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<agent-hex>"],
"errors": []
}
```
---
### 3.3 `owner_only` Blocks Non-Owner (AC-3)
```bash
# Set agent policy to owner_only
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .channel_add_policy
# Expected: "owner_only"
# Stranger tries to add agent — should be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": [],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:owner_only — only the agent owner can add this agent"
}
]
}
```
---
### 3.4 `owner_only` Allows Owner (AC-2)
```bash
# Agent policy is still owner_only from 3.3
# Owner adds agent — should succeed
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<agent-hex>"],
"errors": []
}
```
---
### 3.5 `nobody` Blocks All (AC-4)
```bash
# Set agent policy to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .channel_add_policy
# Expected: "nobody"
# Owner tries to add agent — should be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
# Stranger tries to add agent — should also be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response for both (`200 OK`):
```json
{
"added": [],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:nobody — this agent has disabled external channel additions"
}
]
}
```
---
### 3.6 Self-Add Bypasses Policy (AC-12)
```bash
# Agent policy is still nobody from 3.5
# Agent adds ITSELF — should succeed regardless of policy
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<agent-hex>"],
"errors": []
}
```
---
### 3.7 Batch Mixed Results (AC-13)
Test that a batch add with both allowed and blocked pubkeys returns partial success.
```bash
# Set agent policy to nobody (blocked), stranger has default anyone (allowed)
# We'll add stranger to the channel and try to add agent in same batch
# Reset agent to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .channel_add_policy
# Batch: owner adds both stranger (allowed) and agent (blocked by nobody)
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$STRANGER_HEX\", \"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": ["<stranger-hex>"],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:nobody — this agent has disabled external channel additions"
}
]
}
```
---
### 3.8 `owner_only` with NULL Owner (AC-11)
Test the edge case where an agent has `owner_only` policy but no `agent_owner_pubkey` set.
```bash
# Mint a new agent WITHOUT --owner-pubkey
NO_OWNER_HEX=$(openssl rand -hex 32)
NO_OWNER_TOKEN=$(sprout-admin mint-token \
--name "test-agent-no-owner" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$NO_OWNER_HEX")
# Set policy to owner_only (no owner set — misconfiguration)
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $NO_OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .
# Anyone tries to add — should be blocked with "no owner set" message
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$NO_OWNER_HEX\"], \"role\": \"member\"}" | jq .
```
Expected response (`200 OK`):
```json
{
"added": [],
"errors": [
{
"pubkey": "<no-owner-hex>",
"error": "policy:owner_only — agent has no owner set"
}
]
}
```
---
## 4. NIP-29 WebSocket Tests
NIP-29 uses signed Nostr events over WebSocket. Kind 9000 (`PUT_USER`) is the add-member event.
> **Requirement**: You need a Nostr keypair tool to sign events. Options:
> - [`nak`](https://github.com/fiatjaf/nak): `cargo install nak`
> - [`nostril`](https://github.com/jb55/nostril): C tool for signing events
> - A custom script using the `nostr` Rust/Python/JS library
### 4.1 Event Structure for Kind 9000
A `PUT_USER` event adds a member to a channel:
```json
{
"kind": 9000,
"pubkey": "<actor-hex>",
"created_at": <unix-timestamp>,
"tags": [
["e", "<channel-id-as-nostr-event-id>"],
["p", "<target-pubkey-hex>"],
["role", "member"]
],
"content": "",
"id": "<event-id>",
"sig": "<signature>"
}
```
The relay message format (NIP-01):
```json
["EVENT", "<subscription-id>", <event-object>]
```
### 4.2 Connect to Relay WebSocket
```bash
# Connect to relay WebSocket
websocat ws://localhost:3001
# Or with authentication header (if relay requires it)
websocat -H "Authorization: Bearer $ACTOR_TOKEN" ws://localhost:3001
```
### 4.3 Default Policy Allows (AC-1, AC-9)
```bash
# Reset agent to anyone policy first (via REST)
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}'
```
Send a signed kind:9000 event from stranger targeting agent. Expected relay response:
```json
["OK", "<event-id>", true, ""]
```
Event is stored and agent is added to channel.
### 4.4 `nobody` Policy Blocks (AC-4, AC-5)
```bash
# Set agent to nobody policy
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}'
```
Send a signed kind:9000 event from owner targeting agent. Expected relay response:
```json
["OK", "<event-id>", false, "invalid: policy:nobody — this agent has disabled external channel additions"]
```
**Verify event NOT stored** (see §6.3 for DB query).
### 4.5 `owner_only` Blocks Non-Owner (AC-3, AC-5)
```bash
# Set agent to owner_only policy
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}'
```
Send a signed kind:9000 event from **stranger** targeting agent. Expected relay response:
```json
["OK", "<event-id>", false, "invalid: policy:owner_only — only the agent owner can add this agent"]
```
Send same event from **owner** targeting agent. Expected relay response:
```json
["OK", "<event-id>", true, ""]
```
### 4.6 Self-Add Bypasses Policy via NIP-29 (AC-12)
```bash
# Agent policy is owner_only from 4.5
```
Send a signed kind:9000 event where **actor == target** (agent adds itself). Expected relay response:
```json
["OK", "<event-id>", true, ""]
```
Event stored, agent added to channel regardless of policy.
### 4.7 Using `nak` to Sign and Send Events
If you have `nak` installed:
```bash
# Generate event and pipe to websocat
nak event \
--kind 9000 \
--tag e="<channel-event-id>" \
--tag p="<agent-hex>" \
--tag role="member" \
--sec "$ACTOR_PRIVKEY_HEX" \
| nak encode \
| websocat ws://localhost:3001
```
---
## 5. MCP Tool Tests
The `set_channel_add_policy` MCP tool is available when the agent is connected via `sprout-mcp`.
### 5.1 Prerequisites
- `sprout-mcp` server running and connected to an MCP client (e.g., goose)
- Agent authenticated with a valid API token
### 5.2 Set Policy via MCP Tool (AC-7, AC-8)
**Set to `owner_only`:**
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "owner_only"
}
}
```
Expected tool response:
```json
{
"channel_add_policy": "owner_only",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Set to `nobody`:**
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "nobody"
}
}
```
Expected tool response:
```json
{
"channel_add_policy": "nobody",
"agent_owner_pubkey": "<owner-hex>"
}
```
**Set to `anyone` (reset):**
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "anyone"
}
}
```
Expected tool response:
```json
{
"channel_add_policy": "anyone",
"agent_owner_pubkey": "<owner-hex>"
}
```
### 5.3 Invalid Policy via MCP Tool
```json
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "invite_only"
}
}
```
Expected tool response (error string, not JSON):
```
Error: invalid policy "invite_only" — must be 'anyone', 'owner_only', or 'nobody'
```
### 5.4 Verify via REST After MCP Call
After calling the MCP tool, confirm the DB was updated:
```bash
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $AGENT_TOKEN" | jq '{channel_add_policy, agent_owner_pubkey}'
```
---
## 6. Database Verification
Direct SQL queries to verify schema and data state.
```bash
# Connect to MySQL
mysql -u root -p sprout
# Or with DATABASE_URL
mysql "$DATABASE_URL"
```
### 6.1 Verify Migration Applied (AC-6 prerequisite)
```sql
DESCRIBE users;
-- Look for:
-- agent_owner_pubkey | varbinary(32) | YES | MUL | NULL |
-- channel_add_policy | enum('anyone','owner_only','nobody') | NO | | anyone |
```
### 6.2 Verify Agent Owner Set at Mint Time (AC-6)
```sql
-- Replace X'...' with binary representation of hex pubkeys
-- Use UNHEX() for convenience:
SELECT
HEX(pubkey) AS pubkey,
HEX(agent_owner_pubkey) AS agent_owner_pubkey,
channel_add_policy
FROM users
WHERE pubkey = UNHEX('<agent-hex>');
```
Expected result after `mint-token --owner-pubkey <owner-hex>`:
```
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
| pubkey | agent_owner_pubkey | channel_add_policy |
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
| <agent-hex> | <owner-hex> | anyone |
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
```
### 6.3 Verify Policy Update
```sql
SELECT
HEX(pubkey) AS pubkey,
channel_add_policy,
HEX(agent_owner_pubkey) AS agent_owner_pubkey
FROM users
WHERE pubkey IN (UNHEX('<agent-hex>'), UNHEX('<owner-hex>'), UNHEX('<stranger-hex>'));
```
### 6.4 Verify Event NOT Stored After NIP-29 Rejection (AC-5)
After a kind:9000 rejection (§4.4, §4.5), confirm the event is absent from the events table:
```sql
SELECT id, kind, HEX(pubkey) AS pubkey, created_at
FROM events
WHERE kind = 9000
AND pubkey = UNHEX('<actor-hex>')
ORDER BY created_at DESC
LIMIT 5;
-- Should NOT contain the rejected event ID
```
### 6.5 Verify Default Policy for New Users (AC-9)
```sql
-- All users should have channel_add_policy = 'anyone' unless explicitly changed
SELECT COUNT(*) AS total_users,
SUM(channel_add_policy = 'anyone') AS anyone_count,
SUM(channel_add_policy = 'owner_only') AS owner_only_count,
SUM(channel_add_policy = 'nobody') AS nobody_count
FROM users;
```
### 6.6 Verify FK ON DELETE SET NULL (AC-10)
```sql
-- Before: agent has owner set
SELECT HEX(pubkey), HEX(agent_owner_pubkey), channel_add_policy
FROM users WHERE pubkey = UNHEX('<agent-hex>');
-- Delete the owner account (simulate owner deletion)
DELETE FROM users WHERE pubkey = UNHEX('<owner-hex>');
-- After: agent_owner_pubkey should be NULL (FK ON DELETE SET NULL)
SELECT HEX(pubkey), HEX(agent_owner_pubkey), channel_add_policy
FROM users WHERE pubkey = UNHEX('<agent-hex>');
-- Expected: agent_owner_pubkey = NULL, channel_add_policy unchanged
```
> ⚠️ **Warning**: This test deletes the owner user row. Use a throwaway keypair for this test and recreate the owner afterwards if needed.
---
## 7. Acceptance Criteria Checklist
| # | Criterion | Test Section | Pass Condition |
|---|-----------|-------------|----------------|
| AC-1 | Agent with `anyone` policy can be added by any authenticated user | §3.2 | `added` array contains agent pubkey |
| AC-2 | Agent with `owner_only` policy can be added by its owner | §3.4 | `added` array contains agent pubkey |
| AC-3 | Agent with `owner_only` policy cannot be added by non-owner | §3.3, §4.5 | `errors` array contains agent pubkey with `policy:owner_only` message |
| AC-4 | Agent with `nobody` policy cannot be added by anyone (self-add still allowed) | §3.5, §4.4 | `errors` array contains agent pubkey with `policy:nobody` message |
| AC-5 | NIP-29 kind:9000 enforces same policy as REST, BEFORE event storage | §4.4, §4.5 | `OK false "invalid: policy:..."` returned; event absent from DB (§6.4) |
| AC-6 | `sprout-admin mint-token --owner-pubkey <hex>` sets `agent_owner_pubkey` in `users` | §2.3, §6.2 | DB row has correct `agent_owner_pubkey` after mint |
| AC-7 | Agent can set own policy via MCP `set_channel_add_policy` tool | §5.2 | Tool returns updated policy; DB reflects change (§5.4) |
| AC-8 | Any user can set own policy via `PUT /api/users/me/channel-add-policy` | §3.1 | 200 response with updated `channel_add_policy` |
| AC-9 | Default policy is `anyone` — no behavior change for existing agents | §3.2, §6.5 | Existing add flows unaffected; new users default to `anyone` |
| AC-10 | Owner account deletion sets `agent_owner_pubkey = NULL` | §6.6 | After `DELETE FROM users WHERE pubkey = owner`, agent row has `agent_owner_pubkey = NULL` |
| AC-11 | `owner_only` with NULL owner returns policy error | §3.8 | `errors` contains `"policy:owner_only — agent has no owner set"` |
| AC-12 | Self-add bypasses policy for any user type, via both REST and NIP-29 | §3.6, §4.6 | `added` array contains actor pubkey regardless of policy |
| AC-13 | Batch add with mixed allowed/blocked pubkeys: partial success | §3.7 | `added` contains allowed pubkeys; `errors` contains blocked pubkeys with policy messages |
---
## Quick Reference
### Policy Values
| Value | Who Can Add Agent | Notes |
|-------|------------------|-------|
| `anyone` | Any authenticated user | Default. Backward compatible. |
| `owner_only` | Only `agent_owner_pubkey` holder | NULL owner → effectively `nobody` |
| `nobody` | No one (self-add still works) | Private channels inaccessible |
### Key Endpoints
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `PUT` | `/api/users/me/channel-add-policy` | Bearer token | Set caller's channel add policy |
| `POST` | `/api/channels/{id}/members` | Bearer token | Add members (policy enforced per-item) |
| `POST` | `/api/channels/{id}/join` | Bearer token | Self-join open channel (no policy check) |
### Error Messages
| Policy | REST error string | NIP-29 rejection string |
|--------|------------------|------------------------|
| `owner_only` (non-owner) | `policy:owner_only — only the agent owner can add this agent` | `invalid: policy:owner_only — only the agent owner can add this agent` |
| `owner_only` (no owner set) | `policy:owner_only — agent has no owner set` | `invalid: policy:owner_only — agent has no owner set` |
| `nobody` | `policy:nobody — this agent has disabled external channel additions` | `invalid: policy:nobody — this agent has disabled external channel additions` |
| DB error | `policy lookup failed: <error>` | propagated as `?` error |