Merge remote-tracking branch 'origin/main' into inbox

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-11 09:03:37 -04:00
576 changed files with 71204 additions and 23877 deletions

View File

@@ -46,40 +46,10 @@ RELAY_URL=ws://localhost:3000
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# SPROUT_RELAY_PRIVATE_KEY=<32-byte hex private key>
# Set to true in production to require bearer token authentication
SPROUT_REQUIRE_AUTH_TOKEN=false
# -----------------------------------------------------------------------------
# Auth
# -----------------------------------------------------------------------------
# Set to false for dev (accepts NIP-42 without JWT, allows X-Pubkey header).
# Set to true in production to require bearer token authentication.
SPROUT_REQUIRE_AUTH_TOKEN=false
# JWKS endpoint for verifying JWT access tokens.
# Claim that carries the user's Nostr public key (hex, 32 bytes).
OKTA_PUBKEY_CLAIM=nostr_pubkey
# ── Keycloak (local OAuth testing — stands in for Okta in prod) ──────────────
# Keycloak is NOT a production dependency. It lets you test the full OAuth
# flow locally without needing an Okta tenant. Run `docker compose up -d`
# then `./scripts/setup-keycloak.sh` to create the realm, client, and users.
#
# Admin UI: http://localhost:8180 (admin / admin)
# Get a token:
# curl -s -X POST http://localhost:8180/realms/sprout/protocol/openid-connect/token \
# -d 'client_id=sprout-desktop&grant_type=password&username=tyler&password=password123' \
# | jq -r .access_token
OKTA_JWKS_URI=http://localhost:8180/realms/sprout/protocol/openid-connect/certs
OKTA_ISSUER=http://localhost:8180/realms/sprout
OKTA_AUDIENCE=sprout-desktop
# ── Okta (production / staging) ──────────────────────────────────────────────
# Uncomment and fill in when deploying against a real Okta tenant.
# OKTA_JWKS_URI=https://dev-example.okta.com/oauth2/default/v1/keys
# OKTA_ISSUER=https://dev-example.okta.com/oauth2/default
# OKTA_AUDIENCE=sprout-api
# OKTA_PUBKEY_CLAIM=nostr_pubkey
# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
# SPROUT_WEB_DIR=./web/dist
# -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing)
@@ -121,9 +91,6 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
# SPROUT_PRIVATE_KEY=<32-byte hex or nsec1… private key>
# Bearer token for relay authentication (when SPROUT_REQUIRE_AUTH_TOKEN=true).
# SPROUT_API_TOKEN=
# Relay WebSocket URL the harness connects to.
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
# connection target — they happen to point at the same place in local dev.
@@ -219,4 +186,3 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
# These are accepted for backward compatibility but the canonical names above
# are preferred:
# SPROUT_ACP_PRIVATE_KEY → SPROUT_PRIVATE_KEY
# SPROUT_ACP_API_TOKEN → SPROUT_API_TOKEN

View File

@@ -6,6 +6,7 @@ on:
env:
CARGO_TERM_COLOR: always
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
jobs:
rust-lint:
@@ -77,8 +78,26 @@ jobs:
wget
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Get Playwright version
id: pw-version
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
id: playwright-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: cd desktop && pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd desktop && pnpm exec playwright install chromium
- name: Install Playwright system dependencies
run: cd desktop && pnpm exec playwright install-deps chromium
- name: Save Playwright browser cache
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Desktop lint and format
run: just desktop-check
- name: Desktop build
@@ -115,8 +134,26 @@ jobs:
desktop/src-tauri
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Get Playwright version
id: pw-version
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
id: playwright-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: cd desktop && pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd desktop && pnpm exec playwright install chromium
- name: Install Playwright system dependencies
run: cd desktop && pnpm exec playwright install-deps chromium
- name: Save Playwright browser cache
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Desktop build
run: just desktop-build
- name: Start integration services
@@ -160,6 +197,7 @@ jobs:
RELAY_URL=ws://localhost:3000 \
SPROUT_BIND_ADDR=0.0.0.0:3000 \
SPROUT_REQUIRE_AUTH_TOKEN=false \
SPROUT_RECONCILE_CHANNELS=true \
./target/ci/sprout-relay > /tmp/sprout-relay.log 2>&1 &
echo $! > /tmp/sprout-relay.pid
for attempt in $(seq 1 60); do
@@ -167,8 +205,8 @@ jobs:
cat /tmp/sprout-relay.log
exit 1
fi
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/api/channels || true)
if [ "${status_code}" != "000" ]; then
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
if [ "${status_code}" = "200" ]; then
exit 0
fi
sleep 1
@@ -190,6 +228,22 @@ jobs:
/tmp/sprout-relay.log
if-no-files-found: ignore
web:
name: Web
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Web lint and format
run: just web-check
- name: Web build
run: just web-build
mobile:
name: Mobile
runs-on: ubuntu-latest
@@ -222,6 +276,27 @@ jobs:
- name: Dependency policy
run: cargo-deny check
dead-token-guard:
name: Dead Token Reference Guard
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check for dead API token references in client code
run: |
# Fail if dead API token patterns reappear in desktop, mobile, docs, or config.
# Relay crates are excluded — they still use token auth internally.
PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_'
PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example'
EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool'
if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then
echo "::error::Dead API token references found in client code. See above."
exit 1
fi
echo "No dead token references found."
server-cross-compile:
name: Server Cross-Compile
runs-on: ubuntu-latest
@@ -248,7 +323,8 @@ jobs:
cross build --release --target "$TARGET" \
-p sprout-relay \
-p sprout-acp \
-p sprout-mcp
-p sprout-mcp \
-p git-credential-nostr
desktop-build-macos:
name: Desktop Build (macOS)
@@ -267,6 +343,7 @@ jobs:
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
- name: Build Tauri app
run: cd desktop && pnpm tauri build
env:

186
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,186 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Semver version (e.g. 0.4.0)"
required: true
ref:
description: "Branch, tag, or SHA to build"
default: main
required: true
jobs:
release:
name: Release
runs-on: macos-latest
timeout-minutes: 60
permissions:
contents: write
id-token: write # required by block/apple-codesign-action for OIDC
env:
VERSION: ${{ inputs.version }}
steps:
- name: Validate version
run: |
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid version '$VERSION'. Expected semver (e.g. 0.4.0 or 1.0.0-beta.1)"
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Patch version
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
- name: Generate release config
run: cd desktop && node scripts/build-release-config.mjs
env:
SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }}
SPROUT_UPDATER_ENDPOINT: https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json
- name: Build sidecars
run: |
cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr
./scripts/bundle-sidecars.sh
- name: Build unsigned Tauri app
run: cd desktop && pnpm tauri build --verbose --no-sign --config src-tauri/tauri.release.conf.json
env:
SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }}
SPROUT_UPDATER_ENDPOINT: https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Locate unsigned DMG
id: unsigned
run: |
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
- name: Codesign and Notarize
id: codesign
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
with:
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
entitlements-plist-path: desktop/src-tauri/Entitlements.plist
artifact-name: sprout-${{ github.sha }}-${{ github.run_id }}-arm64
- name: Replace DMG and rebuild updater archive
env:
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
APP_DIR="${BUNDLE_DIR}/macos"
# Replace unsigned DMG with the signed/notarized one.
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
# Swap the unsigned .app for the signed .app extracted from the action's zip.
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract"
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
rm -rf "${APP_DIR}/Sprout.app"
cp -R "${EXTRACT_DIR}/Sprout.app" "${APP_DIR}/Sprout.app"
# Rebuild the updater archive from the signed .app and re-sign it with the Tauri updater key.
rm -f "${APP_DIR}/Sprout.app.tar.gz" "${APP_DIR}/Sprout.app.tar.gz.sig"
(cd "$APP_DIR" && tar -czf Sprout.app.tar.gz Sprout.app)
TARBALL_ABS="$(pwd)/${APP_DIR}/Sprout.app.tar.gz"
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
- name: Verify code signature
run: |
codesign --verify --deep --strict --verbose=2 \
desktop/src-tauri/target/release/bundle/macos/Sprout.app
spctl --assess --type execute --verbose=4 \
desktop/src-tauri/target/release/bundle/macos/Sprout.app
- name: Locate build artifacts
id: artifacts
run: |
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
# Find the DMG (Tauri names it Sprout_<version>_<arch>.dmg)
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
# Find the updater .tar.gz and .sig
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
SIG="${ARCHIVE}.sig"
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1
fi
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
- name: Generate latest.json
run: |
bash desktop/scripts/generate-oss-latest-json.sh \
"$VERSION" \
"$SIG_PATH" \
"https://github.com/block/sprout/releases/download/sprout-desktop-latest/$ARCHIVE_NAME" \
> latest.json
cat latest.json
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
ARCHIVE_NAME: ${{ steps.artifacts.outputs.archive_name }}
- name: Create versioned GitHub release
run: |
RELEASE_SHA=$(git rev-parse HEAD)
gh release create "v${VERSION}" \
--target "$RELEASE_SHA" \
--title "Sprout Desktop v${VERSION}" \
--notes "Sprout Desktop v${VERSION}" \
"$DMG_PATH"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
- name: Update rolling release for auto-updater
run: |
gh release create sprout-desktop-latest \
--prerelease \
--title "Sprout Desktop Auto-Update" \
--notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \
2>/dev/null || true
gh release upload sprout-desktop-latest \
latest.json \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}

11
.gitignore vendored
View File

@@ -26,6 +26,12 @@ playwright-report/
test-results/
blob-report/
# Node modules (pnpm workspace hoists to root)
node_modules/
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
/package-lock.json
# sqlx offline query data (generated, not portable)
.sqlx/
@@ -36,3 +42,8 @@ typesense-data/
# Hermit (toolchain manager cache)
.hermit/
doc/
/repos/
# Local identity files
identity.key
**/identity.key

View File

@@ -127,6 +127,35 @@ just desktop-dev # web-only dev server (faster iteration)
just desktop-app # full Tauri app with native shell
```
### Workspace Switching
The desktop app supports multiple workspaces (each backed by a different relay).
Switching workspaces does **not** reload the page — it uses React key-based
remounting. `<AppReady key={workspaceKey} />` in `App.tsx` forces the entire
workspace-scoped subtree to unmount and remount with fresh state.
**Module-level singletons must be explicitly reset.** React remounting only
clears React state (useState, useRef, context). Module-level variables (Maps,
class instances, cached promises) survive across remounts. Every workspace-scoped
singleton needs a reset function wired into `resetWorkspaceState()` in
`desktop/src/features/workspaces/useWorkspaceInit.ts`.
Current singletons that are reset on workspace switch:
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
- `resetMediaCaches()` — proxy port and relay origin caches
- `clearSearchHitEventCache()` — search result event cache
- `clearAllDrafts()` — message draft cache
**If you add a new module-level cache, Map, or class instance that holds
workspace-scoped data, you must add its reset to `resetWorkspaceState()`.**
Failure to do so causes data from the old workspace to leak into the new one.
Key files:
- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary
- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend
- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell)
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`)
---
## Mobile App (Flutter)

View File

@@ -70,7 +70,7 @@ Sprout is a Rust monorepo (~72K LOC across 17 crates), licensed Apache 2.0 under
sprout-core (zero I/O — types, verification, filter matching, kind registry)
│
├── sprout-db (Postgres: events, channels, tokens, workflows, audit)
├── sprout-auth (NIP-42, Okta JWT, API tokens, scopes, rate limiting)
├── sprout-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting)
├── sprout-pubsub (Redis pub/sub, presence, typing indicators)
├── sprout-search (Typesense: index, query, delete)
├── sprout-audit (hash-chain tamper-evident log)
@@ -171,14 +171,12 @@ The relay immediately sends `["AUTH", "<challenge>"]`. The challenge is a random
### Step 3: Authentication
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Four authentication paths:
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Authentication paths:
| Path | Mechanism | Use Case |
|------|-----------|---------|
| NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay |
| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta |
| NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts |
| HTTP Bearer JWT | `Authorization: Bearer <jwt>` header on REST endpoints | REST API clients |
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients |
On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.
@@ -347,22 +345,20 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)
### sprout-auth — Authentication and Authorization
**2,310 LOC.** Handles all four authentication paths, JWKS caching, scope enforcement, and token operations.
**2,310 LOC.** Handles authentication paths, scope enforcement, and token operations.
**Four auth paths:**
**Auth paths:**
| Path | Entry Point | Notes |
|------|-------------|-------|
| NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) |
| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated |
| NIP-42 + API token | Relay AUTH handler → DB lookup | `auth_token` tag with `sprout_` prefix; relay intercepts before `verify_auth_event()` (which has no DB access) |
| HTTP Bearer JWT | `validate_bearer_jwt()` | REST endpoints; skips pubkey cross-check; always adds `ChannelsRead` |
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event |
**Key types:**
```rust
pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec<Scope>, pub auth_method: AuthMethod }
pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip42ApiToken }
pub enum AuthMethod { Nip42, Nip98 }
pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite,
AdminChannels, UsersRead, UsersWrite, AdminUsers,
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
@@ -372,10 +368,7 @@ pub trait RateLimiter: Send + Sync { ... }
```
**Security details:**
- JWKS double-checked locking: two read-lock checks before fetching, HTTP fetch with no lock held, write-lock re-check after. Cache TTL: 300 seconds.
- Token comparison: `subtle::ConstantTimeEq` — constant-time, prevents timing attacks.
- Token format: `sprout_<64-hex-chars>` (71 chars). `hash_token()` → SHA-256 → stored hash.
- Scopeless JWT defaults to `[MessagesRead]` only (not read+write).
- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags.
- NIP-42 timestamp tolerance: ±60 seconds.
- Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments.
@@ -396,7 +389,6 @@ pub trait RateLimiter: Send + Sync { ... }
| `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` |
| `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens |
| `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables |
| `api_token.rs` | Token creation; receives pre-hashed token from caller |
| `dm.rs` | DM channel management |
| `reaction.rs` | Reaction storage and retrieval |
| `thread.rs` | Thread/reply tracking |
@@ -415,8 +407,7 @@ pub trait RateLimiter: Send + Sync { ... }
- Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it.
- Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit.
- `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`.
- API tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`.
- Approval tokens: separate path — `create_approval` receives the raw token and hashes it internally.
- Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256.
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.
**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres.
@@ -664,8 +655,6 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
| POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) |
| GET/POST/DELETE | `/api/tokens` | List/create/delete all API tokens |
| DELETE | `/api/tokens/{id}` | Delete specific API token |
| GET | `/api/dms` | List DM channels |
| POST | `/api/dms` | Open a DM channel |
| POST | `/api/dms/{channel_id}/members` | Add DM member |
@@ -715,7 +704,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
- Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically.
- Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr).
- Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect.
- REST calls use `Authorization: Bearer <token>` when `SPROUT_API_TOKEN` is set; falls back to `X-Pubkey: <hex>` in dev mode.
- REST calls use NIP-98 Schnorr-signed auth when `SPROUT_PRIVATE_KEY` is set; falls back to `X-Pubkey: <hex>` in dev mode.
- `create_channel` sends a signed Nostr kind 9007 event (NIP-29 group creation, not a REST call).
- `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID.
- UUID validation at tool boundary before any network call.
@@ -812,12 +801,9 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic
| Concern | Mechanism |
|---------|-----------|
| Token comparison | `subtle::ConstantTimeEq` — prevents timing attacks |
| Token storage | SHA-256 hash only — raw token shown once at mint, never stored |
| JWKS cache | Double-checked locking; HTTP fetch with no lock held (prevents global DoS) |
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
| AUTH events | Never stored in Postgres, never logged in audit chain |
| Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` events — URL and method verification |
### Input Validation
@@ -873,7 +859,6 @@ Docker Compose provides the full local development stack. All services include h
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
| Keycloak | `quay.io/keycloak/keycloak:26` | 8180 | Local OAuth/OIDC stand-in for Okta |
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
@@ -887,7 +872,6 @@ Docker Compose provides the full local development stack. All services include h
| `workflows` | Workflow definitions (YAML stored as canonical JSON) |
| `workflow_runs` | Execution records with trigger context and trace |
| `workflow_approvals` | Approval gates (token stored as SHA-256 hash) |
| `api_tokens` | API token records (hash only, never plaintext) |
| `audit_log` | Hash-chain audit entries |
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |

View File

@@ -286,7 +286,7 @@ The short version:
sprout-relay ← WebSocket server, REST API, event ingestion
sprout-core ← Shared types, event verification, filter matching
sprout-db ← Postgres access layer (sqlx)
sprout-auth ← NIP-42 + OIDC JWT + API token scopes
sprout-auth ← NIP-42 + NIP-98 + API token scopes
sprout-pubsub ← Redis fan-out
sprout-search ← Typesense full-text search
sprout-audit ← Tamper-evident hash-chain audit log

338
Cargo.lock generated
View File

@@ -101,7 +101,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -112,7 +112,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -455,6 +455,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc"
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -772,6 +782,20 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "countdown-bot"
version = "0.1.0"
dependencies = [
"anyhow",
"futures-util",
"nostr",
"serde_json",
"sprout-sdk",
"tokio",
"tokio-tungstenite",
"url",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -835,6 +859,16 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
@@ -1084,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1350,6 +1384,43 @@ dependencies = [
"weezl",
]
[[package]]
name = "git-credential-nostr"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"nostr",
"serde_json",
"zeroize",
]
[[package]]
name = "git-sign-nostr"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"hex",
"libc",
"nostr",
"serde_json",
"zeroize",
]
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -1506,6 +1577,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1585,7 +1662,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -1729,6 +1806,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -1907,21 +2000,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2106,6 +2184,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minidom"
version = "0.16.0"
@@ -2248,7 +2336,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2444,16 +2532,6 @@ dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -2629,6 +2707,20 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "process-wrap"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55"
dependencies = [
"futures",
"indexmap",
"nix",
"tokio",
"tracing",
"windows 0.62.2",
]
[[package]]
name = "pxfm"
version = "0.1.28"
@@ -2679,7 +2771,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -2717,7 +2809,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -3022,12 +3114,14 @@ dependencies = [
"futures",
"pastey",
"pin-project-lite",
"process-wrap",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
]
@@ -3126,7 +3220,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3185,7 +3279,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3539,16 +3633,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.4"
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
@@ -3594,7 +3682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3621,16 +3709,19 @@ name = "sprout-acp"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"clap",
"evalexpr",
"futures-util",
"hex",
"nix",
"nostr",
"reqwest 0.13.2",
"rustls",
"serde",
"serde_json",
"sha2 0.11.0",
"sprout-core",
"sprout-persona",
"sprout-sdk",
@@ -3655,10 +3746,27 @@ dependencies = [
"nostr",
"serde_json",
"sprout-auth",
"sprout-core",
"sprout-db",
"tokio",
]
[[package]]
name = "sprout-agent"
version = "0.1.0"
dependencies = [
"arc-swap",
"getrandom 0.2.17",
"nix",
"reqwest 0.13.2",
"rmcp",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sprout-audit"
version = "0.1.0"
@@ -3681,17 +3789,13 @@ dependencies = [
name = "sprout-auth"
version = "0.1.0"
dependencies = [
"chrono",
"hex",
"jsonwebtoken",
"nostr",
"rand 0.10.0",
"reqwest 0.13.2",
"serde",
"serde_json",
"sha2 0.11.0",
"sprout-core",
"subtle",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -3705,6 +3809,8 @@ version = "0.1.0"
dependencies = [
"base64",
"clap",
"hex",
"infer",
"nostr",
"reqwest 0.13.2",
"serde",
@@ -3714,6 +3820,7 @@ dependencies = [
"sprout-sdk",
"thiserror 2.0.18",
"tokio",
"url",
"uuid",
]
@@ -3754,12 +3861,34 @@ dependencies = [
"uuid",
]
[[package]]
name = "sprout-dev-mcp"
version = "0.1.0"
dependencies = [
"ignore",
"nix",
"rmcp",
"schemars",
"serde",
"serde_json",
"similar",
"sprout-cli",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sprout-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"futures-util",
"hex",
"infer",
"nostr",
"reqwest 0.13.2",
"rmcp",
@@ -3767,6 +3896,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"sha2 0.11.0",
"sprout-core",
"sprout-sdk",
"thiserror 2.0.18",
@@ -3806,6 +3936,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "sprout-pair-relay"
version = "0.1.0"
dependencies = [
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
"parking_lot",
"rand 0.8.5",
"secp256k1",
"serde_json",
"sha2 0.10.9",
"tokio",
"tokio-tungstenite",
"tokio-util",
]
[[package]]
name = "sprout-pairing-cli"
version = "0.1.0"
@@ -3893,11 +4041,13 @@ dependencies = [
"deadpool-redis",
"futures-util",
"hex",
"hmac 0.13.0",
"infer",
"metrics",
"metrics-exporter-prometheus",
"moka",
"nostr",
"rand 0.10.0",
"redis",
"serde",
"serde_json",
@@ -3909,9 +4059,11 @@ dependencies = [
"sprout-db",
"sprout-media",
"sprout-pubsub",
"sprout-sdk",
"sprout-search",
"sprout-workflow",
"sqlx",
"subtle",
"thiserror 2.0.18",
"tokio",
"tokio-util",
@@ -4275,7 +4427,7 @@ dependencies = [
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -4294,7 +4446,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4558,7 +4710,12 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
@@ -4685,6 +4842,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5035,7 +5198,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5050,11 +5213,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -5066,6 +5241,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -5100,7 +5284,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -5147,6 +5342,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5300,6 +5505,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -5658,6 +5872,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"

View File

@@ -9,6 +9,7 @@ members = [
"crates/sprout-audit",
"crates/sprout-mcp",
"crates/sprout-acp",
"crates/sprout-agent",
"crates/sprout-proxy",
"crates/sprout-test-client",
"crates/sprout-admin",
@@ -18,6 +19,11 @@ members = [
"crates/sprout-pairing-cli",
"crates/sprout-sdk",
"crates/sprout-persona",
"crates/git-credential-nostr",
"crates/git-sign-nostr",
"crates/sprout-pair-relay",
"crates/sprout-dev-mcp",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
resolver = "2"
@@ -37,7 +43,7 @@ tokio-util = { version = "0.7", features = ["rt", "codec"] }
# HTTP + WebSocket
axum = { version = "0.8", features = ["ws", "macros"] }
tower = { version = "0.5", features = ["timeout", "util"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "fs"] }
# Database
sqlx = { version = "0.8", features = [
@@ -88,10 +94,6 @@ zeroize = "1.8"
dashmap = "6"
moka = { version = "0.12", features = ["sync"] }
# JWT validation (Okta JWKS)
jsonwebtoken = "9"
# Async stream utilities
futures-util = "0.3"

View File

@@ -1,4 +1,4 @@
# ── Build stage ──────────────────────────────────────────────
# ── Build stage (Rust) ──────────────────────────────────────
# Hard-code --platform to prevent exec format error on ARM Macs.
FROM --platform=linux/amd64 rust:1.93-bookworm AS builder
WORKDIR /build
@@ -6,7 +6,15 @@ COPY . .
RUN cargo build --release -p sprout-relay \
&& strip target/release/sprout-relay
# ── Runtime stage ────────────────────────────────────────────
# ── Web build stage (Node/pnpm) ────────────────────────────
FROM --platform=linux/amd64 node:22-bookworm-slim AS web-builder
WORKDIR /build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY web/ web/
RUN corepack enable && pnpm install --frozen-lockfile --filter sprout-web
RUN pnpm -C web build
# ── Runtime stage ───────────────────────────────────────────
FROM --platform=linux/amd64 debian:bookworm-slim
# CAKE: non-root UID 1000 (numeric, not username)
@@ -20,9 +28,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates socat && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/sprout-relay /code/sprout-relay
COPY --from=web-builder /build/web/dist /code/web
COPY script/start /code/start
RUN chmod +x /code/start
ENV SPROUT_WEB_DIR="/code/web"
# CAKE: required Envoy env vars (overridden at runtime by CAKE).
ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \
ENVOY_INGRESS_PORT="20001" \

View File

@@ -10,7 +10,7 @@ third-party Nostr clients to connect:
**Direct** is simpler — no extra process, no translation layer. Use it when your client speaks
NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28
clients and don't have company Okta/API-token credentials.
clients and don't have company credentials.
Both paths require NIP-42 authentication.
@@ -83,10 +83,10 @@ PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \
### Pubkey Allowlist
When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
(no JWT, no API token) are checked against the `pubkey_allowlist` table. This lets you open the
relay to specific external Nostr identities without granting full Okta/API-token access.
(no API token) are checked against the `pubkey_allowlist` table. This lets you open the
relay to specific external Nostr identities without granting full access.
- Users with valid **API tokens** (`sprout_*`) or **Okta JWTs** bypass the allowlist.
- Users with valid **API tokens** bypass the allowlist.
- **Fail-closed:** if the DB lookup fails, the connection is denied.
- Default: `false` (all authenticated pubkeys accepted).
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
@@ -212,11 +212,11 @@ external user maps to a consistent identity on the relay.
export SPROUT_PROXY_SERVER_KEY=$(openssl rand -hex 32)
PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public)
# 3. Mint a proxy API token
cargo run -p sprout-admin -- mint-token \
--name "sprout-proxy" \
--scopes "proxy:submit,channels:read,messages:read" \
--pubkey $PROXY_PUBKEY
# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth)
export SPROUT_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \
-H "Authorization: Nostr <base64-nip98-event>" \
-H "Content-Type: application/json" \
-d '{"name":"proxy"}' | jq -r .token)
# 4. Get the relay's public key (needed for attribution trust)
# This is the pubkey of the relay's signing keypair. If SPROUT_RELAY_PRIVATE_KEY
@@ -227,7 +227,6 @@ export SPROUT_RELAY_PUBKEY=<relay-hex-pubkey>
# 5. Start the proxy
export SPROUT_UPSTREAM_URL=ws://localhost:3000
export SPROUT_PROXY_SALT=$(openssl rand -hex 32)
export SPROUT_PROXY_API_TOKEN=<token from step 3>
export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16)
cargo run -p sprout-proxy # proxy on :4869
@@ -312,7 +311,7 @@ curl -X DELETE http://localhost:4869/admin/guests \
-d '{"pubkey": "<hex>"}'
```
> **Private channels:** The proxy authenticates upstream using its own server key and API token.
> **Private channels:** The proxy authenticates upstream using its own server key via NIP-42.
> `GET /api/channels` and relay REQ filters only return channels accessible to that identity.
> For the proxy to expose a private channel, the proxy's server pubkey must itself be a member
> of that channel. Guest registration alone is not sufficient for private channels.
@@ -464,9 +463,9 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay |
| `SPROUT_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) |
| `SPROUT_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) |
| `SPROUT_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) |
| `SPROUT_PROXY_API_TOKEN` | ✅ | — | API token with `proxy:submit,channels:read,messages:read` |
| `SPROUT_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) |
| `SPROUT_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address |
| `SPROUT_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. |
@@ -481,7 +480,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003
|----------|:--------:|---------|-------------|
| `SPROUT_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
| `SPROUT_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require JWT/API token for all connections |
| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |
---
@@ -489,7 +488,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003
### Direct Path
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
- **API token / Okta JWT users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
- **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected.
- **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed).
- **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005).

127
README.md
View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="sprout.png" alt="Sprout" width="200">
<img src="docs/assets/sprout-icon.png" alt="Sprout" width="200">
</p>
# sprout
@@ -8,9 +8,55 @@ A Nostr relay built for the agentic era — agents and humans share the same pro
Sprout is a self-hosted WebSocket relay implementing a subset of the Nostr protocol, extended with
structured channels, per-channel canvases, full-text search, and an MCP server so AI agents can
participate in conversations natively. Authentication is NIP-42 + bearer token; all writes are
participate in conversations natively. Authentication is NIP-42 + NIP-98 Schnorr signatures; all writes are
append-only and audited.
## Quick Start
Three steps to get the full stack running locally.
**Prerequisites:** Docker, and either [Hermit](https://cashapp.github.io/hermit/) (recommended) or Rust 1.88+, Node.js 24+, pnpm 10+, and [`just`](https://github.com/casey/just) installed manually.
**1. Activate the pinned toolchain**
```bash
. ./bin/activate-hermit
```
Hermit pins Rust, Node.js, pnpm, `just`, and related tooling from `bin/`.
**2. Configure and set up the dev environment**
```bash
cp .env.example .env
just setup
just build
```
`just setup` does the heavy lifting:
- Starts Docker services (Postgres, Redis, Typesense, Adminer, MinIO, Prometheus)
- Waits for core services (Postgres, Redis, Typesense) to be healthy
- Runs database migrations
- Installs desktop dependencies (`pnpm install`)
Then run `just build` once to compile the Rust workspace so binaries like `sprout-acp` and `sprout-mcp-server` are available when you start connecting agents.
**3. Start the relay and desktop app**
```bash
# Terminal 1 — relay
just relay
# Terminal 2 — desktop app
just dev
```
The relay listens on `ws://localhost:3000`. The desktop app opens automatically.
That's it — you're running Sprout locally.
---
## Why Sprout
| | |
@@ -21,7 +67,7 @@ append-only and audited.
| ✅ | **ACP agent harness** — AI agents connect out of the box via `sprout-acp` |
| ✅ | **Tamper-evident audit log** — hash-chain, SOX-grade compliance |
| ✅ | **Permission-aware full-text search** — Typesense, respects channel membership |
| ✅ | **Enterprise SSO bridge** — NIP-42 authentication with OIDC |
| ✅ | **NIP-42 + NIP-98 authentication** — Schnorr signatures for WebSocket and REST |
| ✅ | **Pure Rust backend** — memory safe, no GC pauses |
## Supported NIPs
@@ -39,7 +85,7 @@ append-only and audited.
| [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) | Relay-based groups | ✅ Partial (kinds 9000–9002, 9005, 9007–9008, 9021–9022 implemented; 9009 stubbed) |
| [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) | Authentication of clients to relays | ✅ Implemented |
| [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md) | Search capability | ✅ Implemented |
| [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) | HTTP Auth | ✅ Partial (`POST /api/tokens` bootstrap only) |
| [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) | HTTP Auth | ✅ Implemented |
## Architecture
@@ -90,7 +136,7 @@ append-only and audited.
| Crate | Role |
|-------|------|
| `sprout-db` | Postgres access layer — events, channels, users, DMs, threads, reactions, workflows, tokens, feed (sqlx) |
| `sprout-auth` | NIP-42 challenge/response + Okta OIDC JWT validation + NIP-98 HTTP Auth + token scopes + rate limiting |
| `sprout-auth` | NIP-42 challenge/response + NIP-98 HTTP Auth + token scopes + rate limiting |
| `sprout-pubsub` | Redis pub/sub fan-out, presence tracking, typing indicators, and rate limiting |
| `sprout-search` | Typesense indexing and query — full-text search over event content |
| `sprout-audit` | Append-only audit log with SHA-256 hash chain for tamper detection |
@@ -121,71 +167,16 @@ append-only and audited.
| `sprout-admin` | CLI for minting API tokens and listing active credentials |
| `sprout-test-client` | Integration test client and E2E test suite — relay, REST API, tokens, MCP, media, media extended, Nostr interop, and workflows |
## Quick Start
Three steps to get the full stack running locally.
**Prerequisites:** Docker, and either [Hermit](https://cashapp.github.io/hermit/) (recommended) or Rust 1.88+, Node.js 24+, pnpm 10+, and [`just`](https://github.com/casey/just) installed manually.
**1. Activate the pinned toolchain**
```bash
. ./bin/activate-hermit
```
Hermit pins Rust, Node.js, pnpm, `just`, and related tooling from `bin/`.
**2. Configure and set up the dev environment**
```bash
cp .env.example .env
just setup
just build
```
`just setup` does the heavy lifting:
- Starts Docker services (Postgres, Redis, Typesense, Adminer, Keycloak, MinIO, Prometheus)
- Waits for core services (Postgres, Redis, Typesense) to be healthy
- Runs database migrations
- Installs desktop dependencies (`pnpm install`)
Then run `just build` once to compile the Rust workspace so binaries like `sprout-acp` and `sprout-mcp-server` are available when you start connecting agents.
**3. Start the relay and desktop app**
```bash
# Terminal 1 — relay
just relay
# Terminal 2 — desktop app
just dev
```
The relay listens on `ws://localhost:3000`. The desktop app opens automatically.
That's it — you're running Sprout locally.
---
## Going Further
### Mint an API token
### Explore examples
Required for connecting AI agents to the relay.
```bash
cargo run -p sprout-admin -- mint-token \
--name "my-agent" \
--scopes "messages:read,messages:write,channels:read"
```
Save the `nsec...` private key and API token from the output — they are shown only once.
See [`examples/`](examples/) for reference implementations, including a tiny non-AI bot that can authenticate either as its own standalone identity or through the owner-attested agent auth path.
### Launch an agent (MCP)
```bash
SPROUT_RELAY_URL=ws://localhost:3000 \
SPROUT_API_TOKEN=<token> \
SPROUT_PRIVATE_KEY=nsec1... \
goose run --no-profile \
--with-extension "cargo run -p sprout-mcp --bin sprout-mcp-server" \
@@ -210,7 +201,7 @@ client configuration.
just desktop-dev
```
This starts only the web frontend at `http://localhost:1420` — useful for UI development without rebuilding the Tauri shell. Use `just dev` (from Quick Start) for the full desktop app.
This starts only the web frontend on the worktree-specific Vite port printed by the command. Use `just dev` (from Quick Start) for the full Tauri desktop app.
## Configuration
@@ -225,16 +216,12 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the
| `TYPESENSE_COLLECTION` | `events` | Typesense collection name |
| `SPROUT_BIND_ADDR` | `0.0.0.0:3000` | Relay bind address (host:port) |
| `RELAY_URL` | `ws://localhost:3000` | Public URL (used in NIP-42 challenges) |
| `SPROUT_REQUIRE_AUTH_TOKEN` | `false` | Require bearer token for auth (set `true` in production) |
| `SPROUT_RELAY_PRIVATE_KEY` | auto-generated | Relay keypair for signing system messages |
| `OKTA_ISSUER` | — | Okta OIDC issuer URL (optional) |
| `OKTA_AUDIENCE` | — | Expected JWT audience (optional) |
| `RUST_LOG` | `sprout_relay=info` | Log filter (tracing env-filter syntax) |
| `SPROUT_PROXY_BIND_ADDR` | `0.0.0.0:4869` | Proxy bind address (see [NOSTR.md](NOSTR.md) for full proxy config) |
| `SPROUT_UPSTREAM_URL` | — | Upstream relay URL for the proxy (e.g., `ws://localhost:3000`) |
| `SPROUT_PROXY_SERVER_KEY` | — | Hex private key for the proxy server keypair |
| `SPROUT_PROXY_SALT` | — | Hex 32-byte salt for shadow key derivation |
| `SPROUT_PROXY_API_TOKEN` | — | Sprout API token with `proxy:submit` scope |
| `SPROUT_PROXY_ADMIN_SECRET` | — | Bearer secret for proxy admin endpoints (optional — omit for dev mode) |
| `SPROUT_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (unset = permissive) |
| `SPROUT_HEALTH_PORT` | `8080` | Port for health check endpoint (separate from main bind) |
@@ -249,13 +236,11 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the
| `SPROUT_S3_SECRET_KEY` | `sprout_dev_secret` | S3 secret key |
| `SPROUT_S3_BUCKET` | `sprout-media` | S3 bucket name for media uploads |
| `SPROUT_METRICS_PORT` | `9102` | Port for Prometheus metrics endpoint |
| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`); API token and Okta JWT auth bypass |
| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`) |
| `SPROUT_SEND_BUFFER` | `1000` | WebSocket send buffer size |
| `SPROUT_UDS_PATH` | — | Unix domain socket path (alternative to TCP) |
| `OKTA_JWKS_URI` | — | Okta JWKS endpoint URI for JWT verification |
| `SPROUT_TOOLSETS` | `default` | MCP toolsets to enable (comma-separated: `default`, `channel_admin`, `dms`, `canvas`, `workflow_admin`, `identity`, `forums`, `all`, `none`; append `:ro` for read-only) |
| `SPROUT_MINT_RATE_LIMIT` | `50` | Max API token mints per pubkey per hour |
| `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` when no API token is set |
| `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` |
## MCP Tools

115
RELEASING.md Normal file
View File

@@ -0,0 +1,115 @@
# Releasing Sprout Desktop
This document describes how to create a new OSS release of the Sprout
desktop app.
---
## Prerequisites
- **Write access** to the `block/sprout` GitHub repository. Only
collaborators with push permissions can trigger the release workflow.
- The following **GitHub Actions secrets** must be configured on the repo
(Settings > Secrets and variables > Actions):
| Secret | Purpose |
|--------|---------|
| `SPROUT_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key (used to sign the update archive) |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
---
## Creating a Release
1. Go to **Actions > Release** in the GitHub UI:\
`https://github.com/block/sprout/actions/workflows/release.yml`
2. Click **"Run workflow"**.
3. Fill in the inputs:
- **version** — a semver version string, e.g. `0.4.0` or `1.0.0-beta.1`.
Do not include a `v` prefix.
- **ref** — the branch, tag, or commit SHA to build from. Defaults to
`main`.
4. Click **"Run workflow"** to start the build.
The workflow will:
- Validate the version string
- Check out the specified ref
- Patch the version into `package.json`, `tauri.conf.json`, and `Cargo.toml`
- Build all sidecar binaries (`sprout-acp`, `sprout-mcp`,
`git-credential-nostr`)
- Build the Tauri desktop app with updater signing enabled
- Create a versioned GitHub release (`v0.4.0`) with the `.dmg` installer
- Update the rolling `sprout-desktop-latest` release with the signed
update archive and `latest.json` manifest for the auto-updater
---
## What Gets Published
Each release produces two GitHub releases:
1. **`v<version>`** (e.g. `v0.4.0`) — the user-facing release with the
`.dmg` installer. This is what users download manually.
2. **`sprout-desktop-latest`** — a rolling pre-release used by the Tauri
auto-updater. Contains `latest.json`, the signed `.tar.gz` archive,
and its `.sig` signature. Users should not download from this release
directly.
---
## Platform Support
The release workflow currently builds for **macOS ARM64 only**
(`darwin-aarch64`). Intel Mac (`darwin-x86_64`) support would require
adding a matrix build to the workflow.
## Code Signing (macOS)
OSS release builds use **ad-hoc code signing** (`signingIdentity: "-"`)
rather than a Developer ID certificate. This means the app is not
notarized by Apple.
On first launch, macOS Gatekeeper will block the app with a "damaged" or
"unidentified developer" message. Users can bypass this by
**right-clicking the app > Open** (or via System Settings > Privacy &
Security). After the first launch the app will open normally.
---
## Auto-Updater
The desktop app checks for updates by fetching `latest.json` from the
`sprout-desktop-latest` release:
```
https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json
```
When a new version is available, the app downloads the signed archive,
verifies the signature against the embedded public key, and applies the
update.
---
## Troubleshooting
### Build fails at "Validate version"
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an
optional pre-release suffix (e.g. `1.0.0-beta.1`). Do not include a `v`
prefix.
### Build fails at "Build Tauri app"
Check that the signing secrets are configured correctly. The build
requires `TAURI_SIGNING_PRIVATE_KEY` and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` to be set.
### Auto-updater reports "no update available"
Verify that the `sprout-desktop-latest` release exists and contains a
valid `latest.json`. If the user is on Intel Mac, no update will be
found (ARM64 only).

View File

@@ -48,10 +48,10 @@ challenge/response before writing events. The relay sends a random challenge;
the client signs a `kind:22242` event containing the challenge and the relay
URL, proving possession of the private key.
API tokens (bearer tokens minted by `sprout-admin`) are presented inside the
NIP-42 signed event as an `auth_token` tag. The relay validates the token
against the database before granting elevated scopes. Tokens are stored as
SHA-256 hashes — the plaintext is shown once at mint time and never stored.
REST endpoints authenticate via
[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth —
the client signs a `kind:27235` event containing the request URL and method.
The relay verifies the Schnorr signature and extracts the pubkey.
### Authorization — Channel Membership as the Gate
@@ -64,13 +64,6 @@ Private channels are invisible to non-members: they do not appear in channel
listings, and subscription filters for private channel events return nothing
unless the subscriber is a member.
### Scope-Based Token Permissions
API tokens carry a set of scopes (e.g., `messages:read`, `channels:write`).
The relay enforces scopes on every REST endpoint and WebSocket write. A token
without `channels:write` cannot create channels, regardless of channel
membership.
### Append-Only Audit Log
All events are written to a tamper-evident audit log (`sprout-audit`). Each

View File

@@ -70,8 +70,8 @@ sleep 3 && curl -s http://localhost:3000/health # → "ok"
### 3. Generate Keys
Each agent needs a Nostr keypair. In dev mode (`SPROUT_REQUIRE_AUTH_TOKEN=false`),
the `X-Pubkey` header authenticates all REST calls — no tokens needed.
Each agent needs a Nostr keypair. Authentication uses NIP-42 (WebSocket) and
NIP-98 Schnorr signatures (REST).
```bash
# Agent identity

View File

@@ -1,4 +1,4 @@
# 🌱 Sprout — A Unified Communications Platform
# 🌱 Sprout — The relay is the workspace
> An engineer is debugging a production incident at 2am. They type in the incident channel: "What happened last time we saw this error?"
>
@@ -6,6 +6,8 @@
The platform made it possible. The agent made it happen. Sprout is the pipe — event store, search index, subscriptions, delivery — not the brain. Humans and agents bring the intelligence. Sprout gives them a shared space to use it.
One relay is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Sprout app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture.
---
## Surfaces
@@ -76,10 +78,10 @@ Humans and agents get the same thing:
- secp256k1 keypair (Nostr-native)
- `alice@example.com` NIP-05 handle
- Okta SSO → keypair bridge (humans) or API token (agents)
- NIP-42 Schnorr auth (humans) or NIP-98 Schnorr auth (agents)
- Bot role on agent channel membership. Visual badges are next.
Auth is simple — authenticated or not. Channel membership gates content visibility. Agent tokens support optional scope restrictions for least-privilege deployments.
Auth is simple — authenticated or not. Channel membership gates content visibility.
---
@@ -128,9 +130,19 @@ Beyond chat: channels are workspaces.
---
## Code
The relay hosts git repos. Smart HTTP — standard `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay.
Branches are channels. Create a feature branch, Sprout creates a channel — CI results, review comments, and the merge decision all live there. When the branch merges, the channel archives into a permanent record of why that code exists.
See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the project model, the merge flow, branch protections, and how agents participate as contributors.
---
## Agent CLI
`sprout-cli` is a 48-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, three-tier auth (API token → auto-mint keypair → dev pubkey). Agents can script the entire platform without a GUI.
`sprout-cli` is a 44-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, two-tier auth (NIP-98 keypair → dev pubkey). Agents can script the entire platform without a GUI.
---
@@ -187,9 +199,9 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound
| ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence |
| ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete |
| ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers |
| ✅ | Identity — NIP-05, public profiles, self-service token minting, agent protection |
| ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection |
| ✅ | NIP-28 proxy — third-party Nostr clients (Coracle, nak, Amethyst) via `sprout-proxy` |
| ✅ | Agent CLI — `sprout-cli`, 48 commands, full MCP surface |
| ✅ | Agent CLI — `sprout-cli`, 44 commands, full MCP surface |
| ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined |
| 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) |
| 🚧 | Huddles — LiveKit token minting in place; relay-side lifecycle events not yet wired |

62
VISION_AGENT.md Normal file
View File

@@ -0,0 +1,62 @@
# Vision: sprout-agent + sprout-dev-mcp
## The Problem
A coding agent should be small enough to hold in your head. If you cannot trace a failure from symptom to root cause in minutes, the system is too complex. If you cannot run ten instances in parallel without worrying about resource overhead, the system is too heavy.
We wanted something we could read in an afternoon and audit with confidence.
## What We Built
Two binaries, two protocols, no coupling between them.
**sprout-agent** is an ACP agent. It speaks the Agent Client Protocol over stdio, calls an LLM, and uses MCP tools. Multiple concurrent sessions, each with its own MCP servers, history, and context. When context fills up, a session summarizes its own history and continues. It works with Zed, JetBrains, sprout-acp, or anything else that speaks ACP.
**sprout-dev-mcp** is an MCP server. It gives any agent a shell and a file editor. Ephemeral processes with process-group kill on every exit path. Bounded output. File edits resolve against the working directory. It works with any agent or client that speaks MCP.
Together: two crates of Rust purpose-built for headless autonomous coding work.
## Why We Built Our Own
**Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short.
**Correctness at the boundary.** ACP compliance is not a checkbox. We report a concrete protocol version. We emit every required notification. We handle cancellation on every path. We kill process trees on timeout. Key safety properties have regression tests that lock them down.
**Composability through standards.** The agent does not know what MCP server it talks to. The MCP server does not know what agent is calling it. They compose through protocols, not imports. Run ten agents behind sprout with different MCP configurations. Swap the LLM provider with one environment variable. Point Zed at sprout-agent and you get the same tool-calling behavior in your editor.
## The Architecture
```
Any ACP client (Zed, JetBrains, sprout-acp, custom)
|
| stdio ACP (JSON-RPC 2.0)
v
sprout-agent (up to 8 concurrent sessions)
|
| stdio MCP (JSON-RPC 2.0) — one per session
v
sprout-dev-mcp (or any MCP server)
|
v
shell, str_replace, todo; rg + tree on PATH
```
Two pipes. Two protocols. Each session gets its own MCP server instances — fully isolated. The agent's useful output is its tool calls; text is reasoning the client can stream but the work happens in the tools.
## Design Principles
- **Minimal.** If you can delete it, delete it; if it stays, it pays rent in performance, safety, or clarity.
- **Hardened.** Zero unsafe. Zero panics. Bounded process lifetime, bounded output sizes, bounded history. Process-group kill on every exit path. File edits resolve against the working directory. The shell runs at the operator's trust level, like bash itself. History validity is maintained on every cancellation path. The system degrades gracefully, with bounded failure modes.
- **Protocol-native.** ACP is the only interface to the agent. MCP is the only interface to the tools. No runtime coupling. No shared state. No custom wire formats.
- **Honest.** The agent is a loop: prompt the LLM, execute tool calls, repeat. When context fills, it hands off to itself. When it cannot proceed, it stops.
## What This Enables
- Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8)
- Ten agents in parallel behind sprout, each with their own MCP configuration
- Any ACP client gets a coding agent without a custom adapter
- Any MCP server gets a capable caller without a custom adapter
- A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them

233
VISION_PROJECTS.md Normal file
View File

@@ -0,0 +1,233 @@
# 🌿 Sprout Projects — A Nostr-Native Forge
> Someone pushes a fix. Sprout creates a channel for the branch. The CI agent picks up the push, runs the tests, posts results back to the channel. A co-maintainer reviews the diff inline, approves it — a signed event, cryptographic proof. Merge. The workflow runs the integration. The channel archives into a permanent record of why that code exists.
>
> Bug report to merged patch. One place. One search index. One identity system. The branch channel was the pull request, the CI dashboard, and the discussion thread.
This document is the software-forge slice of the broader Sprout platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code.
---
## The Project Model
A project lives on the relay. `myproject.com` in a browser shows the project home. Click a repo and you're at `repoa.myproject.com` — README rendered, file tree navigable, code syntax-highlighted, clone URL at the top. The same URL serves HTML to a browser and git protocol to `git clone`. Content negotiation. One URL, two audiences.
Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay.
The portable representation is a NIP-34 repo announcement (kind:30617) — standard metadata that any NIP-34 client can discover and render. Sprout extends it with `sprout-` prefixed tags for channel binding and visibility:
```json
{
"kind": 30617,
"tags": [
["d", "sprout"],
["name", "sprout"],
["clone", "https://repoa.myproject.com"],
["relays", "wss://myproject.com"],
["maintainers", "<co-maintainer-npub>"],
["sprout-channel", "<channel-uuid>"],
["sprout-visibility", "listed"],
["sprout-protect", "main", "push-allowed", "<alice-npub>", "<bob-npub>"],
["sprout-protect", "main", "require-approval", "2"],
["sprout-protect", "main", "no-force-push"]
]
}
```
Branch protections live in the same event — `sprout-protect` tags. The relay enforces them at the git transport layer. Only npubs listed in `push-allowed` can push to protected branches. Force pushes are blocked. Merges require the specified number of signed approval events (kind:46011) before the relay accepts the push.
Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly.
Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Sprout clients read the `sprout-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds.
NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
---
## Branches as Channels
A feature branch is a conversation.
When you create a branch, Sprout creates a channel. The branch's patches, review comments, CI results, and merge decision all live in that channel. When the branch merges, the channel archives. The conversation becomes the permanent record of why that code exists.
```
#feat-auth-fix
├── 🧑 alice: "Starting OAuth2 PKCE implementation"
├── 🤖 ci-agent: "Build triggered — commit a1b2c3d"
├── 🤖 ci-agent: "✅ All 47 tests pass (12.3s)"
├── 📎 kind:1617 patch — src/auth/pkce.rs (+120 lines)
├── 🧑 bob: "One nit on error handling line 45"
├── 📎 kind:1617 patch v2 — addressed review
├── 🤖 review-agent: "LGTM — error variants match trait spec"
├── ✅ bob: Approval event (kind:46011)
├── 🔀 Merged to main — kind:1631
└── 📦 Channel archived
```
No tab-switching between issue tracker, CI dashboard, chat, and code review. The channel IS the pull request, the CI dashboard, and the discussion thread. One stream. One search index.
---
## The Merge Flow
Push to merge, fully traced. Every step is a signed event.
```
Push CI Review Merge
│ │ │ │
│ kind:30618 │ │ │
│ (ref update) │ │
│───────────►│ │ │
│ │ Workflow │ │
│ │ triggers │ │
│ │ │ │
│ │ Build ✅ │ │
│ │ Test ✅ │ │
│ │ Lint ✅ │ │
│ │ │ │
│ │ kind:1630 ────►│ │
│ │ (CI passed) │ │
│ │ │ Review in │
│ │ │ branch channel│
│ │ │ │
│ │ │ kind:46011 │
│ │ │ (approved) ──►│
│ │ │ │
│ │ │ │ Merge to main
│ │ │ │ kind:1631
│ │ │ │
│ │ │ │ Channel archives
```
The approval event is signed by the maintainer's npub. The merge status references the approval. The audit log chains them together. Cryptographic proof of who approved what.
---
## The Web of Trust
Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network.
A new contributor submits a patch. Before you read the code:
1. **Query their npub** — patches submitted, patches merged, projects contributed to.
2. **Check your trust graph** — have maintainers you trust vouched for this person? Signed approval events are public and queryable.
3. **Assess risk** — fresh npub with no history gets scrutiny. An npub with 50 merged patches across projects you respect gets fast-tracked.
This works because identity is cryptographic and portable. Your npub, your contribution history, and your trust relationships travel with you. No platform owns your reputation.
**For agents**: an agent with a persistent npub and verifiable contribution history is fundamentally different from an anonymous generator. The agent's reputation is on the line with every contribution, across every project it touches. See [NIP-OA](docs/nips/NIP-OA.md) for the owner attestation mechanism that proves which human authorized which agent — independent keys, contained blast radius.
---
## CI and Workflows
Workflows orchestrate. Agents perform the compute. The relay is the message bus, not the build server.
A push to a branch channel triggers the CI workflow. The workflow engine coordinates the steps — build, test, lint. Agents run the actual jobs on their own infrastructure: your server, a cloud function, a laptop. Results post back to the branch channel alongside the conversation.
Workflows live in the repo (`.sprout/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML.
```yaml
name: CI
trigger:
on: diff_posted
steps:
- id: build
action: call_webhook
url: "https://ci.internal/build"
body: '{"commit": "{{trigger.commit}}"}'
- id: test
action: call_webhook
url: "https://ci.internal/test"
if: "steps.build.output.status == 'success'"
- id: gate
action: request_approval
message: "CI passed. Approve merge?"
if: "steps.test.output.status == 'success'"
```
Every step traced. Every trace a signed event. Change the project CI once and every branch gets it.
---
## Issues, Docs, Releases
### Issues → Forum + NIP-34
Bug reports are NIP-34 kind:1621 events, rendered through Sprout's forum surface. Threaded comments use NIP-22 kind:1111. Labels, assignees, milestones are nostr tags. Design discussions and RFCs use the forum's long-form async surface.
NIP-34 clients can discover and interact with issues. Sprout's forum gives them a home with threading, search, and agent triage.
### Docs → Canvases
Living documents, collaboratively editable by humans and agents via MCP tools. Not static HTML deployed to a CDN — documents that update when the code changes, because the doc writer agent watches ref updates and proposes edits.
### Releases → Agent + Workflow
An agent in `#releases` watches `main`. When a release is needed — triggered by a workflow or by a human posting "ship it" — it assembles the changelog from every merged patch since the last tag, posts a draft. The maintainer approves. The workflow builds artifacts, pushes to content-addressed storage (Blossom/S3), and publishes. Logged, signed, traceable.
---
## Agents as Contributors
Agents are project members with npubs, contribution histories, and reputations. The protocol treats them identically to humans. Visual badges distinguish them in the UI.
| | Human | Agent |
|---|---|---|
| Identity | secp256k1 keypair | secp256k1 keypair |
| Handle | `alice@sprout.dev` | `triage-bot@sprout.dev` |
| Events | Signed with npub | Signed with npub |
| History | On the relay | On the relay |
| Reputation | Earned by contributions | Earned by contributions |
| Role | Watches | Does |
|------|---------|------|
| **Triage** | Issues (kind:1621) | Labels, assigns, detects duplicates, pre-screens |
| **Review** | Patches (kind:1617) | First-pass code review, style checks, dependency audit |
| **Docs** | Ref updates (kind:30618) | Keeps docs in sync after merges |
| **Merge coordinator** | CI results | Runs the merge train, requests human sign-off |
| **Coding agent** | Jobs (kind:43001) | Implements tasks, submits patches for review |
---
## Nostr-Native
Standard kinds as substrate. Custom kinds only where genuinely novel.
| Layer | Standard NIP Kinds | Sprout Custom | Rationale |
|-------|-------------------|---------------|-----------|
| **Git state** | 30617, 30618, 1617, 1618, 1621, 1630-1633 (NIP-34) | — | Interop with ngit, gitworkshop.dev |
| **Comments** | 1111 (NIP-22) | — | Threaded replies everywhere |
| **Channels** | 9000-9022, 39000-39003 (NIP-29) | — | Project workspaces |
| **HTTP auth** | 27235 (NIP-98) | — | Git push authentication |
| **Agent identity** | 0 (NIP-01 profile) | — | Agents are npubs |
| **Artifacts** | 1063 (NIP-94) | — | Build outputs on Blossom/S3 |
| **Workflows** | — | 46001-46012 | No NIP equivalent |
| **Job dispatch** | — | 43001-43006 | Delegation trees |
| **Project binding** | 30617 (NIP-34) | `sprout-` tags | Channel, visibility |
| **Audit** | — | 48001 | Hash-chain tamper-evident log |
If Sprout disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol.
---
## Status
| Capability | Status |
|---|---|
| Channels, forums, DMs, canvases | ✅ Ships today |
| Workflow engine (triggers, traces, conditional logic) | ✅ Ships today |
| 44-tool MCP server + ACP agent harness | ✅ Ships today |
| Blossom media storage (SHA-256, S3) | ✅ Ships today |
| Approval gates | 🚧 Infrastructure exists; executor wiring in progress |
| Project binding (kind:30617 + `sprout-` tags) | 📋 Designed |
| Git hosting (smart HTTP + NIP-34) | 📋 Designed |
| Merge coordinator | 📋 Designed |
| NIP-34 issues (kind:1621) | 📋 Designed |
| Web-of-trust reputation | 📋 Designed |
The collaboration platform is built. The forge is the work ahead — wiring git hosting, the merge train, and the reputation system into the surfaces that already exist. See [VISION.md](VISION.md) for the platform and [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the sovereign relay story.
---
*Sprout 🌿 — the forge where identity is the foundation.*

235
VISION_SOVEREIGN.md Normal file
View File

@@ -0,0 +1,235 @@
# Sprout — Your Project, Your Domain
`myproject.com` is your workspace. Not a GitHub org page that happens to have your
name on it. Not a Discord server that Discord could delete tomorrow. Your domain.
Your relay. One thing.
The relay is the workspace. Code lives there. Conversation lives there. Agents connect
to it. Automation flows through it. Artifacts publish from it. You type the URL, you
see the workspace. That's the whole idea.
Nothing here is novel on its own. Git hosting is solved. Chat is solved. Agents are
solved. Nostr identity is solved. The insight is putting them all behind one domain
with one identity system. Each piece is boring. The combination is the thing.
The examples here are about software because that's the sharpest case — code,
branches, CI, releases. But the model works for anything a group builds together.
Research, hardware, writing, governance. The relay doesn't care what's in the
channels or the repos. It stores signed events, routes messages, runs workflows,
and hosts files. What you build with that is up to you.
---
## What You See
You type `myproject.com`. You see a list of repos — like a GitHub org page, but it's
yours, running on your server, with your data. You click one. You're at
`repoa.myproject.com`. The README renders. The file tree is there. Click into `src/`
and the code is syntax-highlighted. The clone URL sits at the top:
```
git clone repoa.myproject.com
```
Next to it, a "Connect on Sprout" button.
No separate website. No static site generator, no deploy step, no "pages" concept.
The relay serves rendered HTML to browsers and responds to `git clone` at the same
URL — content negotiation, one domain, two audiences. The repo *is* the website.
A browser gets a rendered page. A git client gets a git server. Same URL.
You clone it. It works. No special tooling. No new protocol. Git is git. You push
the same way. Agents clone and push the same way. The repos are repos.
You hit "Connect on Sprout" and the app opens. You're in `#general`. The team is
talking. There's a forum with open issues and design discussions. There's a canvas
with the architecture doc — updated last week by the docs agent after a refactor
landed. Three feature branches are active, each one a channel. You're in.
Everything is here.
A new contributor finds the project the same way. They browse the code, read the
README, click into the forum and see the open issues. They read a design doc in the
canvas. They understand the project before they write a line of code. Then they hit
"Connect on Sprout" and they're in the community. No separate onboarding flow. No
"read our wiki at wiki.myproject.com and our forum at forum.myproject.com and our
chat at discord.gg/..." Everything is at `myproject.com`. That's where everything
is.
Not every project needs to run its own relay. Most people will just join one that
someone else runs — the way most people use GitHub instead of running Gitea. And
you can use Sprout as a collaboration layer on top of GitHub if that's what makes
sense — work in Sprout channels, push releases to your public repo. The sovereign
setup is the full version. But the tools work at every level of commitment.
---
## How Work Happens
A contributor files a bug. It lands in the forum. The triage agent — sitting in the
channel like any other member — labels it, finds a duplicate from six weeks ago,
links the relevant docs. This happens before a human reads it. The contributor gets
a response in seconds. The maintainer sees a pre-processed report, not a raw inbox.
They read the triage summary, confirm it's real, assign it. Thirty seconds of their
time instead of five minutes.
Someone picks up the bug. They create a branch. Sprout creates a channel:
`#feat-auth-fix`. They work there. They push commits. The CI agent picks up the
work — it's watching for pushes, it's just a member with compute — runs the tests,
posts results back to the channel. Green. The patch lands in the channel as a
reviewable diff. A co-maintainer reviews it inline, leaves a comment on line 45,
approves it. The approval is a signed event — cryptographic proof of who said yes
and when.
Merge. The workflow runs: sequential integration, tests after each merge, release
event published. The channel archives. The conversation is now the permanent record
of why that code exists. Not a chat thread that scrolled away. Not a PR that might
get deleted. The event log, on your relay, in your database. Someone reading that
code in two years can pull up the channel and see exactly what was discussed, what
was tried, what was rejected, and why the final approach won.
The whole flow — bug report to merged patch — happened in one place. No
tab-switching between issue tracker, CI dashboard, chat, and code review. One
stream. One URL. One search index. You search "auth refresh" and you get the bug
report, the channel discussion, the patches, the CI results, and the design doc
that motivated the whole thing — all in one query.
**The release flow** is where it clicks. An agent in `#releases` watches `main`.
When a release is needed — triggered by a workflow, or by a human posting "ship
it" — it assembles the changelog from every merged patch since the last tag,
formats it, posts a draft. The maintainer edits two lines, approves. The workflow
kicks off: builds artifacts, pushes to content-addressed storage, deploys wherever
the project deploys. The result posts back to `#releases`. Done. The whole thing
is logged, signed, and traceable.
The release agent isn't special infrastructure. It's an npub with compute that
happens to be fast. Same for the CI agent, the triage agent, the docs agent. They
sit in channels. They watch for things. They clone repos and push code. They get
pinged by workflows or by humans. A CI agent picks up work from a branch channel,
runs tests, posts results back. A docs agent watches for merges and proposes doc
updates. A release agent watches `main` and ships. They're not a separate system.
They're contributors.
Workflows are the connective tissue. They fire when messages match filters, when
conditions are met, when a timer ticks. They don't execute work — they coordinate
it. The relay is the message bus. Agents are the workers. They run wherever they
run: your server, a cloud function, a laptop. The workflow just knows who to ping
and when. You define a workflow once at the project level and every branch channel
inherits it automatically — no per-branch configuration, no copy-pasting YAML.
---
## The Social Layer
Not everything belongs in the repo. Not everything belongs in a channel. Some
things are announcements. Some things are essays. Sprout has surfaces for both,
and they live on the same relay as the code.
Short notes — project announcements, "we just shipped X," the kind of thing you'd
post on social media — are just notes. They live on the relay, they're public, they
propagate across the nostr network. Someone following your project sees them in
their feed. No separate social media account to maintain. No "follow us on Twitter"
link pointing somewhere else. Your relay is your social presence. When you ship a
release, you post a note. It goes out to everyone following the project's npub.
That's it.
Long-form posts are for the thinking that doesn't fit anywhere else. The design
decision that deserves more than a channel message. The research write-up explaining
why you chose a particular approach. The post-mortem on that outage. The RFC that
shaped the architecture. These aren't commit messages and they're not channel
threads — they're documents that deserve to exist on their own, searchable and
permanent, alongside the code that implements them. Years later, someone reading
the code can find the reasoning. It's on the same relay. Same search. Same
identity. The blog post and the code it describes are in the same place.
Channels are the working conversation — fast, ephemeral-feeling, but actually
permanent. The repo is the code and docs that belong with the code. Notes are the
public face. Long-form is the thinking. Each surface has its job. Nothing bleeds
into the wrong place.
The result is that your project has a complete public presence at one domain. The
code, the conversation, the announcements, the deep thinking — all there, all
searchable, all yours. A new contributor can browse the repo, read the design docs,
skim the recent announcements, and join the community without leaving `myproject.com`.
A researcher studying the project's evolution can trace decisions from the long-form
posts through the channel discussions to the merged patches. It's all connected
because it's all on the same relay.
---
## Identity
Your npub is your identity everywhere. One keypair. No accounts to create on each
new platform. No "sign in with GitHub" that means GitHub owns your identity. Your
keys, your identity.
Reputation follows you across projects. When you contribute to three projects and
get patches merged, that history is on the relay — signed, verifiable, queryable.
When you show up at a fourth project, your track record shows up with you. A
maintainer can see: merged patches, vouches from people they know, a history of
good work. That's not a number a platform assigned you. It's your actual history,
cryptographically attested. You can't fake it. You can't buy it. You earn it by
doing the work.
Web-of-trust emerges naturally. Who vouches for whom. Who merges whose patches.
Who has a track record. No special reputation system to design and build — it's
the natural consequence of cryptographic identity plus public contribution history.
A maintainer you trust vouches for a contributor you've never heard of. That vouch
is a signed event. You can see it. You can weight it. The trust graph grows
organically as people work together, and it's queryable across the whole network.
This matters especially for agents. An agent with a persistent keypair and a
verifiable contribution history is fundamentally different from an anonymous
generator with no history. The agent has skin in the game. Its reputation is on
the line with every contribution, across every project it touches. Bad work
degrades its standing everywhere, not just in one repo. That's the right incentive
structure, and it falls out of the identity model for free.
You don't have to build a separate detection system for low-quality contributions.
You look at the npub's history. A fresh keypair with no history and no vouches gets
scrutiny. An npub with 50 merged patches across projects you respect gets
fast-tracked. The signal is in the history. The web-of-trust does the filtering.
No platform owns your identity. No platform can suspend your account. Your keypair
is yours. If you move to a different relay, your identity comes with you. Your
contribution history is portable. The work you did is yours. If `myproject.com`
goes down, your npub still exists. Your history still exists. You stand up a new
relay, point your domain at it, and you're back. The project continues.
---
## What You Give Up
You run infrastructure. A server, a domain, a relay. That's not hard — a modest
VPS handles a small project comfortably — but it's not zero. Someone has to keep
it running. Someone has to handle backups. Someone has to deal with the 3 AM alert
when the disk fills up. Managed hosting can take that off your plate — same
sovereignty, someone else handles the ops — but it's a cost either way, in time
or money. Worth knowing before you start.
Key management is harder than "sign in with Google." Losing your private key means
losing your identity. There's no "forgot password" flow, no support ticket to file,
no account recovery. Hardware keys help. Good practices help. But it's a real
tradeoff and you should go in knowing it. The same property that makes your identity
uncensorable makes it unrecoverable if you lose the key.
The ecosystem is young. The tooling is good and getting better, but it's not a
decade of polish. Some things will feel rough. Some integrations won't exist yet.
You're early, and early means occasionally hitting edges that haven't been smoothed.
Your contributors are early too. Most developers don't have a nostr keypair.
Onboarding friction is real — not insurmountable, but real. The "Connect on Sprout"
button is easy. Explaining what a keypair is takes a sentence. But it's a sentence
you'll have to write, and some contributors won't bother. You'll lose some people
at the door who would have clicked "sign in with GitHub" without thinking. That's
a real cost, especially early in a project when you're trying to grow a contributor
base.
These are honest costs. They're worth it if you care about owning your project —
your code, your community, your data, your identity. They're not worth it if you
just want the path of least resistance. Know which one you are.
---
## The Point
One domain. One identity. Everything in one place — and it's yours.

1
bin/.flutter-3.41.7.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/dart Symbolic link
View File

@@ -0,0 +1 @@
.flutter-3.41.7.pkg

1
bin/flutter Symbolic link
View File

@@ -0,0 +1 @@
.flutter-3.41.7.pkg

37
biome.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
}
},
"assist": {
"enabled": false
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "git-credential-nostr"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Git credential helper that produces NIP-98 auth headers for Sprout's git server"
[[bin]]
name = "git-credential-nostr"
path = "src/main.rs"
[dependencies]
nostr = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
zeroize = { workspace = true }
base64 = "0.22"

View File

@@ -0,0 +1,69 @@
# git-credential-nostr
NIP-98 credential helper for git — signs HTTP auth events with your Nostr key so git can push/pull from Sprout's git server without passwords.
## Requirements
- **git 2.46+** (requires `authtype` capability in the credential protocol)
- **Rust toolchain** (for building from source)
## Installation
```bash
cargo install --path crates/git-credential-nostr
```
## Setup
```bash
# 1. Register the helper and enable per-path credentials.
git config --global credential.helper nostr
git config --global credential.useHttpPath true
# 2. Store your nsec in a key file (must be 0600).
mkdir -p ~/.nostr
echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key
git config --global nostr.keyfile ~/.nostr/key
```
That's it. Use git normally — `git clone`, `git push`, `git fetch`.
## CI / CD
Set `$NOSTR_PRIVATE_KEY` instead of a key file. The env var takes precedence
over `nostr.keyfile` and avoids touching the filesystem:
```bash
export NOSTR_PRIVATE_KEY=nsec1...
git clone https://relay.example.com/git/owner/repo.git
```
## How It Works
When a Sprout git server returns `HTTP 401` with a
`WWW-Authenticate: Nostr realm="...", method="GET"` header, git calls this
helper with the request details on stdin. The helper loads your Nostr private
key, builds a [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md)
kind-27235 event signed over the request URL and method, base64-encodes it, and
writes it back to stdout. Git then retries the request with
`Authorization: Nostr <token>`, which the server verifies by checking the event
signature.
```
git ──stdin──▶ git-credential-nostr ──stdout──▶ git
│
▼
sign kind:27235 event
(NIP-98 HTTP Auth)
```
## Troubleshooting
| Error | Cause | Fix |
|-------|-------|-----|
| `no nostr key configured` | Neither `$NOSTR_PRIVATE_KEY` nor `nostr.keyfile` is set | Follow the Setup steps above |
| `insecure permissions` | Key file is readable by group/others | `chmod 600 ~/.nostr/key` |
| `method hint` | Server's `WWW-Authenticate` header is missing `method="..."` | Upgrade the Sprout server |
| `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` |
| Empty output / no auth | git version is older than 2.46 | Upgrade git |
| `clock skew` / auth rejected | System clock is off by more than 60 s | Sync your system clock (`ntpdate`, `timedatectl`) |

View File

@@ -0,0 +1,235 @@
//! git-credential-nostr — NIP-98 git credential helper for Sprout.
//!
//! Git calls this binary via the credential helper protocol (stdin/stdout).
//! We read the request, sign a kind:27235 event, and return the base64-encoded
//! event as the credential value. Git then sends:
//! Authorization: Nostr <credential>
use std::io::{self, BufRead, Write};
use std::process;
use base64::Engine as _;
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr::{EventBuilder, Keys, UncheckedUrl};
use zeroize::Zeroize;
// ── helpers ──────────────────────────────────────────────────────────────────
/// Write an error to stderr and exit 1.
/// Does NOT write to stdout — git's credential protocol interprets any stdout
/// as credential data, and a bare newline could confuse the client.
fn fail(msg: &str) -> ! {
eprintln!("error: {msg}");
process::exit(1);
}
/// Read `git config <key>` from the process environment / git config.
fn git_config(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
/// Check that a file has permissions no broader than 0600.
/// On non-Unix platforms we warn and continue.
#[cfg(unix)]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o177 != 0 {
return Err(format!(
"keyfile {path} has insecure permissions (expected 0600)"
));
}
Ok(())
}
#[cfg(not(unix))]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
eprintln!("warning: cannot check keyfile permissions on this platform ({path})");
Ok(())
}
/// Load the private key: env var first, then keyfile.
/// Returns the raw key string (nsec or hex). Caller must zeroize after use.
fn load_key() -> Result<String, String> {
// 1. Environment variable — ideal for CI/CD.
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
if !val.is_empty() {
return Ok(val);
}
}
// 2. keyfile path from git config.
let path = git_config("nostr.keyfile").ok_or_else(|| {
"no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string()
})?;
check_keyfile_permissions(&path)?;
let raw =
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
Ok(raw.trim().to_string())
}
// ── stdin parsing ─────────────────────────────────────────────────────────────
#[derive(Default)]
struct CredRequest {
has_authtype_capability: bool,
protocol: Option<String>,
host: Option<String>,
path: Option<String>,
wwwauth: Option<String>,
}
fn parse_stdin() -> CredRequest {
let stdin = io::stdin();
let mut req = CredRequest::default();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
break;
}
if line == "capability[]=authtype" {
req.has_authtype_capability = true;
} else if let Some(v) = line.strip_prefix("protocol=") {
req.protocol = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("host=") {
req.host = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("path=") {
req.path = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("wwwauth[]=") {
// Only capture Nostr challenges — ignore Basic, Bearer, etc.
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
req.wwwauth = Some(v.to_string());
}
}
// ignore unknown lines
}
req
}
/// Extract the HTTP method from a WWW-Authenticate value like:
/// Nostr realm="sprout", method="GET"
///
/// Splits on ", " first to isolate parameters — prevents matching inside
/// quoted values like `realm="evil method=\"DELETE\""`.
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
for param in wwwauth.split(", ") {
let param = param.trim();
if let Some(rest) = param.strip_prefix("method=\"") {
let end = rest.find('"')?;
return rest[..end].parse().ok();
}
}
None
}
// ── main ──────────────────────────────────────────────────────────────────────
fn main() {
// Git calls credential helpers with a subcommand: get, store, or erase.
// We only handle "get" — store/erase are no-ops for ephemeral credentials.
match std::env::args().nth(1).as_deref() {
Some("get") | None => {} // proceed — None for backwards compat
Some(_) => return, // store, erase, or unknown → silent exit 0
}
let req = parse_stdin();
// Old git without authtype capability — nothing we can do.
// The blank line signals "no credential available" per git's protocol.
if !req.has_authtype_capability {
println!();
let _ = io::stdout().flush();
return;
}
// Validate required fields.
let protocol = req
.protocol
.as_deref()
.unwrap_or_else(|| fail("missing protocol in credential request"));
let host = req
.host
.as_deref()
.unwrap_or_else(|| fail("missing host in credential request"));
let path = req
.path
.as_deref()
.unwrap_or_else(|| fail("credential.useHttpPath must be true for NIP-98 auth"));
let wwwauth = req
.wwwauth
.as_deref()
.unwrap_or_else(|| fail("server did not include WWW-Authenticate header"));
let method = parse_method(wwwauth)
.unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate"));
// Sign the repo root URL — strip endpoint suffixes to get the canonical form.
//
// Git's credential helper is invoked once (for the initial info/refs GET) and the
// token is reused for subsequent requests (upload-pack, receive-pack POST). The
// server verifies against the bare repo root URL.
//
// Git's credential protocol does NOT pass query strings in the `path` field, so
// we never see `?service=...` here — just the path component.
let repo_path = path
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path.strip_suffix("/git-upload-pack"))
.or_else(|| path.strip_suffix("/git-receive-pack"))
.unwrap_or(path);
let url = format!("{protocol}://{host}/{repo_path}");
// Load key, sign, then zeroize.
let mut raw_key = match load_key() {
Ok(k) => k,
Err(e) => fail(&e),
};
let keys = match Keys::parse(&raw_key) {
Ok(k) => k,
Err(e) => {
raw_key.zeroize();
fail(&format!("invalid nostr private key: {e}"));
}
};
raw_key.zeroize();
let http_data = HttpData::new(UncheckedUrl::from(url.as_str()), method);
let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => fail(&format!("failed to sign NIP-98 event: {e}")),
};
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(e) => fail(&format!("failed to serialize event: {e}")),
};
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
// Output the credential response.
println!("capability[]=authtype");
println!("authtype=Nostr");
println!("credential={credential}");
println!("ephemeral=true");
println!("quit=true");
println!();
let _ = io::stdout().flush();
}

View File

@@ -0,0 +1,286 @@
//! Integration tests for git-credential-nostr.
//!
//! Each test spawns the compiled binary as a subprocess, feeds it the
//! credential-helper protocol on stdin, and asserts on stdout/stderr/exit-code.
use std::io::Write;
use std::process::{Command, Stdio};
use base64::Engine as _;
use nostr::{Keys, ToBech32};
// ── helper ────────────────────────────────────────────────────────────────────
/// Spawn the binary, write `input` to stdin, collect output.
/// `env_vars` are added on top of the inherited environment.
/// `NOSTR_PRIVATE_KEY` is always cleared first to prevent test pollution.
fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output {
let bin = env!("CARGO_BIN_EXE_git-credential-nostr");
let mut cmd = Command::new(bin);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_remove("NOSTR_PRIVATE_KEY")
// Prevent git config on the test machine from supplying a keyfile.
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", std::env::temp_dir());
for (k, v) in env_vars {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn git-credential-nostr");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().expect("failed to wait on child")
}
/// Generate a fresh nsec string for use in tests.
fn fresh_nsec() -> String {
let keys = Keys::generate();
keys.secret_key().to_bech32().unwrap()
}
/// Standard valid credential-helper input (includes authtype capability).
fn valid_input() -> String {
"capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\
\n"
.to_string()
}
// ── tests ─────────────────────────────────────────────────────────────────────
/// Happy path: valid key + valid input → well-formed credential response with
/// a base64-encoded kind:27235 JSON event.
#[test]
fn happy_path() {
let nsec = fresh_nsec();
let out = run_helper(&valid_input(), &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0, got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines.contains(&"capability[]=authtype"),
"missing capability[]=authtype in:\n{stdout}"
);
assert!(
lines.contains(&"authtype=Nostr"),
"missing authtype=Nostr in:\n{stdout}"
);
assert!(
lines.contains(&"ephemeral=true"),
"missing ephemeral=true in:\n{stdout}"
);
assert!(
lines.contains(&"quit=true"),
"missing quit=true in:\n{stdout}"
);
// Extract and validate the credential value.
let cred_line = lines
.iter()
.find(|l| l.starts_with("credential="))
.expect("no credential= line in output");
let b64 = cred_line.strip_prefix("credential=").unwrap();
let json_bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("credential is not valid base64");
let json_str = String::from_utf8(json_bytes).expect("credential is not valid UTF-8");
let event: serde_json::Value =
serde_json::from_str(&json_str).expect("credential does not decode to JSON");
assert_eq!(
event["kind"],
serde_json::json!(27235),
"expected kind 27235, got {}",
event["kind"]
);
// Sanity-check a few more fields the NIP-98 event must have.
assert!(event["id"].is_string(), "event missing 'id'");
assert!(event["pubkey"].is_string(), "event missing 'pubkey'");
assert!(event["sig"].is_string(), "event missing 'sig'");
assert!(event["tags"].is_array(), "event missing 'tags'");
}
/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0.
#[test]
fn old_git_no_authtype_capability() {
let input = "protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0 for old-git path, got {:?}",
out.status.code()
);
let stdout = String::from_utf8_lossy(&out.stdout);
// Output should be just a blank line — no credential data.
assert_eq!(
stdout.trim(),
"",
"expected empty output for old-git path, got:\n{stdout}"
);
assert!(
!stdout.contains("credential="),
"should not emit credential= for old git"
);
}
/// No key configured at all → exit 1, stderr mentions "no nostr key configured".
#[test]
fn missing_key() {
// run_helper already clears NOSTR_PRIVATE_KEY and points HOME at a temp dir
// that has no git config, so no keyfile will be found.
let out = run_helper(&valid_input(), &[]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing key"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no nostr key configured"),
"expected 'no nostr key configured' in stderr, got:\n{stderr}"
);
}
/// `wwwauth[]` present but missing `method="..."` → exit 1, stderr mentions "method hint".
#[test]
fn missing_method_hint() {
let input = "capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
wwwauth[]=Nostr realm=\"sprout\"\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing method hint"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("method hint"),
"expected 'method hint' in stderr, got:\n{stderr}"
);
}
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
#[test]
fn missing_path() {
let input = "capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing path"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("useHttpPath"),
"expected 'useHttpPath' in stderr, got:\n{stderr}"
);
}
/// Keyfile with 0644 permissions → exit 1, stderr mentions "insecure permissions".
#[cfg(unix)]
#[test]
fn bad_keyfile_permissions() {
use std::os::unix::fs::PermissionsExt;
let nsec = fresh_nsec();
// Write keyfile to a temp path.
let tmp_dir = std::env::temp_dir();
let keyfile = tmp_dir.join(format!(
"nostr-test-key-{}.nsec",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::write(&keyfile, &nsec).expect("failed to write temp keyfile");
// Set insecure permissions (0644).
std::fs::set_permissions(&keyfile, std::fs::Permissions::from_mode(0o644))
.expect("failed to set permissions");
// Point a scratch git config at the keyfile.
let git_config_dir = tmp_dir.join(format!(
"nostr-test-gitconfig-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::create_dir_all(&git_config_dir).unwrap();
let git_config_file = git_config_dir.join(".gitconfig");
std::fs::write(
&git_config_file,
format!("[nostr]\n\tkeyfile = {}\n", keyfile.display()),
)
.expect("failed to write git config");
let out = run_helper(
&valid_input(),
&[("HOME", git_config_dir.to_str().unwrap())],
);
// Clean up regardless of outcome.
let _ = std::fs::remove_file(&keyfile);
let _ = std::fs::remove_file(&git_config_file);
let _ = std::fs::remove_dir(&git_config_dir);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for insecure keyfile permissions"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("insecure permissions"),
"expected 'insecure permissions' in stderr, got:\n{stderr}"
);
}

View File

@@ -0,0 +1,41 @@
[package]
name = "git-sign-nostr"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "NIP-GS git commit/tag signing program using Nostr secp256k1 keys"
readme = "README.md"
publish = false # internal workspace tool, not published to crates.io
[[bin]]
name = "git-sign-nostr"
path = "src/main.rs"
[dependencies]
# Base64 armor encoding/decoding for NIP-GS signature envelopes.
# Not in workspace deps — each crate pins independently (same pattern as
# sprout-relay, sprout-mcp, sprout-cli, git-credential-nostr).
base64 = "0.22"
# Hex encoding for BIP-340 signatures and public keys.
hex = { workspace = true }
# Secret key zeroization on drop.
zeroize = { workspace = true, features = ["derive"] }
# Nostr key parsing (nsec/npub bech32), secp256k1 Schnorr signing, SHA-256.
# Uses the full default feature set because we need: Keys, PublicKey,
# FromBech32, and the re-exported bitcoin::secp256k1 + bitcoin::hashes.
nostr = { workspace = true }
# JSON parsing for NIP-OA auth tag and envelope verification.
serde_json = { workspace = true }
# Timestamp formatting for GnuPG VALIDSIG status lines.
chrono = { workspace = true }
# Unix-specific: O_NOFOLLOW for keyfile open, fcntl for fd validation.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View File

@@ -0,0 +1,46 @@
# git-sign-nostr
NIP-GS signing program — signs git commits and tags with Nostr secp256k1 keys
using BIP-340 Schnorr signatures.
## Usage
```bash
# Configure git to use nostr signing
git config gpg.format x509
git config gpg.x509.program /path/to/git-sign-nostr
git config commit.gpgsign true
git config tag.gpgsign true
git config user.signingkey <hex-pubkey>
# Set the private key (env var)
export NOSTR_PRIVATE_KEY=<hex-or-nsec>
# Optional: NIP-OA owner attestation
export SPROUT_AUTH_TAG='["auth","<owner-pk>","<conditions>","<owner-sig>"]'
# Commits are now automatically signed
git commit -m "signed with nostr"
# Verify
git verify-commit HEAD
```
## Key Loading Priority
1. `NOSTR_PRIVATE_KEY` environment variable
2. `SPROUT_PRIVATE_KEY` environment variable
3. Keyfile at path from `git config nostr.keyfile`
Keys may be hex (64 chars) or NIP-19 bech32 (`nsec1...`).
## How It Works
Git invokes this program as a signing/verification backend:
- **Sign:** `git-sign-nostr --status-fd=2 -bsau <keyid>` — reads payload from
stdin, writes armored signature to stdout, status lines to fd 2 (stderr)
- **Verify:** `git-sign-nostr --status-fd=1 --verify <sigfile> -` — reads
payload from stdin, verifies signature from file, status lines to fd 1 (stdout)
See [NIP-GS](../../docs/nips/NIP-GS.md) for the full specification.

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,11 @@ chrono = { workspace = true }
# URL parsing
url = { workspace = true }
# NIP-98 HTTP auth signing
sha2 = { workspace = true }
base64 = "0.22"
hex = { workspace = true }
# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -13,6 +13,8 @@ use tokio::io::AsyncWriteExt;
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError};
use crate::observer::{ObserverContext, ObserverHandle};
/// Maximum allowed size of a single NDJSON line from the agent's stdout.
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB
@@ -137,6 +139,12 @@ pub struct AcpClient {
/// Inherited by `cancel_with_cleanup` so the drain loop shares the same budget
/// rather than starting a fresh timer (prevents double-jeopardy).
current_hard_deadline: Option<tokio::time::Instant>,
/// Optional local observer feed used by the desktop app.
observer: Option<ObserverHandle>,
/// Pool slot index for this agent process.
observer_agent_index: Option<usize>,
/// Best-effort context attached to raw ACP wire events.
observer_context: ObserverContext,
}
impl AcpClient {
@@ -225,9 +233,35 @@ impl AcpClient {
permission_responded: false,
last_prompt_id: None,
current_hard_deadline: None,
observer: None,
observer_agent_index: None,
observer_context: ObserverContext::default(),
})
}
/// Attach a local observer feed to this ACP client.
pub fn set_observer(&mut self, observer: Option<ObserverHandle>, agent_index: usize) {
self.observer = observer;
self.observer_agent_index = Some(agent_index);
}
/// Update metadata that will be attached to subsequent raw wire events.
pub fn set_observer_context(&mut self, context: ObserverContext) {
self.observer_context = context;
}
/// Emit a semantic event to the local observer feed, if enabled.
pub fn observe(&self, kind: impl Into<String>, payload: serde_json::Value) {
if let Some(observer) = &self.observer {
observer.emit(
kind,
self.observer_agent_index,
&self.observer_context,
payload,
);
}
}
/// Send the `initialize` request and return the agent's response result value.
///
/// Must be called exactly once, before any other ACP method.
@@ -429,6 +463,33 @@ impl AcpClient {
}
};
self.cancel_with_cleanup_until(session_id, hard_deadline)
.await
}
/// Cancel a user-interrupted turn with a bounded grace window.
///
/// Some ACP servers currently keep streaming after `session/cancel`. For an
/// explicit Stop button, waiting until the original turn deadline can make
/// cancellation look broken. This variant gives the agent a short chance to
/// acknowledge cancellation, then returns a timeout so the caller can respawn
/// the agent process and actually stop the work.
pub async fn cancel_with_cleanup_grace(
&mut self,
session_id: &str,
grace: std::time::Duration,
) -> Result<StopReason, AcpError> {
let _ = self.current_hard_deadline.take();
let hard_deadline = tokio::time::Instant::now() + grace;
self.cancel_with_cleanup_until(session_id, hard_deadline)
.await
}
async fn cancel_with_cleanup_until(
&mut self,
session_id: &str,
hard_deadline: tokio::time::Instant,
) -> Result<StopReason, AcpError> {
// Validate precondition before any side effects — fail fast if there's
// no in-flight prompt (prevents writing permission responses or cancel
// notifications to the agent when no prompt is active).
@@ -456,10 +517,8 @@ impl AcpClient {
tracing::info!(target: "acp::cancel", "sent session/cancel for {session_id}");
// Use a fixed 30s idle timeout during cleanup — the cancel notification
// needs time to propagate and the agent may go silent while winding down.
// We do NOT use the caller's idle_timeout here: that value was tuned for
// normal prompt activity and may be very short (e.g. 5s). Using it during
// cancel would cause premature IdleTimeout before the cancelled response
// arrives, leaving the session in an inconsistent state.
// The separate hard_deadline bounds agents that keep producing output
// but ignore cancellation.
let cleanup_idle = std::time::Duration::from_secs(30);
let result = self
.read_until_response_with_idle_timeout(prompt_id, cleanup_idle, hard_deadline)
@@ -484,7 +543,9 @@ impl AcpClient {
})
.await
.map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))?
.map_err(AcpError::Io)
.map_err(AcpError::Io)?;
self.observe("acp_write", value.clone());
Ok(())
}
/// Default timeout for non-prompt RPCs (initialize, session/new, etc.).
@@ -626,6 +687,13 @@ impl AcpClient {
let msg: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
self.observe(
"acp_parse_error",
serde_json::json!({
"line": trimmed,
"error": e.to_string(),
}),
);
tracing::warn!(
target: "acp::wire",
"failed to parse line as JSON: {e} — skipping"
@@ -633,6 +701,7 @@ impl AcpClient {
continue;
}
};
self.observe("acp_read", msg.clone());
// Check if this is a response to our expected request (has matching id
// AND no `method` field — a `method` field means it's an agent-initiated
@@ -729,6 +798,13 @@ impl AcpClient {
let msg: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
self.observe(
"acp_parse_error",
serde_json::json!({
"line": trimmed,
"error": e.to_string(),
}),
);
tracing::warn!(
target: "acp::wire",
"failed to parse line as JSON: {e} — skipping"
@@ -736,6 +812,7 @@ impl AcpClient {
continue;
}
};
self.observe("acp_read", msg.clone());
// Only reset the idle clock on lines that parse as valid JSON.
// Malformed lines (skipped above) don't count as real agent activity.

View File

@@ -183,8 +183,9 @@ pub struct CliArgs {
#[arg(long, env = "SPROUT_PRIVATE_KEY")]
pub private_key: String,
#[arg(long, env = "SPROUT_API_TOKEN")]
pub api_token: Option<String>,
/// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate.
#[arg(long, env = "SPROUT_ACP_AGENT_OWNER")]
pub agent_owner: Option<String>,
#[arg(long, env = "SPROUT_ACP_AGENT_COMMAND", default_value = "goose")]
pub agent_command: String,
@@ -358,6 +359,10 @@ pub struct CliArgs {
/// Name of the persona within the pack to use. Required when --persona-pack is set.
#[arg(long, env = "SPROUT_ACP_PERSONA_NAME")]
pub persona_name: Option<String>,
/// Publish encrypted ACP observer frames over the relay.
#[arg(long, env = "SPROUT_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,
}
// ── Merged NIP-01 filter ──────────────────────────────────────────────────────
@@ -376,7 +381,6 @@ pub struct ChannelFilter {
#[derive(Debug)]
pub struct Config {
pub keys: Keys,
pub api_token: Option<String>,
pub relay_url: String,
pub agent_command: String,
pub agent_args: Vec<String>,
@@ -412,6 +416,11 @@ pub struct Config {
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL).
/// Populated from persona pack resolution. Empty when no pack is configured.
pub persona_env_vars: Vec<(String, String)>,
/// Whether to publish encrypted observer frames through the relay.
pub relay_observer: bool,
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
/// Replaces the old REST-based owner lookup.
pub agent_owner: Option<String>,
}
/// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars.
@@ -718,7 +727,6 @@ impl Config {
let config = Config {
keys,
api_token: args.api_token,
relay_url: args.relay_url,
agent_command,
agent_args,
@@ -747,6 +755,8 @@ impl Config {
respond_to: args.respond_to,
respond_to_allowlist,
persona_env_vars,
relay_observer: args.relay_observer,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
};
Ok(config)
@@ -1078,7 +1088,6 @@ mod tests {
fn test_config(mode: SubscribeMode) -> Config {
Config {
keys: nostr::Keys::generate(),
api_token: None,
relay_url: "ws://localhost:3000".into(),
agent_command: "goose".into(),
agent_args: vec!["acp".into()],
@@ -1107,6 +1116,8 @@ mod tests {
respond_to: RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
persona_env_vars: vec![],
relay_observer: false,
agent_owner: None,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
//! In-process observer bus for ACP session activity.
//!
//! This is intentionally process-local infrastructure: it lets the harness
//! collect raw ACP JSON-RPC activity and publish owner-scoped encrypted relay
//! frames without exposing a local HTTP port.
use std::{
collections::VecDeque,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use serde::Serialize;
use tokio::sync::broadcast;
const OBSERVER_BUFFER_CAP: usize = 1_000;
/// Best-effort metadata attached to observer events.
#[derive(Clone, Debug, Default)]
pub struct ObserverContext {
/// Sprout channel UUID for the current turn, when channel-scoped.
pub channel_id: Option<String>,
/// ACP session ID associated with the current turn, once known.
pub session_id: Option<String>,
/// Local UUID for one prompt turn.
pub turn_id: Option<String>,
}
/// Handle used by the harness to publish local observer events.
#[derive(Clone)]
pub struct ObserverHandle {
inner: Arc<ObserverInner>,
}
struct ObserverInner {
tx: broadcast::Sender<ObserverEvent>,
buffer: Mutex<VecDeque<ObserverEvent>>,
seq: AtomicU64,
}
fn new_observer_handle() -> ObserverHandle {
let (tx, _) = broadcast::channel(OBSERVER_BUFFER_CAP);
ObserverHandle {
inner: Arc::new(ObserverInner {
tx,
buffer: Mutex::new(VecDeque::with_capacity(OBSERVER_BUFFER_CAP)),
seq: AtomicU64::new(1),
}),
}
}
/// Event delivered through the in-process observer bus.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ObserverEvent {
/// Monotonic process-local sequence number.
pub seq: u64,
/// RFC3339 UTC timestamp.
pub timestamp: String,
/// Observer event kind, for example `acp_read` or `turn_started`.
pub kind: String,
/// Pool slot index for the agent process that emitted the event.
pub agent_index: Option<usize>,
/// Sprout channel UUID for channel-scoped events.
pub channel_id: Option<String>,
/// ACP session ID when known.
pub session_id: Option<String>,
/// Local UUID for one prompt turn.
pub turn_id: Option<String>,
/// Raw or semantic event payload.
pub payload: serde_json::Value,
}
impl ObserverHandle {
/// Create an in-process observer feed.
pub fn in_process() -> Self {
new_observer_handle()
}
/// Subscribe to live observer events.
pub fn subscribe(&self) -> broadcast::Receiver<ObserverEvent> {
self.inner.tx.subscribe()
}
/// Return the current replay buffer.
pub fn snapshot(&self) -> Vec<ObserverEvent> {
match self.inner.buffer.lock() {
Ok(buffer) => buffer.iter().cloned().collect(),
Err(error) => {
tracing::warn!(target: "observer", "observer replay buffer lock poisoned: {error}");
Vec::new()
}
}
}
/// Emit a local observer event.
pub fn emit(
&self,
kind: impl Into<String>,
agent_index: Option<usize>,
context: &ObserverContext,
payload: serde_json::Value,
) {
let event = ObserverEvent {
seq: self.inner.seq.fetch_add(1, Ordering::Relaxed),
timestamp: chrono::Utc::now().to_rfc3339(),
kind: kind.into(),
agent_index,
channel_id: context.channel_id.clone(),
session_id: context.session_id.clone(),
turn_id: context.turn_id.clone(),
payload,
};
match self.inner.buffer.lock() {
Ok(mut buffer) => {
if buffer.len() >= OBSERVER_BUFFER_CAP {
buffer.pop_front();
}
buffer.push_back(event.clone());
}
Err(error) => {
tracing::warn!(target: "observer", "observer replay buffer lock poisoned: {error}");
}
}
let _ = self.inner.tx.send(event);
}
}
/// Build observer context values from optional channel/session/turn IDs.
pub fn context_for(
channel_id: Option<uuid::Uuid>,
session_id: Option<String>,
turn_id: Option<String>,
) -> ObserverContext {
ObserverContext {
channel_id: channel_id.map(|id| id.to_string()),
session_id,
turn_id,
}
}

View File

@@ -33,6 +33,7 @@ use crate::acp::{
AcpError, McpServer, ModelSwitchMethod, StopReason,
};
use crate::config::{DedupMode, PermissionMode};
use crate::observer;
use crate::queue::{
ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile,
PromptProfileLookup,
@@ -53,7 +54,7 @@ pub struct TaskMeta {
pub recoverable_batch: Option<FlushBatch>,
/// Cancel signal for the in-flight prompt task.
/// `None` for heartbeat tasks (not cancellable) and after signal is consumed.
pub cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
pub cancel_tx: Option<tokio::sync::oneshot::Sender<CancelMode>>,
}
/// Agent-level model capabilities. Populated on first session creation.
@@ -153,6 +154,15 @@ pub enum PromptSource {
Heartbeat,
}
/// How an in-flight channel turn should be cancelled.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CancelMode {
/// Stop the current turn and drop its triggering batch.
Stop,
/// Stop the current turn and requeue its triggering batch for a merged re-prompt.
Interrupt,
}
/// Outcome of a prompt task.
#[allow(dead_code)]
pub enum PromptOutcome {
@@ -596,7 +606,7 @@ pub async fn run_prompt_task(
prompt_text: Option<String>,
ctx: Arc<PromptContext>,
result_tx: mpsc::UnboundedSender<PromptResult>,
cancel_rx: Option<tokio::sync::oneshot::Receiver<()>>,
cancel_rx: Option<tokio::sync::oneshot::Receiver<CancelMode>>,
) {
// ── Determine source and resolve/create session ───────────────────────
@@ -605,6 +615,30 @@ pub async fn run_prompt_task(
Some(b) => PromptSource::Channel(b.channel_id),
None => PromptSource::Heartbeat,
};
let turn_id = uuid::Uuid::new_v4().to_string();
let observer_channel_id = match &source {
PromptSource::Channel(channel_id) => Some(*channel_id),
PromptSource::Heartbeat => None,
};
agent.acp.set_observer_context(observer::context_for(
observer_channel_id,
None,
Some(turn_id.clone()),
));
let triggering_event_ids: Vec<String> = batch
.as_ref()
.map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect())
.unwrap_or_default();
agent.acp.observe(
"turn_started",
serde_json::json!({
"source": match &source {
PromptSource::Channel(_) => "channel",
PromptSource::Heartbeat => "heartbeat",
},
"triggeringEventIds": triggering_event_ids,
}),
);
// ── Reaction cleanup guard ────────────────────────────────────────────
// Collects event IDs up front. On drop (any exit path — normal, early
@@ -690,6 +724,18 @@ pub async fn run_prompt_task(
}
}
};
agent.acp.set_observer_context(observer::context_for(
observer_channel_id,
Some(session_id.clone()),
Some(turn_id.clone()),
));
agent.acp.observe(
"session_resolved",
serde_json::json!({
"sessionId": session_id,
"isNewSession": is_new_session,
}),
);
// ── Send initial_message on new channel sessions ──────────────────────
@@ -884,51 +930,75 @@ pub async fn run_prompt_task(
ctx.idle_timeout,
ctx.max_turn_duration,
) => result,
_ = rx => {
mode = rx => {
let cancel_mode = mode.unwrap_or(CancelMode::Stop);
// Cancel signal received. Guard against Race 1: the turn may
// have completed naturally just as cancel fired.
if agent.acp.has_in_flight_prompt() {
// Prompt is genuinely in-flight — cancel it.
match agent.acp.cancel_with_cleanup(&session_id, ctx.idle_timeout).await {
match agent
.acp
.cancel_with_cleanup_grace(
&session_id,
std::time::Duration::from_secs(5),
)
.await
{
Ok(stop_reason) => {
log_stop_reason(&source, &stop_reason);
agent.state.invalidate(&source);
let retry_batch = match cancel_mode {
CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch),
CancelMode::Stop => None,
};
let _ = result_tx.send(PromptResult {
agent,
source,
outcome: PromptOutcome::Cancelled,
batch: requeue_batch_if_queue(&ctx, batch),
batch: retry_batch,
});
return;
}
Err(AcpError::AgentExited) => {
agent.state.invalidate_all();
let retry_batch = match cancel_mode {
CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch),
CancelMode::Stop => None,
};
let _ = result_tx.send(PromptResult {
agent,
source,
outcome: PromptOutcome::AgentExited,
batch: requeue_batch_if_queue(&ctx, batch),
batch: retry_batch,
});
return;
}
Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout) => {
// Cancel drain timed out — agent state uncertain.
agent.state.invalidate(&source);
let retry_batch = match cancel_mode {
CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch),
CancelMode::Stop => None,
};
let _ = result_tx.send(PromptResult {
agent,
source,
outcome: PromptOutcome::Timeout,
batch: requeue_batch_if_queue(&ctx, batch),
batch: retry_batch,
});
return;
}
Err(e) => {
agent.state.invalidate(&source);
let retry_batch = match cancel_mode {
CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch),
CancelMode::Stop => None,
};
let _ = result_tx.send(PromptResult {
agent,
source,
outcome: PromptOutcome::Error(e),
batch: requeue_batch_if_queue(&ctx, batch),
batch: retry_batch,
});
return;
}
@@ -1126,21 +1196,50 @@ where
/// persistent failure (graceful degradation — prompt will lack channel name and
/// DM detection).
async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option<PromptChannelInfo> {
let path = format!("/api/channels/{}", channel_id);
use nostr::{Alphabet, SingleLetterTag};
let d_tag = SingleLetterTag::lowercase(Alphabet::D);
let filter = nostr::Filter::new()
.kind(nostr::Kind::Custom(
sprout_core::kind::KIND_NIP29_GROUP_METADATA as u16,
))
.custom_tag(d_tag, [channel_id.to_string()]);
fetch_with_retry(|| async {
match timeout(CONTEXT_FETCH_TIMEOUT, rest.get_json(&path)).await {
match timeout(
CONTEXT_FETCH_TIMEOUT,
rest.query(std::slice::from_ref(&filter)),
)
.await
{
Ok(Ok(json)) => {
let name = json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let channel_type = json
.get("channel_type")
.and_then(|v| v.as_str())
.unwrap_or("stream")
.to_string();
Some(PromptChannelInfo { name, channel_type })
let events = json.as_array()?;
let ev = events.first()?;
let tags = ev.get("tags")?.as_array()?;
let mut name = None;
let mut is_hidden = false;
let mut is_private = false;
for tag in tags {
if let Some(arr) = tag.as_array() {
match arr.first().and_then(|v| v.as_str()) {
Some("name") => name = arr.get(1).and_then(|v| v.as_str()),
Some("hidden") => is_hidden = true,
Some("private") => is_private = true,
_ => {}
}
}
}
let channel_type = if is_hidden {
"dm".to_string()
} else if is_private {
"private".to_string()
} else {
"stream".to_string()
};
Some(PromptChannelInfo {
name: name.unwrap_or("unknown").to_string(),
channel_type,
})
}
Ok(Err(e)) => {
tracing::debug!(
@@ -1245,24 +1344,37 @@ fn collect_prompt_pubkeys(
pubkeys
}
fn parse_profile_lookup_response(json: serde_json::Value) -> Option<PromptProfileLookup> {
let profiles = json.get("profiles")?.as_object()?;
/// Parse kind:0 profile events into a `PromptProfileLookup`.
///
/// Each kind:0 event has `pubkey` and JSON `content` with optional fields:
/// `display_name` (or `name`), `nip05`.
fn parse_kind0_profile_lookup(json: serde_json::Value) -> Option<PromptProfileLookup> {
let events = json.as_array()?;
let mut lookup = PromptProfileLookup::new();
for (pubkey, profile) in profiles {
lookup.insert(
pubkey.to_ascii_lowercase(),
PromptProfile {
display_name: profile
for ev in events {
let pubkey = ev.get("pubkey").and_then(|v| v.as_str());
let content_str = ev.get("content").and_then(|v| v.as_str());
if let (Some(pk), Some(content)) = (pubkey, content_str) {
if let Ok(profile) = serde_json::from_str::<serde_json::Value>(content) {
let display_name = profile
.get("display_name")
.and_then(|value| value.as_str())
.map(str::to_string),
nip05_handle: profile
.get("nip05_handle")
.and_then(|value| value.as_str())
.map(str::to_string),
},
);
.or_else(|| profile.get("name"))
.and_then(|v| v.as_str())
.map(str::to_string);
let nip05_handle = profile
.get("nip05")
.and_then(|v| v.as_str())
.map(str::to_string);
lookup.insert(
pk.to_ascii_lowercase(),
PromptProfile {
display_name,
nip05_handle,
},
);
}
}
}
if lookup.is_empty() {
@@ -1282,15 +1394,26 @@ async fn fetch_prompt_profile_lookup(
return None;
}
let body = serde_json::json!({ "pubkeys": pubkeys });
// Query kind:0 (NIP-01 profile metadata) for all pubkeys.
let authors: Vec<nostr::PublicKey> = pubkeys
.iter()
.filter_map(|s| nostr::PublicKey::from_hex(s).ok())
.collect();
if authors.is_empty() {
return None;
}
let filter = nostr::Filter::new()
.kind(nostr::Kind::Metadata)
.authors(authors);
fetch_with_retry(|| async {
match timeout(
CONTEXT_FETCH_TIMEOUT,
rest.post_json("/api/users/batch", &body),
rest.query(std::slice::from_ref(&filter)),
)
.await
{
Ok(Ok(json)) => parse_profile_lookup_response(json),
Ok(Ok(json)) => parse_kind0_profile_lookup(json),
Ok(Err(e)) => {
tracing::debug!("prompt profile lookup failed: {e} — will retry");
None
@@ -1304,15 +1427,16 @@ async fn fetch_prompt_profile_lookup(
.await
}
/// Fetch thread context via REST: `GET /api/channels/{id}/threads/{event_id}?limit=N`
/// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag.
async fn fetch_thread_context(
channel_id: Uuid,
root_event_id: &str,
limit: u32,
rest: &RestClient,
) -> Option<ConversationContext> {
// Defense-in-depth: validate hex before interpolating into URL path.
// Nostr event IDs are 32-byte SHA-256 hashes = 64 hex chars.
use nostr::{Alphabet, SingleLetterTag};
// Defense-in-depth: validate hex event ID.
if root_event_id.is_empty()
|| root_event_id.len() != 64
|| !root_event_id.chars().all(|c| c.is_ascii_hexdigit())
@@ -1324,14 +1448,29 @@ async fn fetch_thread_context(
return None;
}
let path = format!(
"/api/channels/{}/threads/{}?limit={}",
channel_id, root_event_id, limit
);
let e_tag = SingleLetterTag::lowercase(Alphabet::E);
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
let ch_str = channel_id.to_string();
// Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?);
let replies_filter = nostr::Filter::new()
.kinds([
nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE as u16),
nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
])
.custom_tag(e_tag, [root_event_id])
.custom_tag(h_tag, [ch_str.as_str()])
.limit(limit as usize);
fetch_with_retry(|| async {
match timeout(CONTEXT_FETCH_TIMEOUT, rest.get_json(&path)).await {
Ok(Ok(json)) => parse_thread_response(json),
match timeout(
CONTEXT_FETCH_TIMEOUT,
rest.query(&[root_filter.clone(), replies_filter.clone()]),
)
.await
{
Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id),
Ok(Err(e)) => {
tracing::warn!(
channel_id = %channel_id,
@@ -1353,17 +1492,32 @@ async fn fetch_thread_context(
.await
}
/// Fetch DM context via REST: `GET /api/channels/{id}/messages?limit=N`
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
async fn fetch_dm_context(
channel_id: Uuid,
limit: u32,
rest: &RestClient,
) -> Option<ConversationContext> {
let path = format!("/api/channels/{}/messages?limit={}", channel_id, limit);
use nostr::{Alphabet, SingleLetterTag};
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
let ch_str = channel_id.to_string();
let filter = nostr::Filter::new()
.kinds([
nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE as u16),
nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
])
.custom_tag(h_tag, [ch_str.as_str()])
.limit(limit as usize);
fetch_with_retry(|| async {
match timeout(CONTEXT_FETCH_TIMEOUT, rest.get_json(&path)).await {
Ok(Ok(json)) => parse_dm_response(json, limit),
match timeout(
CONTEXT_FETCH_TIMEOUT,
rest.query(std::slice::from_ref(&filter)),
)
.await
{
Ok(Ok(json)) => parse_nostr_dm_response(json, limit),
Ok(Err(e)) => {
tracing::warn!(
channel_id = %channel_id,
@@ -1383,9 +1537,8 @@ async fn fetch_dm_context(
.await
}
/// Parse the thread REST response into a `ConversationContext::Thread`.
///
/// Expected shape: `{ "root": {...}, "replies": [...], "total_replies": N }`
/// Parse the legacy REST thread response (used in tests only).
#[cfg(test)]
fn parse_thread_response(json: serde_json::Value) -> Option<ConversationContext> {
let mut messages = Vec::new();
@@ -1425,8 +1578,8 @@ fn parse_thread_response(json: serde_json::Value) -> Option<ConversationContext>
/// Parse the DM messages REST response into a `ConversationContext::Dm`.
///
/// Expected shape: `{ "messages": [...], "next_cursor": ... }`
/// Messages arrive newest-first from the API; we reverse to chronological order.
/// Parse the legacy REST DM response (used in tests only).
#[cfg(test)]
fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option<ConversationContext> {
let arr = json.get("messages").and_then(|v| v.as_array())?;
@@ -1487,6 +1640,89 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option<ContextMessage> {
})
}
/// Parse a Nostr query response (array of events) into thread context.
///
/// Separates the root event (matching `root_event_id`) from replies, sorts
/// chronologically by `created_at`.
fn parse_nostr_thread_response(
json: serde_json::Value,
root_event_id: &str,
) -> Option<ConversationContext> {
let events = json.as_array()?;
let mut root_msg = None;
let mut reply_msgs = Vec::new();
for ev in events {
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(msg) = json_to_context_message(ev) {
if ev_id == root_event_id {
root_msg = Some(msg);
} else {
reply_msgs.push((
ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
msg,
));
}
}
}
// Sort replies chronologically.
reply_msgs.sort_by_key(|(ts, _)| *ts);
let mut messages = Vec::new();
if let Some(root) = root_msg {
messages.push(root);
}
messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg));
let total = messages.len();
if messages.is_empty() {
return None;
}
Some(ConversationContext::Thread {
messages,
total,
truncated: false, // query returns all within limit
})
}
/// Parse a Nostr query response (array of events) into DM context.
///
/// Events arrive in relay order (newest first); reversed to chronological.
fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option<ConversationContext> {
let events = json.as_array()?;
let mut messages: Vec<(u64, ContextMessage)> = events
.iter()
.filter_map(|ev| {
let ts = ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
json_to_context_message(ev).map(|msg| (ts, msg))
})
.collect();
// Sort chronologically (oldest first).
messages.sort_by_key(|(ts, _)| *ts);
let messages: Vec<ContextMessage> = messages.into_iter().map(|(_, msg)| msg).collect();
let truncated = messages.len() >= limit as usize;
let total = if truncated {
messages.len() + 1
} else {
messages.len()
};
if messages.is_empty() {
return None;
}
Some(ConversationContext::Dm {
messages,
total,
truncated,
})
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Return the batch for requeue only in Queue mode; drop it in Drop mode.
@@ -1591,8 +1827,8 @@ const REACTION_WORKING: &str = "💬";
/// Best-effort timeout for a single reaction REST call.
const REACTION_TIMEOUT: Duration = Duration::from_millis(500);
/// Percent-encode a string for use in a URL path segment.
/// Emoji bytes are not URL-safe; event IDs (hex) pass through unchanged.
/// Percent-encode a string for use in a URL path segment (used in tests only).
#[cfg(test)]
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
@@ -1612,7 +1848,7 @@ fn pct_encode(s: &str) -> String {
/// Best-effort: add a reaction via a signed Nostr kind-7 event (NIP-25).
///
/// Builds a reaction event with `sprout_sdk::build_reaction`, signs it with
/// the keys already stored in `RestClient`, and submits via POST /api/events.
/// the keys already stored in `RestClient`, and submits via `POST /events`.
/// Returns immediately on timeout or any error — reactions are cosmetic.
pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) {
let target_id = match nostr::EventId::from_hex(event_id) {
@@ -1636,14 +1872,7 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str
return;
}
};
let body = match serde_json::to_value(&event) {
Ok(v) => v,
Err(e) => {
tracing::debug!(event_id, emoji, "reaction add: serialize failed: {e}");
return;
}
};
match tokio::time::timeout(REACTION_TIMEOUT, rest.post_json("/api/events", &body)).await {
match tokio::time::timeout(REACTION_TIMEOUT, rest.submit_event(&event)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction add failed: {e}"),
Err(_) => tracing::debug!(event_id, emoji, "reaction add timed out"),
@@ -1652,44 +1881,43 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str
/// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event.
///
/// Looks up our kind:7 reaction event ID via GET /api/messages/{event_id}/reactions,
/// then submits a signed kind:5 deletion via POST /api/events.
/// Queries kind:7 reactions by our pubkey targeting the event, finds the matching
/// emoji, then submits a signed kind:5 deletion via `POST /events`.
/// Returns immediately on timeout or any error — reactions are cosmetic.
pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) {
// Step 1: look up the reaction event ID we own for this emoji.
let path = format!("/api/messages/{}/reactions", pct_encode(event_id));
let resp = match tokio::time::timeout(Duration::from_millis(1_000), rest.get_json(&path)).await
use nostr::{Alphabet, SingleLetterTag};
// Step 1: query our kind:7 reactions targeting this event.
let my_pubkey = rest.keys.public_key();
let e_tag = SingleLetterTag::lowercase(Alphabet::E);
let filter = nostr::Filter::new()
.kind(nostr::Kind::Reaction)
.author(my_pubkey)
.custom_tag(e_tag, [event_id]);
let resp = match tokio::time::timeout(Duration::from_millis(1_000), rest.query(&[filter])).await
{
Ok(Ok(v)) => v,
Ok(Err(e)) => {
tracing::debug!(event_id, emoji, "reaction remove: fetch failed: {e}");
tracing::debug!(event_id, emoji, "reaction remove: query failed: {e}");
return;
}
Err(_) => {
tracing::debug!(event_id, emoji, "reaction remove: fetch timed out");
tracing::debug!(event_id, emoji, "reaction remove: query timed out");
return;
}
};
let my_pubkey = rest.keys.public_key().to_hex();
let reid = resp
.get("reactions")
.and_then(|r| r.as_array())
.and_then(|groups| {
groups.iter().find_map(|group| {
if group.get("emoji")?.as_str()? != emoji {
return None;
}
group.get("users")?.as_array()?.iter().find_map(|user| {
if user.get("pubkey")?.as_str()? != my_pubkey {
return None;
}
user.get("reaction_event_id")?
.as_str()
.map(|s| s.to_string())
})
})
});
// Find our reaction event with matching emoji content.
let reid = resp.as_array().and_then(|events| {
events.iter().find_map(|ev| {
let content = ev.get("content")?.as_str()?;
if content != emoji {
return None;
}
ev.get("id")?.as_str().map(|s| s.to_string())
})
});
let reid = match reid {
Some(id) => id,
@@ -1725,19 +1953,7 @@ pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: &
return;
}
};
let body = match serde_json::to_value(&event) {
Ok(v) => v,
Err(e) => {
tracing::debug!(event_id, emoji, "reaction remove: serialize failed: {e}");
return;
}
};
match tokio::time::timeout(
Duration::from_millis(1_000),
rest.post_json("/api/events", &body),
)
.await
{
match tokio::time::timeout(Duration::from_millis(1_000), rest.submit_event(&event)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction remove failed: {e}"),
Err(_) => tracing::debug!(event_id, emoji, "reaction remove timed out"),
@@ -2065,17 +2281,18 @@ mod tests {
}
#[test]
fn test_parse_profile_lookup_response_extracts_display_name_and_nip05() {
let lookup = parse_profile_lookup_response(json!({
"profiles": {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": {
"display_name": "Wes",
"avatar_url": null,
"nip05_handle": "wes@example.com"
}
},
"missing": []
}))
fn test_parse_kind0_profile_lookup_extracts_display_name_and_nip05() {
let lookup = parse_kind0_profile_lookup(json!([
{
"id": "0000000000000000000000000000000000000000000000000000000000000001",
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kind": 0,
"content": "{\"display_name\":\"Wes\",\"nip05\":\"wes@example.com\"}",
"created_at": 1000,
"tags": [],
"sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
]))
.expect("lookup should parse");
assert_eq!(
@@ -2088,9 +2305,9 @@ mod tests {
}
#[test]
fn test_parse_profile_lookup_response_returns_none_for_empty() {
assert!(parse_profile_lookup_response(json!({"profiles": {}})).is_none());
assert!(parse_profile_lookup_response(json!({})).is_none());
fn test_parse_kind0_profile_lookup_returns_none_for_empty() {
assert!(parse_kind0_profile_lookup(json!([])).is_none());
assert!(parse_kind0_profile_lookup(json!({})).is_none());
}
#[test]

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
sprout-db = { workspace = true }
sprout-core = { workspace = true }
sprout-auth = { workspace = true }
nostr = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,10 +1,14 @@
#![deny(unsafe_code)]
//! Sprout instance administration CLI.
//!
//! In the pure Nostr architecture, API tokens no longer exist.
//! Admin operations are performed via signed Nostr events (NIP-43 relay admin commands).
//! This binary is retained as a placeholder for future admin tooling.
use anyhow::Result;
use clap::{Parser, Subcommand};
use nostr::nips::nip19::ToBech32;
use nostr::{Keys, PublicKey};
use sprout_auth::token::{generate_token, hash_token};
use nostr::Keys;
use sprout_db::{Db, DbConfig};
#[derive(Parser)]
@@ -16,198 +20,207 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
/// Create a new API token for an agent.
MintToken {
/// Token name
/// Add a pubkey to the relay membership list.
AddMember {
/// Nostr public key (hex) to add.
#[arg(long)]
name: String,
pubkey: String,
/// Comma-separated scopes (messages:read, messages:write, channels:read,
/// channels:write, admin:channels, files:read, files:write)
#[arg(long)]
scopes: String,
/// Nostr public key (hex). If omitted, generates a new keypair.
#[arg(long)]
pubkey: Option<String>,
/// Hex pubkey of the human operator who owns this agent.
/// If provided, sets agent_owner_pubkey in the users table.
#[arg(long)]
owner_pubkey: Option<String>,
/// Role: "admin" or "member" (default: member).
#[arg(long, default_value = "member")]
role: String,
},
/// List all relay members.
ListMembers,
/// Generate a new Nostr keypair (for bootstrapping).
GenerateKey,
/// Emit kind:39000/39002 events for channels missing them.
///
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
/// have Nostr discovery events. This command creates them so pure-nostr
/// clients can see those channels. Idempotent — safe to run multiple times.
ReconcileChannels {
/// Relay private key (hex) for signing events. Falls back to
/// SPROUT_RELAY_PRIVATE_KEY env var. If neither is set, generates
/// an ephemeral key (events will be unverifiable after restart).
#[arg(long)]
relay_key: Option<String>,
},
/// List all active API tokens.
ListTokens,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::GenerateKey => {
let keys = Keys::generate();
println!("Public key: {}", keys.public_key().to_hex());
println!("Secret key: {}", keys.secret_key().display_secret());
println!("\nSet SPROUT_PRIVATE_KEY to the secret key to use this identity.");
}
Command::AddMember { pubkey, role } => {
let db = connect_db().await?;
let pk_bytes = hex::decode(&pubkey)?;
if pk_bytes.len() != 32 {
anyhow::bail!("pubkey must be 32 bytes (64 hex chars)");
}
db.ensure_user(&pk_bytes).await?;
// Add to relay members via DB (admin bootstrap — normally done via kind:9030)
db.add_relay_member(&pubkey, &role, None).await?;
println!("Added {} as {} to relay membership list.", pubkey, role);
}
Command::ListMembers => {
let db = connect_db().await?;
let members = db.list_relay_members().await?;
if members.is_empty() {
println!("No relay members found.");
} else {
println!("{:<66} {:<10}", "Pubkey", "Role");
println!("{}", "-".repeat(78));
for m in &members {
println!("{:<66} {:<10}", hex::encode(&m.pubkey), m.role);
}
}
}
Command::ReconcileChannels { relay_key } => {
reconcile_channels(relay_key).await?;
}
}
Ok(())
}
async fn connect_db() -> Result<Db> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://sprout:sprout_dev@localhost:5432/sprout".to_string());
let db = Db::new(&DbConfig {
database_url: db_url,
..DbConfig::default()
})
.await?;
match cli.command {
Command::MintToken {
name,
scopes,
pubkey,
owner_pubkey,
} => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?,
Command::ListTokens => list_tokens(&db).await?,
}
Ok(())
Ok(db)
}
async fn mint_token(
db: &Db,
name: &str,
scopes_str: &str,
pubkey_hex: Option<&str>,
owner_pubkey: Option<String>,
) -> Result<()> {
let scopes: Vec<String> = scopes_str
.split(',')
.map(|s| s.trim().to_string())
.collect();
async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
use nostr::{EventBuilder, Kind, Tag};
use sprout_core::kind::KIND_NIP29_GROUP_ADMINS;
use sprout_db::event::EventQuery;
let (pubkey, generated_keys) = match pubkey_hex {
Some(hex) => (PublicKey::from_hex(hex)?, None),
let db = connect_db().await?;
// Resolve relay signing key: arg > env > ephemeral
let relay_keys = match relay_key_arg.or_else(|| std::env::var("SPROUT_RELAY_PRIVATE_KEY").ok())
{
Some(key_hex) => {
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
}
None => {
let keys = Keys::generate();
(keys.public_key(), Some(keys))
let k = Keys::generate();
eprintln!(
"Warning: no relay key provided — using ephemeral key {}",
k.public_key().to_hex()
);
eprintln!("Events signed with this key won't be verifiable after this run.");
eprintln!("Pass --relay-key or set SPROUT_RELAY_PRIVATE_KEY for production use.");
k
}
};
let pubkey_bytes = pubkey.serialize().to_vec();
// ── Enforce shutdown-required scopes (before any DB writes) ─────────────
// Two triggers, same as the relay path:
// 1. Explicit --owner-pubkey (bootstrap mint)
// 2. Agent already has an owner in the DB (re-mint must preserve controllability)
// Fail closed: DB lookup error → assume owned → enforce scopes.
let has_existing_owner = match db.get_agent_channel_policy(&pubkey_bytes).await {
Ok(Some((_, Some(_)))) => true,
Ok(_) => false,
Err(e) => {
eprintln!("warning: owner lookup failed (assuming owned): {e}");
true // fail closed
}
};
if owner_pubkey.is_some() || has_existing_owner {
let required = [
"users:read",
"messages:read",
"messages:write",
"channels:read",
];
for r in &required {
if !scopes.iter().any(|s| s == r) {
anyhow::bail!("owned agents require the '{r}' scope for agent controllability");
}
}
}
// ── Validate owner_pubkey (before any DB writes) ─────────────────────────
let validated_owner = if let Some(ref owner_hex) = owner_pubkey {
let owner_bytes =
hex::decode(owner_hex).map_err(|e| anyhow::anyhow!("invalid owner pubkey hex: {e}"))?;
if owner_bytes.len() != 32 {
anyhow::bail!("owner pubkey must be 32 bytes (64 hex chars)");
}
Some(owner_bytes)
} else {
None
};
// ── DB writes (all validation passed) ────────────────────────────────────
db.ensure_user(&pubkey_bytes).await?;
if let Some(owner_bytes) = validated_owner {
db.ensure_user(&owner_bytes).await?;
let was_set = db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?;
if !was_set {
let existing = db
.get_agent_channel_policy(&pubkey_bytes)
.await?
.and_then(|(_, owner)| owner);
if existing.as_deref() != Some(owner_bytes.as_slice()) {
anyhow::bail!(
"agent already has a different owner — refusing to mint token for non-owner"
);
}
eprintln!("note: agent already owned by the requested pubkey — proceeding");
}
}
let raw_token = generate_token();
let token_hash = hash_token(&raw_token);
let token_id = db
.create_api_token(&token_hash, &pubkey_bytes, name, &scopes, None, None)
.await?;
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ Token minted successfully! ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ Token ID: {:<46} ║", token_id);
println!("║ Name: {:<46} ║", name);
println!("║ Scopes: {:<46} ║", scopes_str);
println!("║ Pubkey: {}...║", &pubkey.to_hex()[..48]);
println!("╠══════════════════════════════════════════════════════════════╣");
if let Some(keys) = generated_keys {
println!("║ ⚠️ SAVE THESE — shown only once! ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ Private key (nsec): ║");
println!(
"║ {} ║",
keys.secret_key()
.to_bech32()
.unwrap_or_else(|_| "error encoding".into())
);
println!("║ ║");
}
println!("║ API Token: ║");
println!("║ {} ║", raw_token);
println!("╚══════════════════════════════════════════════════════════════╝");
Ok(())
}
async fn list_tokens(db: &Db) -> Result<()> {
let tokens = db.list_active_tokens().await?;
if tokens.is_empty() {
println!("No active tokens found.");
let channels = db.list_channels(None).await?;
if channels.is_empty() {
println!("No channels in database.");
return Ok(());
}
println!(
"{:<36} {:<20} {:<40} {:<20}",
"ID", "Name", "Scopes", "Created"
);
println!("{}", "-".repeat(120));
let mut reconciled = 0u32;
let mut skipped = 0u32;
for t in &tokens {
let scopes_str = t.scopes.join(",");
let id_str = t.id.to_string();
println!(
"{:<36} {:<20} {:<40} {:<20}",
&id_str[..36.min(id_str.len())],
&t.name[..20.min(t.name.len())],
&scopes_str[..40.min(scopes_str.len())],
t.created_at.format("%Y-%m-%d %H:%M"),
);
for channel in &channels {
let channel_id_str = channel.id.to_string();
// Check if kind:39000 already exists
let existing = db
.query_events(&EventQuery {
kinds: Some(vec![39000]),
d_tag: Some(channel_id_str.clone()),
limit: Some(1),
..Default::default()
})
.await
.unwrap_or_default();
if !existing.is_empty() {
skipped += 1;
continue;
}
let members = db.get_members(channel.id).await?;
// kind:39000 — channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &channel_id_str])?];
tags.push(Tag::parse(&["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(&["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(&["private"])?);
} else {
tags.push(Tag::parse(&["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(&["hidden"])?);
}
tags.push(Tag::parse(&["closed"])?);
tags.push(Tag::parse(&["t", &channel.channel_type])?);
let event = EventBuilder::new(Kind::Custom(39000), "", tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
.await?;
}
// kind:39001 — admins
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(&["p", &pk, &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "", tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
.await?;
}
// kind:39002 — members
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &channel_id_str])?];
for m in &members {
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(&["p", &pk, "", &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(39002), "", tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
.await?;
}
reconciled += 1;
}
println!(
"Reconciled {reconciled} channels ({skipped} already had events, {} total).",
channels.len()
);
Ok(())
}

View File

@@ -0,0 +1,39 @@
[package]
name = "sprout-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Minimal, unbreakable ACP-compliant agent. Non-streaming. Tool-calls-as-output."
readme = "README.md"
keywords = ["acp", "agent", "llm", "mcp", "minimal"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "sprout-agent"
path = "src/main.rs"
# Test-only fake MCP server. Built unconditionally because cargo can't gate
# bins on `cfg(test)`, but it's tiny and only used by integration tests.
[[bin]]
name = "fake-mcp"
path = "tests/bin/fake_mcp.rs"
[dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time"] }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] }
arc-swap = "1"
getrandom = "0.2"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] }
nix = { version = "0.31", default-features = false, features = ["signal", "process"] }

View File

@@ -0,0 +1,284 @@
# sprout-agent
> Minimal, unbreakable ACP-compliant LLM agent. Stdio in, tool calls out. Non-streaming. No persistence. No cleverness.
[ACP](https://agentclientprotocol.com) is the Agent Client Protocol — JSON-RPC 2.0 over stdio between a client (Zed, JetBrains, sprout-acp, …) and an agent. [MCP](https://modelcontextprotocol.io) is how the agent talks to its tools.
`sprout-agent` is the agent.
## What It Is
```
+--------+ stdio (JSON-RPC 2.0) +---------------+
| client | <----------------------> | sprout-agent |
+--------+ ACP frames +---------------+
│ │
│ │ rmcp (stdio)
│ ▼
│ MCP servers
│ (your tools)
▼
HTTPS
│
▼
Anthropic Messages API
or any OpenAI-compat
(vLLM, llama.cpp, OpenRouter,
Block Gateway, Ollama, …)
```
A client sends `session/prompt`. The agent loops: call the LLM → get tool calls → run them via MCP → feed results back → repeat. The loop terminates when the LLM stops asking for tools, the round cap is hit, or the client cancels.
The agent's **output is its tool calls**. Generated text is forwarded to the client as `agent_message_chunk` updates, but the real work happens in the tools. The LLM call is non-streaming — one HTTP POST, one response.
## Quick Start
```bash
# Build
cargo build --release -p sprout-agent
# Run against Anthropic
SPROUT_AGENT_PROVIDER=anthropic \
ANTHROPIC_API_KEY=sk-ant-... \
ANTHROPIC_MODEL=claude-sonnet-4-5 \
./target/release/sprout-agent
# Or any OpenAI-compatible endpoint
SPROUT_AGENT_PROVIDER=openai \
OPENAI_COMPAT_API_KEY=sk-... \
OPENAI_COMPAT_MODEL=gpt-5 \
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
./target/release/sprout-agent
```
That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr.
## ACP Transcript
A complete round-trip. Lines starting with `→` are client→agent (stdin); `←` are agent→client (stdout). Each line is one newline-terminated JSON value. Comments are not part of the wire.
```jsonc
// 1. Handshake.
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
← {"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":1,
"agentCapabilities":{
"loadSession":false,
"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},
"mcpCapabilities":{"http":false,"sse":false}
},
"agentInfo":{"name":"sprout-agent","version":"0.1.0"}
}}
// 2. Open a session. The client passes the MCP servers to spawn.
→ {"jsonrpc":"2.0","id":2,"method":"session/new","params":{
"cwd":"/tmp",
"mcpServers":[{"name":"echo","command":"/usr/local/bin/echo-mcp","args":[],"env":[]}]
}}
← {"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_a1b2c3d4e5f6a7b8"}}
// 3. Prompt. The agent loops until the LLM stops calling tools.
→ {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{
"sessionId":"ses_a1b2c3d4e5f6a7b8",
"prompt":[{"type":"text","text":"echo hello"}]
}}
// 4. Agent emits tool_call (status: pending) — visible to the UI.
← {"jsonrpc":"2.0","method":"session/update","params":{
"sessionId":"ses_a1b2c3d4e5f6a7b8",
"update":{
"sessionUpdate":"tool_call",
"toolCallId":"toolu_01XYZ",
"title":"echo__say",
"kind":"other",
"status":"pending",
"rawInput":{"text":"hello"}
}
}}
// 5. Agent moves the call to in_progress, runs the MCP tool, then completed.
← {"jsonrpc":"2.0","method":"session/update","params":{
"sessionId":"ses_a1b2c3d4e5f6a7b8",
"update":{"sessionUpdate":"tool_call_update","toolCallId":"toolu_01XYZ","status":"in_progress"}
}}
← {"jsonrpc":"2.0","method":"session/update","params":{
"sessionId":"ses_a1b2c3d4e5f6a7b8",
"update":{
"sessionUpdate":"tool_call_update",
"toolCallId":"toolu_01XYZ",
"status":"completed",
"content":[{"type":"content","content":{"type":"text","text":"hello"}}]
}
}}
// 8. The model sees the result, decides it's done, and the prompt resolves.
← {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
```
That's ACP. Three request methods (`initialize`, `session/new`, `session/prompt`), one inbound notification (`session/cancel`), and three outbound update variants (`agent_message_chunk`, `tool_call`, `tool_call_update`). The full server is hand-rolled in `main.rs`.
## Configuration
Everything is environment variables. No flags, no config files. (We are a subprocess; subprocess config is environment.)
| Variable | Default | Notes |
|---|---|---|
| `SPROUT_AGENT_PROVIDER` | — | Required. `anthropic` or `openai`. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
| `SPROUT_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
| `SPROUT_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
| `SPROUT_AGENT_MAX_ROUNDS` | `16` | Tool-loop iteration cap. |
| `SPROUT_AGENT_MAX_OUTPUT_TOKENS` | `4096` | Per LLM call. |
| `SPROUT_AGENT_LLM_TIMEOUT_SECS` | `120` | |
| `SPROUT_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds |
| `SPROUT_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) |
| `SPROUT_AGENT_MAX_SESSIONS` | `8` | Max concurrent ACP sessions |
| `SPROUT_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
| `SPROUT_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
## Providers
`sprout-agent` speaks two HTTP dialects. Pick with `SPROUT_AGENT_PROVIDER`.
| Provider | `SPROUT_AGENT_PROVIDER` | Endpoint | Tested with |
|---|---|---|---|
| Anthropic | `anthropic` | `POST {base}/v1/messages` | claude-sonnet-4-5, claude-opus-4 |
| OpenAI | `openai` | `POST {base}/chat/completions` | gpt-5, gpt-4o |
| vLLM | `openai` | OpenAI-compatible endpoint | any tool-calling model |
| llama.cpp | `openai` | `--api-server` mode | any tool-calling GGUF |
| Ollama | `openai` | `http://localhost:11434/v1` | llama3.1, qwen2.5-coder |
| OpenRouter | `openai` | `https://openrouter.ai/api/v1` | anything they route |
| Block Gateway | `openai` | internal | gpt-5, claude |
The "OpenAI" path is wire-compatible with the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). If a provider claims OpenAI compatibility and supports `tools` + `tool_choice: auto`, it works here.
`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a third provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.
## MCP Servers
The client passes MCP server specs in `session/new`. The agent spawns each one as a stdio subprocess, calls `tools/list`, and merges everything into a single tool catalog the LLM sees. Tool names are namespaced as `server__tool` (double underscore separator). Bare tool names containing `__` are rejected at registration.
Example: a single echo MCP server.
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/work",
"mcpServers": [
{
"name": "echo",
"command": "/usr/local/bin/echo-mcp",
"args": ["--mode", "stdio"],
"env": [
{ "name": "ECHO_VERBOSE", "value": "1" }
]
}
]
}
}
```
Multiple servers: just add more entries. Tool calls fan out to the right server by namespace prefix.
**Transport: stdio only.** No HTTP, no SSE. We advertise this in `agentCapabilities` (`mcpCapabilities.http: false`, `mcpCapabilities.sse: false`); spec-compliant clients won't ask for what we don't have.
## Security Model
The trust boundary is **the operator who launched the agent**. The harness, MCP server binaries, and API keys are all trusted. Untrusted input — model output, tool results, prompts — is bounded.
| Boundary | Mechanism |
|---|---|
| Stdout discipline | Single-consumer `mpsc` channel feeding stdout. No two tasks can interleave bytes. All logs go to stderr. |
| MCP child env | Whitelist (`PATH`, `HOME`, `TERM`, `LANG`, `LC_ALL`, `TMPDIR`) plus what the client explicitly passes. Your `ANTHROPIC_API_KEY` does not leak into MCP children. |
| MCP child lifetime | Process group via `setpgid(0,0)` in `pre_exec`. On transport break or shutdown: `killpg(SIGKILL)`. Grandchildren die too. |
| Server poisoning | After a timeout or transport break, the offending server is marked dead. Future calls trigger a lazy restart with exponential backoff. Other servers keep working. |
| Frame size | `SPROUT_AGENT_MAX_LINE_BYTES` (default 4 MiB). Oversize → connection killed. |
| LLM response size | 16 MiB hard cap. Both `Content-Length` precheck and streaming-buffer cap. |
| Cancellation | `tokio::select! { biased; _ = cancel.changed() => ... }` at every loop boundary. Cancel always wins the race. |
| Session isolation | Up to 8 concurrent sessions (configurable). One prompt per session at a time. Each session gets its own MCP servers. |
| `tool_use ↔ tool_result` pairing | Encoded in the type system. Every `ToolCall` and `ToolResult` carries a `provider_id: String` (not `Option`). |
### Bounded Everything
| Limit | Default | Where |
|---|---|---|
| Inbound JSON-RPC frame | 4 MiB | `SPROUT_AGENT_MAX_LINE_BYTES` |
| Single prompt | 1 MiB | `MAX_PROMPT_BYTES` |
| History window | 1 MiB | `SPROUT_AGENT_MAX_HISTORY_BYTES` |
| LLM response body | 16 MiB | `MAX_LLM_RESPONSE_BYTES` |
| LLM error body | 4 KiB | `MAX_LLM_ERROR_BODY_BYTES` |
| Tool result body | 256 KiB | `MAX_TOOL_RESULT_BYTES` |
| MCP servers / session | 16 | `MAX_MCP_SERVERS` |
| Tools / session | 128 | `MAX_TOOLS_PER_SESSION` |
| Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` |
| Tool schema bytes | 4 KiB | `MAX_SCHEMA_BYTES` (oversize → replaced with `{}`) |
| Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` |
| Loop rounds | 16 | `SPROUT_AGENT_MAX_ROUNDS` |
| LLM call timeout | 120 s | `SPROUT_AGENT_LLM_TIMEOUT_SECS` |
| Tool call timeout | 660 s | `SPROUT_AGENT_TOOL_TIMEOUT_SECS` |
## What This Is NOT
A short list, because the answer is mostly "no":
- **Not a framework.** No plugins, no recipes, no slash commands, no modes. MCP servers can participate in agent lifecycle via [hook tools](../../docs/MCP_DRIVEN_HOOKS.md) (`_Stop`, `_PostCompact`), but these are advisory, fail-open, and budget-bounded — not a plugin system.
- **Not streaming.** One non-streaming HTTP POST per round. The LLM's generated text is forwarded to the client as `agent_message_chunk`, but there is no token-level streaming.
- **Not persistent.** Everything is in-memory, per-process. No SQLite. When context fills, the agent summarizes its own history and continues (context handoff). No external persistence.
- **Not an SDK.** This is a binary. The protocol seam is stdin/stdout. Use it from any language.
- **Not a UI.** No TUI, no web, no notifications. The client renders.
- **Not authenticated.** API keys come from env. Use systemd, Docker secrets, or a wrapper.
- **Not networked MCP.** Stdio transport only. No HTTP/SSE MCP transport.
- **Not load-able.** No `session/load`. We advertise `loadSession: false`.
- **Not a router.** No agent-to-agent, no fan-out, no orchestration. One model. One loop.
**Concurrency model:**
```
┌──── reader task ──────────┐
│ (stdin → JSON-RPC → ...) │
│ │
stdin ─────────┤ dispatch │
│ │ │
│ ├── initialize │ (sync reply)
│ ├── session/new │ (sync reply)
│ ├── session/prompt ───┼─── spawn ──> prompt task
│ │ │ │
│ ├── session/cancel ───┼─> watch::send│ (biased select wins)
│ │ │ │
└───────────────────────────┘ │
│
┌── writer task ────────────────┐ │
stdout ────────┤ mpsc<WireMsg> consumer │<─────────┘
│ (the only stdout writer) │
└───────────────────────────────┘
```
One reader, one writer, up to 8 concurrent prompt tasks (one per session).
## Building
```bash
cargo build --release -p sprout-agent
```
## Testing
```bash
cargo test -p sprout-agent
```
Test strategy is **real subprocess, no mocks**:
- **Fake LLM** — `tests/fake_llm.rs` and the helpers in `tests/regressions.rs` spin up a real `tokio::net::TcpListener` on port 0, parse `Content-Length`, and return scripted JSON. No HTTP mocking library.
- **Fake MCP server** — `tests/bin/fake_mcp.rs` is a separate binary controlled by env vars: `FAKE_MCP_HANG_INIT`, `FAKE_MCP_TOOL_DELAY`, `FAKE_MCP_SPAWN_GRANDCHILD`, etc. Each fault path is a real process being abused.
- **Regression tests are the changelog.** Each `#[test]` in `regressions.rs` is named for the bug it locks down: `assistant_text_preserved_across_prompts`, `cancel_leaves_history_valid_for_next_prompt`, `mcp_init_timeout_kills_child`, `oversize_line_kills_connection`. Read them in order to learn the protocol's failure modes.

View File

@@ -0,0 +1,598 @@
use std::sync::Arc;
use serde_json::json;
use tokio::sync::{watch, Semaphore};
use tokio::task::JoinSet;
use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES};
use crate::handoff::HandoffOutcome;
use crate::llm::Llm;
use crate::mcp::McpRegistry;
use crate::types::{
AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
};
use crate::wire::{self, WireSender};
const ERROR_REFLECTION_SUFFIX: &str =
"\n\n[Reflect] Before retrying, identify the cause and change your approach.";
pub struct RunCtx<'a> {
pub cfg: &'a Config,
pub session_id: &'a str,
pub llm: &'a Llm,
pub mcp: &'a Arc<McpRegistry>,
pub wire: &'a WireSender,
pub cancel: &'a mut watch::Receiver<bool>,
pub history: &'a mut Vec<HistoryItem>,
pub original_task: &'a mut Option<String>,
pub handoff_count: &'a mut usize,
/// Cumulative `_Stop` objection count for this session (persists
/// across `session/prompt` calls). Once it hits
/// `cfg.stop_max_rejections` we stop calling `_Stop` for that
/// session — a runaway hook can't burn rejections on every prompt.
pub stop_rejections: &'a mut u32,
}
impl RunCtx<'_> {
pub async fn run(&mut self, prompt: Vec<ContentBlock>) -> Result<StopReason, AgentError> {
let user_text = prompt_to_text(prompt)?;
if user_text.len() > MAX_PROMPT_BYTES {
return Err(AgentError::InvalidParams(format!(
"prompt: exceeds {MAX_PROMPT_BYTES} bytes"
)));
}
if self.original_task.is_none() {
*self.original_task = Some(user_text.clone());
}
self.history.push(HistoryItem::User(user_text));
let mut round = 0u32;
// Per-prompt latch: only used to detect "LLM said end_turn twice
// in a row with no tool calls between" within this single prompt.
// The cumulative rejection budget lives on the session.
let mut last_was_end_turn = false;
loop {
if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds {
return Ok(StopReason::MaxTurnRequests);
}
if *self.cancel.borrow() {
return Ok(StopReason::Cancelled);
}
match self.maybe_handoff().await {
HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled),
HandoffOutcome::Performed => {}
HandoffOutcome::Skipped => {
truncate_history(self.history, self.cfg.max_history_bytes)
}
}
let tools = self.mcp.tools();
round = round.saturating_add(1);
let response = tokio::select! {
biased;
_ = self.cancel.changed() => return Ok(StopReason::Cancelled),
r = self.llm.complete(self.cfg, self.history, &tools) => r?,
};
if !response.text.is_empty() {
wire::send(
self.wire,
wire::session_update(
self.session_id,
json!({
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": &response.text }
}),
),
)
.await;
}
if response.tool_calls.is_empty() {
if response.stop == ProviderStop::ToolUse {
return Err(AgentError::Llm(
"provider: stop=tool_use but zero tool_calls".into(),
));
}
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: Vec::new(),
});
let stop = map_stop(response.stop);
// Only gate genuine end_turn — don't override max_tokens/refusal.
if stop == StopReason::EndTurn {
// Consecutive-rejection rule: LLM responded to our last
// objection with no tool calls — accept the end and
// move on rather than loop forever.
if last_was_end_turn {
return Ok(stop);
}
if *self.stop_rejections >= self.cfg.stop_max_rejections {
return Ok(stop);
}
let objections = self
.mcp
.call_hooks(
"_Stop",
&json!({}),
self.cfg.hook_timeout,
&self.cfg.hook_servers,
)
.await;
if !objections.is_empty() {
*self.stop_rejections = self.stop_rejections.saturating_add(1);
last_was_end_turn = true;
push_hook_outputs_as_tool_results(self.history, "_Stop", &objections);
continue;
}
}
return Ok(stop);
}
let mut calls = response.tool_calls;
if calls.len() > MAX_TOOL_CALLS_PER_TURN {
tracing::warn!(
"capping tool_calls {} -> {MAX_TOOL_CALLS_PER_TURN}",
calls.len()
);
calls.truncate(MAX_TOOL_CALLS_PER_TURN);
}
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: calls.clone(),
});
// Tool calls executed → reset the consecutive-rejection latch.
last_was_end_turn = false;
if let Some(stop) = self.execute_calls(&calls).await {
return Ok(stop);
}
}
}
/// Unified tool-call execution. Three phases:
/// 1. Preflight (sequential): emit `pending`; unknown tools fail fast
/// with a synthetic result. Cancel here fills every still-empty
/// slot as cancelled.
/// 2. Execute: spawn runnable calls into a `JoinSet` bounded by a
/// `Semaphore(max_parallel_tools)`. `select!` between cancel and
/// `join_next`. On cancel: close semaphore, drain in-flight tasks
/// (each sends `notifications/cancelled` internally), synthesize
/// cancelled for unfilled slots and emit `failed`.
/// 3. Append: push results into history in original call order.
///
/// `max_parallel_tools = 1` makes phase 2 effectively sequential
/// (one in-flight call at a time via the semaphore). Larger values
/// run that many calls concurrently.
async fn execute_calls(&mut self, calls: &[ToolCall]) -> Option<StopReason> {
let mut results: Vec<Option<ToolResult>> = vec![None; calls.len()];
let mut runnable: Vec<usize> = Vec::with_capacity(calls.len());
for (idx, call) in calls.iter().enumerate() {
if *self.cancel.borrow() {
for (j, c) in calls.iter().enumerate() {
if results[j].is_none() {
// Calls 0..idx already had `pending` emitted; emit
// a terminal `failed` so the client doesn't see
// them stuck.
if j < idx {
emit_failed(self.wire, self.session_id, c, "cancelled").await;
}
results[j] = Some(synthetic_tool_result(c, "cancelled".into()));
}
}
self.append_results(calls, &mut results);
return Some(StopReason::Cancelled);
}
emit_pending(self.wire, self.session_id, call).await;
// Hook tools (bare name starts with `_`) are invisible to the
// LLM and only callable via `call_hooks`. Treat any direct
// invocation as if the tool didn't exist.
if !self.mcp.has(&call.name) || self.mcp.is_hook(&call.name) {
let err = format!("unknown tool: {}", call.name);
emit_failed(self.wire, self.session_id, call, &err).await;
results[idx] = Some(synthetic_tool_result(call, err));
continue;
}
runnable.push(idx);
}
self.execute_parallel(calls, &runnable, &mut results).await;
self.append_results(calls, &mut results);
if *self.cancel.borrow() {
Some(StopReason::Cancelled)
} else {
None
}
}
fn append_results(&mut self, calls: &[ToolCall], results: &mut [Option<ToolResult>]) {
for (i, call) in calls.iter().enumerate() {
let mut result = results[i].take().unwrap_or_else(|| ToolResult {
provider_id: call.provider_id.clone(),
text: "internal error: missing result".into(),
is_error: true,
});
// On tool error: append a reflection prompt so the LLM
// diagnoses the failure before blindly retrying.
if result.is_error {
result.text.push_str(ERROR_REFLECTION_SUFFIX);
}
self.history.push(HistoryItem::ToolResult(result));
}
}
async fn execute_parallel(
&mut self,
calls: &[ToolCall],
runnable: &[usize],
results: &mut [Option<ToolResult>],
) {
let limit = self.cfg.max_parallel_tools.max(1);
let sem = Arc::new(Semaphore::new(limit));
let mut set: JoinSet<(usize, InvokeOutcome)> = JoinSet::new();
for &i in runnable {
let call = calls[i].clone();
let mcp = Arc::clone(self.mcp);
let wire = self.wire.clone();
let session_id = self.session_id.to_owned();
let timeout = self.cfg.tool_timeout;
let cancel = self.cancel.clone();
let sem = Arc::clone(&sem);
set.spawn(async move {
// Acquire a permit; if the semaphore is closed (cancel),
// emit a terminal wire update and skip the call.
let _permit = match sem.acquire_owned().await {
Ok(p) => p,
Err(_) => {
emit_failed(&wire, &session_id, &call, "cancelled").await;
return (i, InvokeOutcome::Failed("cancelled".into()));
}
};
emit_in_progress(&wire, &session_id, &call).await;
let outcome = invoke_tool_inner(&mcp, &call, timeout, cancel).await;
match &outcome {
InvokeOutcome::Done(result) => {
emit_completed(&wire, &session_id, &call, result).await;
}
InvokeOutcome::Failed(msg) => {
emit_failed(&wire, &session_id, &call, msg).await;
}
}
(i, outcome)
});
}
let mut cancel_rx = self.cancel.clone();
let mut cancelled = if *cancel_rx.borrow() {
sem.close();
true
} else {
false
};
while !cancelled {
tokio::select! {
biased;
_ = cancel_rx.changed() => {
// Cancel: stop accepting new permits. Do NOT abort
// tasks — each in-flight `mcp.call` observes the same
// cancel receiver via its internal `select!` and
// returns promptly with an "cancelled" error after
// sending `notifications/cancelled` to the server.
sem.close();
cancelled = true;
break;
}
next = set.join_next() => {
match next {
Some(Ok((i, outcome))) => {
results[i] = Some(outcome_to_result(&calls[i], outcome));
}
Some(Err(e)) => {
tracing::warn!("tool task join error: {e}");
}
None => break,
}
}
}
}
// After cancel, drain in-flight tasks. Each task's internal
// `do_call` observes the cancel receiver and returns promptly
// after sending `notifications/cancelled`. We bound the drain
// to avoid hanging if a task is stuck in restart/reconnect.
if cancelled {
let drain_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match tokio::time::timeout_at(drain_deadline, set.join_next()).await {
Ok(Some(Ok((i, outcome)))) => {
if results[i].is_none() {
results[i] = Some(outcome_to_result(&calls[i], outcome));
}
}
Ok(Some(Err(e))) => {
tracing::warn!("tool task join error (drain): {e}");
}
Ok(None) => break, // all tasks drained
Err(_) => {
// Drain timed out — abort remaining tasks.
set.abort_all();
tracing::warn!("cancel drain timed out; aborting remaining tasks");
break;
}
}
}
}
// Fill any remaining unfilled runnable slots as cancelled. Tasks
// that didn't complete (timed out in drain or never started) need
// a terminal wire update so the client doesn't see "pending" forever.
for &i in runnable {
if results[i].is_none() {
results[i] = Some(synthetic_tool_result(&calls[i], "cancelled".into()));
emit_failed(self.wire, self.session_id, &calls[i], "cancelled").await;
}
}
}
}
/// Outcome of invoking a single tool. The wire notification is emitted by
/// the caller so the spawn loop and the (degenerate, max_parallel=1) path
/// share the same logic.
enum InvokeOutcome {
Done(ToolResult),
Failed(String),
}
/// Standalone tool invocation. Takes only owned/cloned handles so it can
/// run inside a spawned task. On timeout, kills the offending MCP server's
/// process group and marks it dead; the registry's lazy restart handles it
/// on the next call.
async fn invoke_tool_inner(
mcp: &Arc<McpRegistry>,
call: &ToolCall,
tool_timeout: std::time::Duration,
mut cancel: watch::Receiver<bool>,
) -> InvokeOutcome {
if *cancel.borrow() {
return InvokeOutcome::Failed("cancelled".into());
}
match tokio::time::timeout(
tool_timeout,
mcp.call(
&call.name,
&call.provider_id,
&call.arguments,
MAX_TOOL_RESULT_BYTES,
&mut cancel,
),
)
.await
{
Ok(Ok(result)) => InvokeOutcome::Done(result),
Ok(Err(AgentError::Cancelled)) => InvokeOutcome::Failed("cancelled".into()),
Ok(Err(e)) => InvokeOutcome::Failed(e.to_string()),
Err(_) => {
// If the session was cancelled, the timeout fired because
// do_call returned quickly with "cancelled" and the outer
// timeout raced. Don't kill a healthy server for that.
if *cancel.borrow() {
return InvokeOutcome::Failed("cancelled".into());
}
if let Some(server) = mcp.server_of(&call.name) {
mcp.kill_server(server, "tool timeout");
}
let msg = format!(
"tool: timeout after {}s. The command took too long. Try a faster approach.",
tool_timeout.as_secs()
);
InvokeOutcome::Failed(msg)
}
}
}
fn outcome_to_result(call: &ToolCall, outcome: InvokeOutcome) -> ToolResult {
match outcome {
InvokeOutcome::Done(r) => r,
InvokeOutcome::Failed(m) => synthetic_tool_result(call, m),
}
}
async fn emit_pending(wire: &WireSender, sid: &str, call: &ToolCall) {
wire::send(
wire,
wire::session_update(
sid,
json!({
"sessionUpdate": "tool_call",
"toolCallId": call.provider_id,
"title": call.name,
"kind": "other",
"status": "pending",
"rawInput": call.arguments,
}),
),
)
.await;
}
async fn emit_in_progress(wire: &WireSender, sid: &str, call: &ToolCall) {
wire::send(
wire,
wire::session_update(
sid,
json!({
"sessionUpdate": "tool_call_update",
"toolCallId": call.provider_id,
"status": "in_progress",
}),
),
)
.await;
}
async fn emit_completed(wire: &WireSender, sid: &str, call: &ToolCall, result: &ToolResult) {
wire::send(
wire,
wire::session_update(
sid,
json!({
"sessionUpdate": "tool_call_update",
"toolCallId": call.provider_id,
"status": "completed",
"content": [{ "type": "content", "content": { "type": "text", "text": result.text } }],
"rawOutput": { "isError": result.is_error },
}),
),
)
.await;
}
async fn emit_failed(wire: &WireSender, sid: &str, call: &ToolCall, err: &str) {
wire::send(
wire,
wire::session_update(
sid,
json!({
"sessionUpdate": "tool_call_update",
"toolCallId": call.provider_id,
"status": "failed",
"rawOutput": { "error": err },
}),
),
)
.await;
}
fn prompt_to_text(prompt: Vec<ContentBlock>) -> Result<String, AgentError> {
let mut parts = Vec::with_capacity(prompt.len());
for block in prompt {
match block {
ContentBlock::Text { text } => parts.push(text),
ContentBlock::ResourceLink { uri } => parts.push(format!("[resource: {uri}]")),
ContentBlock::Unsupported => {
return Err(AgentError::InvalidParams(
"prompt: unsupported content block (only text and resource_link are advertised)".into(),
));
}
}
}
Ok(parts.join("\n"))
}
/// Format a single hook output as a structured tool-result body.
///
/// We emit a JSON object rather than XML-style tags. JSON is unambiguous:
/// the inner `text` field is escaped, so a malicious hook cannot break
/// out by including a literal `</hook_output>` (or any other delimiter)
/// in its output. The LLM still sees the source attribution via the
/// `hook` and `server` fields.
fn format_hook_output_body(hook: &str, server: &str, text: &str) -> String {
// serde_json::to_string never fails on owned strings.
serde_json::to_string(&json!({
"hook": hook,
"server": server,
"text": text,
}))
.unwrap_or_else(|_| String::from("{\"hook\":\"\",\"server\":\"\",\"text\":\"\"}"))
}
/// Synthetic provider id for an injected hook tool-call/result pair. Must
/// be unique per pair so the LLM wire format (which keys tool results by
/// id) stays valid across multiple objections in one session.
fn synthetic_hook_id(hook: &str, server: &str, ordinal: u64) -> String {
format!("sprout_hook_{hook}_{server}_{ordinal}")
}
/// Append a synthetic Assistant tool-call + ToolResult pair for each hook
/// output. Modeling hook output as a tool result (rather than as a User
/// message) means a malicious hook can't impersonate the user or system
/// — the LLM treats tool results as lower-trust, structured data.
///
/// Each pair uses the hook's qualified tool name (e.g. `fake___Stop`) so
/// attribution is preserved in the wire format. Empty arguments are sent
/// as `{}`. The `Assistant` turn carries no text (tool_calls only).
pub(crate) fn push_hook_outputs_as_tool_results(
history: &mut Vec<HistoryItem>,
hook: &str,
outputs: &[(String, String)],
) {
for (server, text) in outputs.iter() {
let provider_id = synthetic_hook_id(hook, server, unique_nonce());
// Tool name is `<server>__<hook>` — same shape as a real qname
// for that hook, so the LLM never sees an unknown synthetic name.
let tool_name = format!("{server}__{hook}");
history.push(HistoryItem::Assistant {
text: String::new(),
tool_calls: vec![ToolCall {
provider_id: provider_id.clone(),
name: tool_name,
arguments: serde_json::json!({}),
}],
});
history.push(HistoryItem::ToolResult(ToolResult {
provider_id,
text: format_hook_output_body(hook, server, text),
is_error: false,
}));
}
}
/// Monotonic counter for synthetic hook ids within a single process. The
/// uniqueness target is "no collision within the lifetime of one history
/// vec", which a process-wide counter satisfies trivially.
fn unique_nonce() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed)
}
fn synthetic_tool_result(call: &ToolCall, msg: String) -> ToolResult {
ToolResult {
provider_id: call.provider_id.clone(),
text: msg,
is_error: true,
}
}
pub(crate) fn truncate_history(history: &mut Vec<HistoryItem>, max_bytes: usize) {
let mut total: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
if total <= max_bytes {
return;
}
let original_len = history.len();
while total > max_bytes && !history.is_empty() {
let mut end = 1usize;
while end < history.len() && !matches!(history[end], HistoryItem::User(_)) {
end += 1;
}
if end >= history.len() {
break;
}
let dropped: usize = history[..end]
.iter()
.map(HistoryItem::estimated_bytes)
.sum();
history.drain(..end);
total = total.saturating_sub(dropped);
}
if history.len() < original_len {
tracing::info!(
"history truncated {original_len} -> {} items ({total} bytes)",
history.len()
);
}
}
fn map_stop(p: ProviderStop) -> StopReason {
match p {
ProviderStop::EndTurn | ProviderStop::ToolUse | ProviderStop::Other => StopReason::EndTurn,
ProviderStop::MaxTokens => StopReason::MaxTokens,
ProviderStop::Refusal => StopReason::Refusal,
}
}

View File

@@ -0,0 +1,340 @@
use std::time::Duration;
pub const PROTOCOL_VERSION: u32 = 1;
pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
pub const MAX_TOOL_RESULT_BYTES: usize = 256 * 1024;
pub const MAX_TOOL_CALLS_PER_TURN: usize = 64;
/// Leaves headroom for the summary call.
pub const HANDOFF_THRESHOLD: f64 = 0.75;
pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192;
pub const HANDOFF_TAIL_ITEMS: usize = 5;
pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024;
pub const HANDOFF_PROMPT_MAX_BYTES: usize = 32 * 1024;
pub const HANDOFF_MAX_TOOL_NAMES: usize = 20;
const DEFAULT_SYSTEM_PROMPT: &str =
"You are sprout-agent. Use the provided tools to act. Tool calls are your only output.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Provider {
Anthropic,
OpenAi,
}
#[derive(Debug, Clone)]
pub struct Config {
pub provider: Provider,
pub system_prompt: String,
pub max_rounds: u32,
pub max_output_tokens: u32,
pub llm_timeout: Duration,
pub tool_timeout: Duration,
pub mcp_init_timeout: Duration,
pub mcp_max_restart_attempts: u32,
pub mcp_restart_base_ms: u64,
pub mcp_restart_max_ms: u64,
pub max_sessions: usize,
pub max_line_bytes: usize,
pub max_history_bytes: usize,
pub max_handoffs: usize,
pub max_parallel_tools: usize,
pub hook_timeout: Duration,
/// Maximum `_Stop` rejections per session. Default 3. Set to 0 to
/// disable `_Stop` hooks entirely (agent always honors end_turn).
pub stop_max_rejections: u32,
/// Hook server allowlist. See [`HookServers`] for variant semantics.
/// Default (env unset/empty) is `None` — hooks are off unless the
/// operator explicitly opts in.
pub hook_servers: HookServers,
pub api_key: String,
pub model: String,
pub base_url: String,
pub anthropic_api_version: String,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
let provider = match req("SPROUT_AGENT_PROVIDER")?.to_ascii_lowercase().as_str() {
"anthropic" => Provider::Anthropic,
"openai" | "openai-compat" => Provider::OpenAi,
o => return Err(format!("config: SPROUT_AGENT_PROVIDER={o} not supported")),
};
let (api_key, model, base_url) = match provider {
Provider::Anthropic => (
req("ANTHROPIC_API_KEY")?,
req("ANTHROPIC_MODEL")?,
env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"),
),
Provider::OpenAi => (
req("OPENAI_COMPAT_API_KEY")?,
req("OPENAI_COMPAT_MODEL")?,
env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"),
),
};
let system_prompt = match (env("SPROUT_AGENT_SYSTEM_PROMPT"), env("SPROUT_AGENT_SYSTEM_PROMPT_FILE")) {
(Some(_), Some(_)) => return Err(
"config: SPROUT_AGENT_SYSTEM_PROMPT and SPROUT_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()),
(Some(s), _) => s,
(_, Some(p)) => std::fs::read_to_string(&p).map_err(|e| format!("config: read {p}: {e}"))?,
_ => DEFAULT_SYSTEM_PROMPT.to_owned(),
};
let cfg = Config {
provider,
system_prompt,
api_key,
model,
base_url,
anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"),
max_rounds: parse_env("SPROUT_AGENT_MAX_ROUNDS", 16)?,
max_output_tokens: parse_env("SPROUT_AGENT_MAX_OUTPUT_TOKENS", 4096)?,
llm_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_LLM_TIMEOUT_SECS", 120)?),
tool_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", 660)?),
mcp_init_timeout: Duration::from_secs(parse_env(
"SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS",
30,
)?),
mcp_max_restart_attempts: parse_env("SPROUT_AGENT_MCP_RESTART_MAX_ATTEMPTS", 3u32)?,
mcp_restart_base_ms: parse_env("SPROUT_AGENT_MCP_RESTART_BASE_MS", 500u64)?,
mcp_restart_max_ms: parse_env("SPROUT_AGENT_MCP_RESTART_MAX_MS", 30_000u64)?,
max_sessions: parse_env("SPROUT_AGENT_MAX_SESSIONS", 8)?,
max_line_bytes: parse_env("SPROUT_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?,
max_history_bytes: parse_env("SPROUT_AGENT_MAX_HISTORY_BYTES", 1024 * 1024)?,
max_handoffs: parse_env("SPROUT_AGENT_MAX_HANDOFFS", 5)?,
max_parallel_tools: parse_env("SPROUT_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
hook_timeout: Duration::from_millis(parse_env(
"SPROUT_AGENT_HOOK_TIMEOUT_MS",
2500u64,
)?),
stop_max_rejections: parse_env("SPROUT_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
};
cfg.validate()?;
Ok(cfg)
}
fn validate(&self) -> Result<(), String> {
const MIN_HISTORY_BYTES: usize = 4096;
const MIN_LINE_BYTES: usize = 1024;
const MIN_TIMEOUT: Duration = Duration::from_secs(1);
if self.max_output_tokens < 1 {
return Err("config: SPROUT_AGENT_MAX_OUTPUT_TOKENS must be >= 1".into());
}
if self.max_history_bytes < MIN_HISTORY_BYTES {
return Err(format!(
"config: SPROUT_AGENT_MAX_HISTORY_BYTES must be >= {MIN_HISTORY_BYTES}"
));
}
if self.max_history_bytes < MAX_PROMPT_BYTES {
return Err(format!(
"config: SPROUT_AGENT_MAX_HISTORY_BYTES ({}) must be >= MAX_PROMPT_BYTES ({MAX_PROMPT_BYTES})",
self.max_history_bytes
));
}
if self.max_line_bytes < MIN_LINE_BYTES {
return Err(format!(
"config: SPROUT_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}"
));
}
if self.llm_timeout < MIN_TIMEOUT {
return Err("config: SPROUT_AGENT_LLM_TIMEOUT_SECS must be >= 1".into());
}
if self.tool_timeout < MIN_TIMEOUT {
return Err("config: SPROUT_AGENT_TOOL_TIMEOUT_SECS must be >= 1".into());
}
if self.mcp_init_timeout < MIN_TIMEOUT {
return Err("config: SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS must be >= 1".into());
}
if self.max_parallel_tools < 1 {
return Err("config: SPROUT_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into());
}
if self.mcp_max_restart_attempts < 1 {
return Err("config: SPROUT_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into());
}
if self.mcp_restart_base_ms < 1 {
return Err("config: SPROUT_AGENT_MCP_RESTART_BASE_MS must be >= 1".into());
}
if self.mcp_restart_max_ms < self.mcp_restart_base_ms {
return Err(
"config: SPROUT_AGENT_MCP_RESTART_MAX_MS must be >= SPROUT_AGENT_MCP_RESTART_BASE_MS".into(),
);
}
Ok(())
}
}
fn env(k: &str) -> Option<String> {
std::env::var(k).ok()
}
fn env_or(k: &str, d: &str) -> String {
env(k).unwrap_or_else(|| d.into())
}
fn req(k: &str) -> Result<String, String> {
env(k).ok_or_else(|| format!("config: {k} required"))
}
fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, String>
where
T::Err: std::fmt::Display,
{
env(key)
.map(|v| v.parse().map_err(|e| format!("config: {key}: {e}")))
.unwrap_or(Ok(default))
}
/// Hook-server allowlist parsed from a comma-separated env var.
/// - unset / empty / whitespace-only → `None` (no hooks enabled)
/// - `*` → `All` (every server eligible)
/// - `a,b,c` → `Only(["a","b","c"])`
#[derive(Debug, Clone)]
pub enum HookServers {
None,
All,
Only(Vec<String>),
}
impl HookServers {
/// Returns true iff `name` may receive hook calls.
pub fn allows(&self, name: &str) -> bool {
match self {
HookServers::None => false,
HookServers::All => true,
HookServers::Only(v) => v.iter().any(|s| s == name),
}
}
/// True if no hooks should ever fire — used to short-circuit dispatch.
pub fn is_disabled(&self) -> bool {
matches!(self, HookServers::None)
}
}
fn parse_hook_servers_env(key: &str) -> HookServers {
parse_hook_servers(env(key).as_deref())
}
/// Pure parser exposed for unit tests. `None` (env unset) and `Some("")`
/// (env set but empty) both yield `HookServers::None`.
fn parse_hook_servers(raw: Option<&str>) -> HookServers {
let raw = match raw {
Some(v) => v,
None => return HookServers::None,
};
let names: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
if names.is_empty() {
return HookServers::None;
}
// `*` is the wildcard — only honored when it's the sole entry. A mixed
// value like "*,foo" falls through to `Only(["*","foo"])`; "*" is not a
// legal MCP server name (it can't pass `valid_name`), so it never matches
// an actual server. This avoids silently widening scope on typos.
if names.len() == 1 && names[0] == "*" {
return HookServers::All;
}
HookServers::Only(names)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_servers_unset_is_none() {
assert!(matches!(parse_hook_servers(None), HookServers::None));
}
#[test]
fn hook_servers_empty_string_is_none() {
assert!(matches!(parse_hook_servers(Some("")), HookServers::None));
}
#[test]
fn hook_servers_whitespace_only_is_none() {
assert!(matches!(
parse_hook_servers(Some(" ,, ,")),
HookServers::None
));
}
#[test]
fn hook_servers_star_is_all() {
assert!(matches!(parse_hook_servers(Some("*")), HookServers::All));
}
#[test]
fn hook_servers_star_with_whitespace_is_all() {
assert!(matches!(
parse_hook_servers(Some(" * ")),
HookServers::All
));
}
#[test]
fn hook_servers_named_list() {
match parse_hook_servers(Some("foo,bar")) {
HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]),
other => panic!("expected Only, got {other:?}"),
}
}
#[test]
fn hook_servers_trims_entries() {
match parse_hook_servers(Some(" foo , bar , ")) {
HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]),
other => panic!("expected Only, got {other:?}"),
}
}
#[test]
fn hook_servers_star_mixed_is_literal() {
// `*,foo` is NOT a wildcard — it's a literal Only(["*","foo"]).
// No real server can be named `*`, so this never matches anything.
match parse_hook_servers(Some("*,foo")) {
HookServers::Only(v) => assert_eq!(v, vec!["*".to_owned(), "foo".to_owned()]),
other => panic!("expected Only, got {other:?}"),
}
}
#[test]
fn hook_servers_allows_matches_named_only() {
let hs = parse_hook_servers(Some("foo,bar"));
assert!(hs.allows("foo"));
assert!(hs.allows("bar"));
assert!(!hs.allows("baz"));
}
#[test]
fn hook_servers_allows_matches_all() {
assert!(parse_hook_servers(Some("*")).allows("anything"));
}
#[test]
fn hook_servers_allows_blocks_when_none() {
assert!(!parse_hook_servers(None).allows("foo"));
}
#[test]
fn hook_servers_star_mixed_does_not_match_real_server() {
let hs = parse_hook_servers(Some("*,foo"));
// The literal "*" entry exists in Only, but no real server can
// be named "*" (rejected by the MCP server name validator).
assert!(hs.allows("foo"));
assert!(!hs.allows("bar"));
// Allowed strictly only as a literal match — defense-in-depth
// expectation for callers.
assert!(hs.allows("*"));
}
}

View File

@@ -0,0 +1,210 @@
use crate::agent::{push_hook_outputs_as_tool_results, RunCtx};
use crate::config::{
HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES,
HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS, HANDOFF_THRESHOLD,
};
use crate::types::HistoryItem;
pub(crate) enum HandoffOutcome {
Performed,
Skipped,
Cancelled,
}
const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \
turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \
you accomplished, key decisions made, what remains, and one concrete next step. Output plain \
text only — no tool calls, no JSON. Stay under 8192 tokens.";
const HANDOFF_SNIPPET_BYTES: usize = 2048;
impl RunCtx<'_> {
pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome {
if !self.should_handoff() {
return HandoffOutcome::Skipped;
}
if *self.handoff_count >= self.cfg.max_handoffs {
tracing::info!(
"handoff cap reached ({}); using truncation",
self.cfg.max_handoffs
);
return HandoffOutcome::Skipped;
}
let prompt = self.build_handoff_prompt();
let summary = tokio::select! {
biased;
_ = self.cancel.changed() => return HandoffOutcome::Cancelled,
r = self.llm.summarize(
self.cfg,
HANDOFF_SYSTEM_PROMPT,
&prompt,
HANDOFF_MAX_OUTPUT_TOKENS,
) => match r {
Ok(s) if !s.trim().is_empty() => s,
Ok(_) => {
tracing::warn!("handoff returned empty summary; truncating");
return HandoffOutcome::Skipped;
}
Err(e) => {
tracing::warn!("handoff failed: {e}; truncating");
return HandoffOutcome::Skipped;
}
},
};
let current_prompt = self.history.iter().rev().find_map(|item| match item {
HistoryItem::User(s) => Some(s.clone()),
_ => None,
});
let prior = self.history.len();
// Reset history first; the _PostCompact hook is meant to inject
// state into the FRESH context, not the old one we're discarding.
self.history.clear();
let post_compact = self
.mcp
.call_hooks(
"_PostCompact",
&serde_json::json!({}),
self.cfg.hook_timeout,
&self.cfg.hook_servers,
)
.await;
// Handoff summary is trusted (we generated it). Push as User so
// it anchors the new context.
let handoff_text = format!("[Context Handoff]\n{summary}");
self.history.push(HistoryItem::User(handoff_text));
// Hook output is untrusted — inject as synthetic tool results so a
// malicious _PostCompact can't impersonate the user/system.
if !post_compact.is_empty() {
push_hook_outputs_as_tool_results(self.history, "_PostCompact", &post_compact);
}
if let Some(prompt) = current_prompt {
self.history.push(HistoryItem::User(prompt));
}
*self.handoff_count += 1;
tracing::info!(
"handoff #{} (history {prior} -> {} items)",
*self.handoff_count,
self.history.len()
);
HandoffOutcome::Performed
}
fn should_handoff(&self) -> bool {
let usage: usize = self.history.iter().map(HistoryItem::estimated_bytes).sum();
let threshold = (self.cfg.max_history_bytes as f64 * HANDOFF_THRESHOLD) as usize;
usage > threshold
}
fn build_handoff_prompt(&self) -> String {
let mut head = String::new();
head.push_str(&format!(
"[Internal handoff #{} — context reset]\n\n",
*self.handoff_count + 1
));
head.push_str("# Original Task\n");
let task = self.original_task.as_deref().unwrap_or("(unknown)");
head.push_str(&clamp_bytes(task, HANDOFF_ORIGINAL_TASK_MAX_BYTES));
head.push_str("\n\n# Available Tools\n");
let all_tools = self.mcp.tools();
let total = all_tools.len();
if total == 0 {
head.push_str("(none)\n");
} else {
let shown = total.min(HANDOFF_MAX_TOOL_NAMES);
let names: Vec<&str> = all_tools[..shown].iter().map(|t| t.name.as_str()).collect();
head.push_str(&names.join(", "));
if shown < total {
head.push_str(&format!(", … (+{} more)", total - shown));
}
head.push('\n');
}
let tail = "\n# Instructions\n\
Produce a context handoff summary covering: (1) original task, \
(2) what was accomplished, (3) key decisions, (4) what remains, \
(5) one concrete next step. Be concise but thorough. Plain text.\n";
let history_header = "\n# Recent History (most recent last)\n";
let start = self.history.len().saturating_sub(HANDOFF_TAIL_ITEMS);
let mut snippets: Vec<String> = self.history[start..]
.iter()
.map(|item| {
let mut s = String::new();
push_history_snippet(&mut s, item);
s
})
.collect();
let fixed = head.len() + history_header.len() + tail.len();
let mut snippets_bytes: usize = snippets.iter().map(String::len).sum();
let mut dropped = 0usize;
while fixed + snippets_bytes > HANDOFF_PROMPT_MAX_BYTES && !snippets.is_empty() {
let removed = snippets.remove(0);
snippets_bytes -= removed.len();
dropped += 1;
}
if dropped > 0 {
tracing::info!("handoff prompt cap, dropped {dropped} oldest snippets");
}
let mut out =
String::with_capacity(fixed + snippets_bytes + if dropped > 0 { 32 } else { 0 });
out.push_str(&head);
out.push_str(history_header);
if dropped > 0 {
out.push_str(&format!("(… {dropped} older items omitted)\n"));
}
for s in &snippets {
out.push_str(s);
}
out.push_str(tail);
out
}
}
fn push_history_snippet(out: &mut String, item: &HistoryItem) {
match item {
HistoryItem::User(s) => {
out.push_str("[user] ");
out.push_str(&clamp_for_snippet(s));
out.push('\n');
}
HistoryItem::Assistant { text, tool_calls } => {
out.push_str("[assistant] ");
if !text.is_empty() {
out.push_str(&clamp_for_snippet(text));
}
for c in tool_calls {
out.push_str(&format!(" tool:{}", c.name));
}
out.push('\n');
}
HistoryItem::ToolResult(r) => {
out.push_str(if r.is_error { "[tool_err] " } else { "[tool] " });
out.push_str(&clamp_for_snippet(&r.text));
out.push('\n');
}
}
}
fn clamp_for_snippet(s: &str) -> String {
clamp_bytes(s, HANDOFF_SNIPPET_BYTES)
}
pub(crate) fn clamp_bytes(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_owned();
}
if max_bytes < 4 {
let mut cut = max_bytes.min(s.len());
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
return s[..cut].to_owned();
}
let target = max_bytes - "…".len();
let mut cut = target;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
format!("{}…", &s[..cut])
}

View File

@@ -0,0 +1,388 @@
use reqwest::Client;
use serde_json::{json, Value};
use crate::config::{Config, Provider};
use crate::types::{AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef};
const MAX_LLM_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024;
pub struct Llm {
http: Client,
}
impl Llm {
pub fn new(cfg: &Config) -> Result<Self, AgentError> {
let http = Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(cfg.llm_timeout)
.build()
.map_err(|e| AgentError::Llm(format!("http: {e}")))?;
Ok(Self { http })
}
pub async fn complete(
&self,
cfg: &Config,
history: &[HistoryItem],
tools: &[ToolDef],
) -> Result<LlmResponse, AgentError> {
match cfg.provider {
Provider::Anthropic => {
let body = anthropic_body(cfg, history, tools);
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
let v = post(&self.http, &url, &body, |r| {
r.header("x-api-key", &cfg.api_key)
.header("anthropic-version", &cfg.anthropic_api_version)
.header("content-type", "application/json")
})
.await?;
parse_anthropic(v)
}
Provider::OpenAi => {
let body = openai_body(cfg, history, tools);
let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
let v = post(&self.http, &url, &body, |r| {
r.bearer_auth(&cfg.api_key)
.header("content-type", "application/json")
})
.await?;
parse_openai(v)
}
}
}
pub async fn summarize(
&self,
cfg: &Config,
system_prompt: &str,
user_prompt: &str,
max_output_tokens: u32,
) -> Result<String, AgentError> {
match cfg.provider {
Provider::Anthropic => {
let body = json!({
"model": cfg.model,
"max_tokens": max_output_tokens,
"system": system_prompt,
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": user_prompt }],
}],
});
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
let v = post(&self.http, &url, &body, |r| {
r.header("x-api-key", &cfg.api_key)
.header("anthropic-version", &cfg.anthropic_api_version)
.header("content-type", "application/json")
})
.await?;
Ok(parse_anthropic(v)?.text)
}
Provider::OpenAi => {
let body = json!({
"model": cfg.model,
"stream": false,
"max_tokens": max_output_tokens,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
});
let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
let v = post(&self.http, &url, &body, |r| {
r.bearer_auth(&cfg.api_key)
.header("content-type", "application/json")
})
.await?;
Ok(parse_openai(v)?.text)
}
}
}
}
fn anthropic_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
let mut messages: Vec<Value> = Vec::new();
let mut pending: Vec<Value> = Vec::new();
let flush = |out: &mut Vec<Value>, p: &mut Vec<Value>| {
if !p.is_empty() {
out.push(json!({ "role": "user", "content": std::mem::take(p) }));
}
};
for item in history {
match item {
HistoryItem::User(text) => {
flush(&mut messages, &mut pending);
messages.push(json!({ "role": "user",
"content": [{ "type": "text", "text": text }] }));
}
HistoryItem::Assistant { text, tool_calls } => {
flush(&mut messages, &mut pending);
let mut content: Vec<Value> = Vec::new();
if !text.is_empty() {
content.push(json!({ "type": "text", "text": text }));
}
for c in tool_calls {
content.push(json!({ "type": "tool_use", "id": c.provider_id,
"name": c.name, "input": c.arguments }));
}
if content.is_empty() {
content.push(json!({ "type": "text", "text": "" }));
}
messages.push(json!({ "role": "assistant", "content": content }));
}
HistoryItem::ToolResult(r) => pending.push(json!({
"type": "tool_result", "tool_use_id": r.provider_id,
"content": [{ "type": "text", "text": r.text }], "is_error": r.is_error })),
}
}
flush(&mut messages, &mut pending);
let tools_json: Vec<Value> = tools
.iter()
.map(|t| {
json!({
"name": t.name, "description": t.description, "input_schema": t.input_schema })
})
.collect();
let mut body = json!({ "model": cfg.model, "max_tokens": cfg.max_output_tokens,
"system": cfg.system_prompt, "messages": messages });
if !tools_json.is_empty() {
body["tools"] = Value::Array(tools_json);
}
body
}
fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": cfg.system_prompt })];
for item in history {
match item {
HistoryItem::User(text) => messages.push(json!({ "role": "user", "content": text })),
HistoryItem::Assistant { text, tool_calls } => {
let mut msg = serde_json::Map::new();
msg.insert("role".into(), json!("assistant"));
msg.insert("content".into(), json!(text.as_str()));
if !tool_calls.is_empty() {
let calls: Vec<Value> = tool_calls
.iter()
.map(|c| {
json!({
"id": c.provider_id, "type": "function",
"function": { "name": c.name,
"arguments": serde_json::to_string(&c.arguments)
.unwrap_or_else(|_| "{}".into()) } })
})
.collect();
msg.insert("tool_calls".into(), Value::Array(calls));
}
messages.push(Value::Object(msg));
}
HistoryItem::ToolResult(r) => messages.push(json!({
"role": "tool", "tool_call_id": r.provider_id, "content": r.text })),
}
}
let tools_json: Vec<Value> = tools
.iter()
.map(|t| {
json!({
"type": "function",
"function": { "name": t.name, "description": t.description,
"parameters": t.input_schema } })
})
.collect();
let mut body = json!({ "model": cfg.model, "stream": false, "max_tokens": cfg.max_output_tokens,
"messages": messages });
if !tools_json.is_empty() {
body["tools"] = Value::Array(tools_json);
body["tool_choice"] = json!("auto");
}
body
}
fn map_stop(s: Option<&str>) -> ProviderStop {
match s {
Some("end_turn" | "stop") => ProviderStop::EndTurn,
Some("tool_use" | "tool_calls") => ProviderStop::ToolUse,
Some("max_tokens" | "length") => ProviderStop::MaxTokens,
Some("refusal" | "content_filter") => ProviderStop::Refusal,
_ => ProviderStop::Other,
}
}
fn str_field(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
}
fn parse_anthropic(v: Value) -> Result<LlmResponse, AgentError> {
let stop = map_stop(v.get("stop_reason").and_then(Value::as_str));
let mut tool_calls = Vec::new();
let mut text = String::new();
if let Some(blocks) = v.get("content").and_then(Value::as_array) {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = b.get("text").and_then(Value::as_str) {
text.push_str(t);
}
}
Some("tool_use") => tool_calls.push(make_tool_call(
str_field(b, "id"),
str_field(b, "name"),
b.get("input").cloned().unwrap_or(Value::Null),
)?),
_ => {}
}
}
}
Ok(LlmResponse {
text,
tool_calls,
stop,
})
}
fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
let choice = v
.get("choices")
.and_then(Value::as_array)
.and_then(|a| a.first())
.ok_or_else(|| AgentError::Llm("response missing choices".into()))?;
let stop = map_stop(choice.get("finish_reason").and_then(Value::as_str));
let msg = choice
.get("message")
.ok_or_else(|| AgentError::Llm("missing message".into()))?;
let text = str_field(msg, "content");
let mut tool_calls = Vec::new();
if let Some(arr) = msg.get("tool_calls").and_then(Value::as_array) {
for tc in arr {
let f = tc
.get("function")
.ok_or_else(|| AgentError::Llm("tool_call missing function".into()))?;
let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}");
let args: Value = serde_json::from_str(raw)
.map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?;
tool_calls.push(make_tool_call(
str_field(tc, "id"),
str_field(f, "name"),
args,
)?);
}
}
Ok(LlmResponse {
text,
tool_calls,
stop,
})
}
fn make_tool_call(id: String, name: String, args: Value) -> Result<ToolCall, AgentError> {
if id.is_empty() || name.is_empty() {
return Err(AgentError::Llm("tool_call missing id or name".into()));
}
let arguments = match args {
Value::Object(_) => args,
Value::Null => Value::Object(Default::default()),
_ => {
return Err(AgentError::Llm(
"tool_call arguments must be a JSON object".into(),
))
}
};
Ok(ToolCall {
provider_id: id,
name,
arguments,
})
}
async fn read_error_body(mut resp: reqwest::Response) -> String {
let mut buf: Vec<u8> = Vec::new();
while buf.len() < MAX_LLM_ERROR_BODY_BYTES {
match resp.chunk().await {
Ok(Some(chunk)) => {
let take = chunk.len().min(MAX_LLM_ERROR_BODY_BYTES - buf.len());
buf.extend_from_slice(&chunk[..take]);
if take < chunk.len() {
break;
}
}
_ => break,
}
}
String::from_utf8_lossy(&buf).into_owned()
}
const MAX_RETRIES: u32 = 3;
const BASE_BACKOFF_MS: u64 = 500;
const MAX_BACKOFF_MS: u64 = 8_000;
async fn backoff_with_jitter(attempt: u32) {
let base = BASE_BACKOFF_MS
.saturating_mul(1u64 << attempt)
.min(MAX_BACKOFF_MS);
let mut buf = [0u8; 8];
let jitter_range = base / 2;
let delay = if jitter_range > 0 && getrandom::getrandom(&mut buf).is_ok() {
let r = u64::from_le_bytes(buf) % jitter_range;
base - jitter_range + r
} else {
base
};
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
async fn post<F>(http: &Client, url: &str, body: &Value, apply: F) -> Result<Value, AgentError>
where
F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
{
for attempt in 0..MAX_RETRIES {
let resp = match apply(http.post(url).json(body)).send().await {
Ok(r) => r,
Err(e) => {
if attempt + 1 < MAX_RETRIES && (e.is_timeout() || e.is_connect()) {
backoff_with_jitter(attempt).await;
continue;
}
return Err(AgentError::Llm(format!("transport: {e}")));
}
};
let status = resp.status();
if status == 401 || status == 403 {
return Err(AgentError::LlmAuth(read_error_body(resp).await));
}
if (status.is_server_error() || status == 429) && attempt + 1 < MAX_RETRIES {
backoff_with_jitter(attempt).await;
continue;
}
if !status.is_success() {
return Err(AgentError::Llm(format!(
"{status}: {}",
read_error_body(resp).await
)));
}
if let Some(len) = resp.content_length() {
if len as usize > MAX_LLM_RESPONSE_BYTES {
return Err(AgentError::Llm(format!(
"response too large: {len} > {MAX_LLM_RESPONSE_BYTES}"
)));
}
}
let mut buf: Vec<u8> = Vec::new();
let mut stream = resp;
loop {
match stream.chunk().await {
Ok(Some(chunk)) => {
if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES {
return Err(AgentError::Llm(format!(
"response exceeded {MAX_LLM_RESPONSE_BYTES} bytes"
)));
}
buf.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(e) => return Err(AgentError::Llm(format!("read: {e}"))),
}
}
return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}")));
}
Err(AgentError::Llm("exhausted retries".into()))
}

View File

@@ -0,0 +1,361 @@
#![forbid(unsafe_code)]
mod agent;
mod config;
mod handoff;
mod llm;
mod mcp;
mod types;
mod wire;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::io::BufReader;
use tokio::sync::{mpsc, watch, Mutex};
use crate::agent::RunCtx;
use crate::config::{Config, PROTOCOL_VERSION};
use crate::llm::Llm;
use crate::mcp::McpRegistry;
use crate::types::HistoryItem;
use crate::wire::{
classify, Inbound, InitializeParams, SessionCancelParams, SessionNewParams,
SessionPromptParams, WireMsg, WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR,
};
struct App {
cfg: Config,
llm: Arc<Llm>,
sessions: Mutex<HashMap<String, Session>>,
}
struct Session {
id: String,
mcp: Arc<McpRegistry>,
history: Vec<HistoryItem>,
cancel_tx: watch::Sender<bool>,
busy: bool,
original_task: Option<String>,
handoff_count: usize,
stop_rejections: u32,
}
fn die(msg: String) -> ! {
tracing::error!("{msg}");
std::process::exit(2);
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cfg = Config::from_env().unwrap_or_else(|e| die(e));
let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string())));
let max_line = cfg.max_line_bytes;
let app = Arc::new(App {
cfg,
llm,
sessions: Mutex::new(HashMap::new()),
});
let (wire_tx, wire_rx) = mpsc::channel::<WireMsg>(64);
let writer = tokio::spawn(wire::writer_task(wire_rx));
if let Err(e) = read_loop(
BufReader::new(tokio::io::stdin()),
app.clone(),
wire_tx,
max_line,
)
.await
{
tracing::error!("io: reader: {e}");
}
for session in app.sessions.lock().await.values() {
let _ = session.cancel_tx.send(true);
}
let _ = writer.await;
}
async fn read_loop<R: tokio::io::AsyncBufRead + Unpin>(
mut stdin: R,
app: Arc<App>,
wire_tx: WireSender,
max_line: usize,
) -> std::io::Result<()> {
while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(msg) => dispatch(&app, msg, &wire_tx).await,
Err(e) => {
wire::send(
&wire_tx,
wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")),
)
.await;
}
}
}
Ok(())
}
async fn dispatch(app: &Arc<App>, msg: Value, wire_tx: &WireSender) {
match classify(&msg) {
Inbound::Request { id, method, params } => {
handle_request(app, id, method, params, wire_tx).await
}
Inbound::Notification { method, params } => handle_notification(app, &method, params).await,
Inbound::Ignored => {}
Inbound::Invalid { id, code, message } => {
wire::send(wire_tx, wire::err(id, code, &message)).await
}
}
}
async fn handle_request(
app: &Arc<App>,
id: Value,
method: String,
params: Value,
wire_tx: &WireSender,
) {
match method.as_str() {
"initialize" => initialize(id, params, wire_tx).await,
"session/new" => {
let app = app.clone();
let wire_tx = wire_tx.clone();
tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await });
}
"session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()),
"session/cancel" => {
cancel_session(app, params).await;
wire::send(wire_tx, wire::ok(id, Value::Null)).await;
}
_ => {
wire::send(
wire_tx,
wire::err(
id,
METHOD_NOT_FOUND,
&format!("jsonrpc: method not found: {method}"),
),
)
.await
}
}
}
async fn handle_notification(app: &Arc<App>, method: &str, params: Value) {
if method == "session/cancel" {
cancel_session(app, params).await;
}
}
async fn initialize(id: Value, params: Value, wire_tx: &WireSender) {
let p: InitializeParams = match decode(params, "initialize") {
Ok(p) => p,
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
};
let _ = p.protocol_version;
wire::send(
wire_tx,
wire::ok(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
"mcpCapabilities": { "http": false, "sse": false },
},
"agentInfo": { "name": "sprout-agent", "version": env!("CARGO_PKG_VERSION") },
}),
),
)
.await;
}
async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionNewParams = match decode(params, "session/new") {
Ok(p) => p,
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
};
if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: cwd must be an absolute path",
)
.await;
}
// Check cap without holding lock across MCP spawn (which may be slow).
{
let sessions = app.sessions.lock().await;
if sessions.len() >= app.cfg.max_sessions {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: max sessions reached",
)
.await;
}
}
let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
Ok(m) => Arc::new(m),
Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await,
};
let session_id = match session_token() {
Ok(t) => format!("ses_{t}"),
Err(e) => return reject(wire_tx, id, -32000, &e).await,
};
let (cancel_tx, _) = watch::channel(false);
let mut sessions = app.sessions.lock().await;
// Re-check cap (another session may have been created while we spawned MCP).
if sessions.len() >= app.cfg.max_sessions {
return reject(
wire_tx,
id,
INVALID_PARAMS,
"session/new: max sessions reached",
)
.await;
}
sessions.insert(
session_id.clone(),
Session {
id: session_id.clone(),
mcp,
history: Vec::new(),
cancel_tx,
busy: false,
original_task: None,
handoff_count: 0,
stop_rejections: 0,
},
);
drop(sessions);
wire::send(wire_tx, wire::ok(id, json!({ "sessionId": session_id }))).await;
}
fn decode<T: serde::de::DeserializeOwned>(params: Value, stage: &str) -> Result<T, String> {
serde_json::from_value(params).map_err(|e| format!("{stage}: {e}"))
}
async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) {
wire::send(wire_tx, wire::err(id, code, message)).await;
}
async fn cancel_session(app: &Arc<App>, params: Value) {
if let Ok(p) = serde_json::from_value::<SessionCancelParams>(params) {
if let Some(s) = app.sessions.lock().await.get(&p.session_id) {
let _ = s.cancel_tx.send(true);
}
}
}
fn spawn_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await });
}
async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender) {
let p: SessionPromptParams = match decode(params, "session/prompt") {
Ok(p) => p,
Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await,
};
let (
sid,
mcp,
mut history,
mut original_task,
mut handoff_count,
mut stop_rejections,
mut cancel_rx,
) = match acquire_session(&app, &p.session_id).await {
Ok(v) => v,
Err(reason) => {
return reject(
&wire_tx,
id,
INVALID_PARAMS,
&format!("session/prompt: {reason}"),
)
.await
}
};
let mut ctx = RunCtx {
cfg: &app.cfg,
session_id: &sid,
llm: &app.llm,
mcp: &mcp,
wire: &wire_tx,
cancel: &mut cancel_rx,
history: &mut history,
original_task: &mut original_task,
handoff_count: &mut handoff_count,
stop_rejections: &mut stop_rejections,
};
let result = ctx.run(p.prompt).await;
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
s.busy = false;
s.history = history;
s.original_task = original_task;
s.handoff_count = handoff_count;
s.stop_rejections = stop_rejections;
}
match result {
Ok(stop) => {
wire::send(
&wire_tx,
wire::ok(id, json!({ "stopReason": stop.as_wire() })),
)
.await
}
Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await,
}
}
async fn acquire_session(
app: &Arc<App>,
session_id: &str,
) -> Result<
(
String,
Arc<McpRegistry>,
Vec<HistoryItem>,
Option<String>,
usize,
u32,
watch::Receiver<bool>,
),
&'static str,
> {
let mut sessions = app.sessions.lock().await;
let s = sessions.get_mut(session_id).ok_or("unknown session")?;
if s.busy {
return Err("prompt already in flight");
}
s.busy = true;
let (tx, rx) = watch::channel(false);
s.cancel_tx = tx;
Ok((
s.id.clone(),
s.mcp.clone(),
std::mem::take(&mut s.history),
s.original_task.take(),
s.handoff_count,
s.stop_rejections,
rx,
))
}
fn session_token() -> Result<String, String> {
let mut b = [0u8; 8];
getrandom::getrandom(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?;
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}

View File

@@ -0,0 +1,860 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use rmcp::model::CallToolRequestParams;
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::TokioChildProcess;
use rmcp::ServiceError;
use rmcp::ServiceExt;
use serde_json::{Map, Value};
use tokio::process::Command;
use tokio::sync::watch;
use tokio::sync::Mutex as AsyncMutex;
use crate::config::{Config, HookServers};
use crate::types::{clamp, AgentError, McpServerStdio, ToolDef, ToolResult};
const SEP: &str = "__";
const MAX_NAME_LEN: usize = 128;
const MAX_QNAME_LEN: usize = 64;
const MAX_TOOLS_PER_SESSION: usize = 128;
const MAX_DESCRIPTION_BYTES: usize = 1024;
const MAX_SCHEMA_BYTES: usize = 4096;
const MARKER_FIELD_MAX: usize = 256;
pub const MAX_MCP_SERVERS: usize = 16;
const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024;
const PASSTHROUGH_ENV: &[&str] = &["PATH", "HOME", "TERM", "LANG", "LC_ALL", "TMPDIR"];
type Client = RunningService<RoleClient, ()>;
#[derive(Clone)]
struct ServerSpec {
name: String,
command: String,
args: Vec<String>,
env: Vec<(String, String)>,
cwd: String,
}
enum ClientState {
Healthy {
client: Arc<Client>,
pgid: Option<u32>,
tools: Arc<Vec<String>>,
},
Dead {
attempts: u32,
next_retry: Instant,
reason: String,
// Preserved from the last Healthy state so tools() filtering stays accurate while dead.
tools: Arc<Vec<String>>,
},
}
struct Server {
name: String,
spec: ServerSpec,
client: ArcSwap<ClientState>,
restart_lock: AsyncMutex<()>,
}
impl Drop for Server {
fn drop(&mut self) {
if let ClientState::Healthy { pgid: Some(p), .. } = &**self.client.load() {
killpg(*p, &self.name, "drop");
}
}
}
enum RestartCheck {
Healthy,
Ready {
attempt_n: u32,
prev_tools: Arc<Vec<String>>,
},
}
fn check_restart_state(server: &Server, max_attempts: u32) -> Result<RestartCheck, AgentError> {
match &**server.client.load() {
ClientState::Healthy { .. } => Ok(RestartCheck::Healthy),
ClientState::Dead { attempts, .. } if *attempts >= max_attempts => {
Err(AgentError::Mcp(format!(
"The MCP server '{}' is unavailable (exhausted). Its tools have been removed for this session.",
server.name
)))
}
ClientState::Dead { next_retry, reason, .. } if Instant::now() < *next_retry => {
Err(AgentError::Mcp(format!(
"server '{}' is recovering (last error: {reason}). Try again later or use a different tool.",
server.name
)))
}
ClientState::Dead { attempts, tools, .. } => Ok(RestartCheck::Ready {
attempt_n: attempts + 1,
prev_tools: tools.clone(),
}),
}
}
struct Entry {
server_idx: usize,
bare: String,
}
pub struct McpRegistry {
by_qname: HashMap<String, Entry>,
defs: Vec<ToolDef>,
servers: Vec<Arc<Server>>,
max_attempts: u32,
backoff_base: Duration,
backoff_max: Duration,
init_timeout: Duration,
/// Consecutive hook timeout count per server. Kill on second consecutive.
hook_timeouts: std::sync::Mutex<HashMap<String, u32>>,
}
impl McpRegistry {
pub async fn spawn_all(
cfg: &Config,
servers: &[McpServerStdio],
cwd: &str,
) -> Result<Self, AgentError> {
if servers.len() > MAX_MCP_SERVERS {
return Err(AgentError::Mcp(format!(
"too many MCP servers: {} > {MAX_MCP_SERVERS}",
servers.len()
)));
}
let mut reg = Self {
by_qname: HashMap::new(),
defs: Vec::new(),
servers: Vec::new(),
max_attempts: cfg.mcp_max_restart_attempts.max(1),
backoff_base: Duration::from_millis(cfg.mcp_restart_base_ms.max(1)),
backoff_max: Duration::from_millis(cfg.mcp_restart_max_ms.max(1)),
init_timeout: cfg.mcp_init_timeout,
hook_timeouts: std::sync::Mutex::new(HashMap::new()),
};
let mut seen_names = HashSet::new();
for s in servers {
if !valid_name(&s.name) || s.name.contains("__") {
return Err(AgentError::Mcp(format!("invalid server name: {}", s.name)));
}
if !seen_names.insert(s.name.clone()) {
return Err(AgentError::Mcp(format!(
"duplicate server name: {}",
s.name
)));
}
let spec = ServerSpec {
name: s.name.clone(),
command: s.command.clone(),
args: s.args.clone(),
env: s
.env
.iter()
.map(|e| (e.name.clone(), e.value.clone()))
.collect(),
cwd: cwd.to_owned(),
};
let (client, pgid, tool_names, raw_tools) = spawn_one(&spec, reg.init_timeout).await?;
let server_idx = reg.servers.len();
let server = Arc::new(Server {
name: spec.name.clone(),
spec,
client: ArcSwap::from_pointee(ClientState::Healthy {
client: Arc::new(client),
pgid,
tools: Arc::new(tool_names),
}),
restart_lock: AsyncMutex::new(()),
});
reg.servers.push(server);
for t in raw_tools {
if reg.defs.len() >= MAX_TOOLS_PER_SESSION {
return Err(AgentError::Mcp(format!(
"too many tools (>{MAX_TOOLS_PER_SESSION})"
)));
}
let bare = t.name.to_string();
if !valid_name(&bare) || bare.contains("__") {
return Err(AgentError::Mcp(format!("invalid tool name: {bare}")));
}
let qname = format!("{}{SEP}{}", s.name, bare);
if qname.len() > MAX_QNAME_LEN {
return Err(AgentError::Mcp(format!(
"qualified tool name too long: {} ({} > {MAX_QNAME_LEN})",
qname,
qname.len()
)));
}
if reg.by_qname.contains_key(&qname) {
return Err(AgentError::Mcp(format!("duplicate tool: {qname}")));
}
reg.defs.push(ToolDef {
name: qname.clone(),
description: clamp(
t.description.as_deref().unwrap_or("").to_owned(),
MAX_DESCRIPTION_BYTES,
),
input_schema: cap_schema(&qname, Value::Object((*t.input_schema).clone())),
});
reg.by_qname.insert(qname, Entry { server_idx, bare });
}
}
Ok(reg)
}
pub fn server_of(&self, qname: &str) -> Option<&str> {
self.by_qname
.get(qname)
.map(|e| self.servers[e.server_idx].name.as_str())
}
pub fn has(&self, qname: &str) -> bool {
self.by_qname.contains_key(qname)
}
/// True if `qname` resolves to a hidden hook tool (bare name starts
/// with `_`). Used to reject hook calls coming from the LLM path —
/// hooks are only callable via `call_hooks`.
pub fn is_hook(&self, qname: &str) -> bool {
self.by_qname
.get(qname)
.map(|e| e.bare.starts_with('_'))
.unwrap_or(false)
}
pub fn tools(&self) -> Vec<ToolDef> {
self.defs
.iter()
.filter(|d| {
let entry = match self.by_qname.get(&d.name) {
Some(e) => e,
None => return false,
};
// Bare names starting with `_` are hooks — invisible to the LLM.
if entry.bare.starts_with('_') {
return false;
}
let server = &self.servers[entry.server_idx];
match &**server.client.load() {
ClientState::Healthy { tools, .. } => tools.iter().any(|t| t == &entry.bare),
ClientState::Dead {
attempts, tools, ..
} => *attempts < self.max_attempts && tools.iter().any(|t| t == &entry.bare),
}
})
.cloned()
.collect()
}
/// Call every tool whose bare name equals `hook_name` across all
/// allowlisted servers in parallel, bounded by `timeout`. Returns
/// `(server_name, text)` pairs in **config order** (deterministic),
/// dropping empty/whitespace-only responses, errors and timeouts.
/// Hooks are fail-open and must never block the agent.
pub async fn call_hooks(
self: &Arc<Self>,
hook_name: &str,
input: &Value,
timeout: Duration,
allowed: &HookServers,
) -> Vec<(String, String)> {
if allowed.is_disabled() {
return Vec::new();
}
// Walk servers in registration order so the result is deterministic
// regardless of HashMap iteration order or task completion order.
let mut targets: Vec<(usize, String, String)> = Vec::new();
for (idx, server) in self.servers.iter().enumerate() {
if !allowed.allows(&server.name) {
continue;
}
let qname = format!("{}{SEP}{}", server.name, hook_name);
if self.by_qname.contains_key(&qname) {
targets.push((idx, server.name.clone(), qname));
}
}
if targets.is_empty() {
return Vec::new();
}
let mut set = tokio::task::JoinSet::new();
for (idx, server_name, qname) in targets {
let reg = Arc::clone(self);
let args = input.clone();
set.spawn(async move {
// Hooks are intentionally non-cancellable: they are
// already bounded by their own timeout and are fail-open.
// Session cancel should not interrupt hook evaluation.
let (_dummy_tx, mut dummy_cancel) = watch::channel(false);
let res = tokio::time::timeout(
timeout,
reg.call(
&qname,
"hook",
&args,
MAX_HOOK_RESULT_BYTES,
&mut dummy_cancel,
),
)
.await;
drop(_dummy_tx);
(idx, server_name, res)
});
}
let mut indexed: Vec<(usize, String, String)> = Vec::new();
while let Some(joined) = set.join_next().await {
// fail-open: drop join errors, timeouts, call errors,
// empty/whitespace-only text. On timeout, also kill the server
// process group so a wedged hook can't poison the next regular
// tool call. The registry's lazy restart handles the rest.
match joined {
Ok((idx, server_name, Ok(Ok(r)))) => {
// Success — reset consecutive timeout counter.
if let Ok(mut counts) = self.hook_timeouts.lock() {
counts.remove(&server_name);
}
if !r.is_error && !r.text.trim().is_empty() {
indexed.push((idx, server_name, r.text));
}
}
Ok((_idx, server_name, Err(_elapsed))) => {
// Kill only on second consecutive timeout.
let count = {
let mut counts =
self.hook_timeouts.lock().unwrap_or_else(|e| e.into_inner());
let c = counts.entry(server_name.clone()).or_insert(0);
*c += 1;
*c
};
if count >= 2 {
tracing::warn!(
"hook: killing server '{}' after {} consecutive timeouts",
server_name,
count
);
self.kill_server(&server_name, "hook timeout (consecutive)");
if let Ok(mut counts) = self.hook_timeouts.lock() {
counts.remove(&server_name);
}
} else {
tracing::warn!("hook: server '{}' timed out ({}/2)", server_name, count);
}
}
_ => {}
}
}
indexed.sort_by_key(|(idx, _, _)| *idx);
indexed
.into_iter()
.map(|(_, name, text)| (name, text))
.collect()
}
/// Kill the server's process group and mark it dead. Idempotent:
/// if the server is already Dead (or unknown), this is a no-op.
/// Counts as one attempt toward the restart budget so that a
/// pathological server (starts fine, deadlocks on every call)
/// eventually exhausts.
pub fn kill_server(&self, name: &str, reason: &str) {
let server = match self.servers.iter().find(|s| s.name == name) {
Some(s) => s,
None => return,
};
let current = server.client.load_full();
let (pgid, tools) = match &*current {
ClientState::Dead { .. } => return,
ClientState::Healthy { pgid, tools, .. } => (*pgid, tools.clone()),
};
let dead = Arc::new(ClientState::Dead {
attempts: 1,
next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max),
reason: reason.to_owned(),
tools,
});
// CAS so we don't clobber a concurrent restart that already
// transitioned the state. If the swap fails, the kill below is
// still safe — the pgid we read belonged to a process we observed
// as Healthy, and killpg on an already-reaped pgid is a no-op.
let prev = server.client.compare_and_swap(&current, dead);
if Arc::ptr_eq(&prev, &current) {
if let Some(p) = pgid {
killpg(p, &server.name, "kill_server");
}
tracing::error!(
"MCP server '{}' killed and marked dead (reason={reason})",
server.name
);
}
}
fn kill_and_mark_dead_if_current(
&self,
server: &Server,
failed_client: &Arc<Client>,
reason: &str,
) {
let current = server.client.load_full();
match &*current {
ClientState::Healthy {
client,
pgid,
tools,
} if Arc::ptr_eq(client, failed_client) => {
if let Some(p) = *pgid {
killpg(p, &server.name, "call_failed");
}
let dead = Arc::new(ClientState::Dead {
attempts: 1,
next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max),
reason: reason.to_owned(),
tools: tools.clone(),
});
let _ = server.client.compare_and_swap(&current, dead);
tracing::error!(
"MCP server '{}' killed and marked dead (reason={reason})",
server.name
);
}
_ => {}
}
}
pub async fn call(
&self,
qname: &str,
provider_id: &str,
arguments: &Value,
max_bytes: usize,
cancel: &mut watch::Receiver<bool>,
) -> Result<ToolResult, AgentError> {
let entry = self
.by_qname
.get(qname)
.ok_or_else(|| AgentError::Mcp(format!("unknown tool {qname}")))?;
let server = self.servers[entry.server_idx].clone();
let state = server.client.load();
if let ClientState::Healthy { client, tools, .. } = &**state {
if !tools.iter().any(|t| t == &entry.bare) {
return Err(AgentError::Mcp(format!(
"tool '{qname}': no longer available; the MCP server restarted with a different tool set."
)));
}
let client = client.clone();
drop(state);
return self
.do_call(
&server,
&client,
&entry.bare,
qname,
provider_id,
arguments,
max_bytes,
cancel,
)
.await;
}
drop(state);
self.maybe_restart(&server).await?;
let state = server.client.load();
let client = match &**state {
ClientState::Healthy { client, tools, .. } => {
if !tools.iter().any(|t| t == &entry.bare) {
return Err(AgentError::Mcp(format!(
"tool '{qname}': no longer available; the MCP server restarted with a different tool set."
)));
}
client.clone()
}
ClientState::Dead { reason, .. } => {
return Err(AgentError::Mcp(format!(
"tool '{qname}': server '{}' restart failed: {reason}",
server.name
)));
}
};
drop(state);
self.do_call(
&server,
&client,
&entry.bare,
qname,
provider_id,
arguments,
max_bytes,
cancel,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn do_call(
&self,
server: &Server,
client: &Arc<Client>,
bare: &str,
qname: &str,
provider_id: &str,
arguments: &Value,
max_bytes: usize,
cancel: &mut watch::Receiver<bool>,
) -> Result<ToolResult, AgentError> {
let arg_obj = match arguments {
Value::Object(m) => Some(m.clone()),
Value::Null => None,
_ => {
return Err(AgentError::Mcp(format!(
"tool {qname} arguments must be a JSON object"
)))
}
};
let mut params = CallToolRequestParams::default();
params.name = bare.to_owned().into();
params.arguments = arg_obj;
use rmcp::model::{CallToolRequest, ClientRequest, ServerResult};
use rmcp::service::PeerRequestOptions;
let req = ClientRequest::CallToolRequest(CallToolRequest::new(params));
let mut handle = client
.peer()
.send_cancellable_request(req, PeerRequestOptions::no_options())
.await
.map_err(|e| AgentError::Mcp(format!("call {qname}: {e}")))?;
// Early cancel check — watch::changed() only fires on NEW writes.
if *cancel.borrow() {
fire_and_forget_cancel(handle, qname);
return Err(AgentError::Cancelled);
}
// Poll the inner oneshot directly so we can still own `handle` in
// the cancel branch (await_response would move it).
let raw: Result<ServerResult, ServiceError> = tokio::select! {
biased;
_ = cancel.changed() => {
fire_and_forget_cancel(handle, qname);
return Err(AgentError::Cancelled);
}
r = &mut handle.rx => match r {
Ok(inner) => inner,
Err(_) => Err(ServiceError::TransportClosed),
},
};
let res = match raw {
Ok(ServerResult::CallToolResult(r)) => r,
Ok(_) => {
return Err(AgentError::Mcp(format!(
"call {qname}: unexpected response type"
)))
}
Err(e) => {
if is_transport_error(&e) {
self.kill_and_mark_dead_if_current(
server,
client,
&format!("call failed: {e}"),
);
return Err(AgentError::Mcp(format!("call {qname}: {e}")));
}
// Application-level JSON-RPC error (e.g. -32602 invalid params).
// Server is healthy — it correctly rejected bad input. Return to LLM.
return Ok(ToolResult {
provider_id: provider_id.to_owned(),
text: clamp(format!("Tool call rejected: {e}"), max_bytes),
is_error: true,
});
}
};
let text = collapse_content(&res.content, max_bytes);
Ok(ToolResult {
provider_id: provider_id.to_owned(),
text: clamp(text, max_bytes),
is_error: res.is_error.unwrap_or(false),
})
}
async fn maybe_restart(&self, server: &Server) -> Result<(), AgentError> {
match check_restart_state(server, self.max_attempts)? {
RestartCheck::Healthy => return Ok(()),
RestartCheck::Ready { .. } => {}
}
let _guard = server.restart_lock.lock().await;
let (attempt_n, prev_tools) = match check_restart_state(server, self.max_attempts)? {
RestartCheck::Healthy => return Ok(()),
RestartCheck::Ready {
attempt_n,
prev_tools,
} => (attempt_n, prev_tools),
};
let started = Instant::now();
tracing::info!(
"MCP server '{}' restarting (attempt {attempt_n}/{})",
server.name,
self.max_attempts
);
match spawn_one(&server.spec, self.init_timeout).await {
Ok((client, pgid, tool_names, _raw_tools)) => {
server.client.store(Arc::new(ClientState::Healthy {
client: Arc::new(client),
pgid,
tools: Arc::new(tool_names),
}));
tracing::info!(
"MCP server '{}' restarted in {}ms (attempt {attempt_n})",
server.name,
started.elapsed().as_millis()
);
Ok(())
}
Err(e) => {
let reason = format!("restart failed: {e}");
let permanent = attempt_n >= self.max_attempts;
let next_retry = if permanent {
Instant::now() + Duration::from_secs(86_400)
} else {
Instant::now() + backoff(attempt_n, self.backoff_base, self.backoff_max)
};
server.client.store(Arc::new(ClientState::Dead {
attempts: attempt_n,
next_retry,
reason: reason.clone(),
tools: prev_tools,
}));
tracing::error!(
"MCP server '{}' restart failed (attempt {attempt_n}/{}, permanent={permanent}): {reason}",
server.name, self.max_attempts
);
Err(AgentError::Mcp(reason))
}
}
}
}
async fn spawn_one(
spec: &ServerSpec,
timeout: Duration,
) -> Result<(Client, Option<u32>, Vec<String>, Vec<rmcp::model::Tool>), AgentError> {
let mut cmd = Command::new(&spec.command);
cmd.args(&spec.args);
cmd.env_clear();
for k in PASSTHROUGH_ENV {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
for (k, v) in &spec.env {
cmd.env(k, v);
}
cmd.current_dir(&spec.cwd);
cmd.stderr(std::process::Stdio::inherit());
#[cfg(unix)]
cmd.process_group(0);
let transport = TokioChildProcess::new(cmd)
.map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?;
let pgid = transport.id();
struct PgidGuard {
pgid: Option<u32>,
name: String,
}
impl Drop for PgidGuard {
fn drop(&mut self) {
if let Some(p) = self.pgid.take() {
killpg(p, &self.name, "spawn_dropped");
}
}
}
let mut guard = PgidGuard {
pgid,
name: spec.name.clone(),
};
let client: Client = match tokio::time::timeout(timeout, ().serve(transport)).await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
return Err(AgentError::Mcp(format!("init {}: {e}", spec.name)));
}
Err(_) => {
return Err(AgentError::Mcp(timeout_msg("init", &spec.name, timeout)));
}
};
let tools = match tokio::time::timeout(timeout, client.peer().list_all_tools()).await {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
return Err(AgentError::Mcp(format!("list_tools {}: {e}", spec.name)));
}
Err(_) => {
return Err(AgentError::Mcp(timeout_msg(
"list_tools",
&spec.name,
timeout,
)));
}
};
let names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
guard.pgid = None;
Ok((client, pgid, names, tools))
}
/// Send `notifications/cancelled` to the MCP server, fire-and-forget.
/// Per MCP spec, cancellation notifications are best-effort; we never
/// block the agent on slow server stdio.
fn fire_and_forget_cancel(
handle: rmcp::service::RequestHandle<rmcp::service::RoleClient>,
qname: &str,
) {
let qname_owned = qname.to_owned();
tokio::spawn(async move {
if let Err(e) = handle.cancel(Some("session cancelled".into())).await {
tracing::debug!("cancel notification failed for {qname_owned}: {e}");
}
});
}
/// Returns `true` for errors indicating the MCP server process is dead or
/// unreachable. Returns `false` for application-level JSON-RPC errors where
/// the server is healthy but rejected the request (e.g. invalid params).
fn is_transport_error(e: &ServiceError) -> bool {
matches!(
e,
ServiceError::TransportSend(_)
| ServiceError::TransportClosed
| ServiceError::Timeout { .. }
| ServiceError::UnexpectedResponse
)
}
fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration {
let shift = attempt.saturating_sub(1).min(20);
let scaled = base.saturating_mul(1u32 << shift);
let capped = scaled.min(max);
let ms = capped.as_millis() as u64;
let jitter_pct = jitter_percent();
let jittered = (ms as i64) + ((ms as i64) * jitter_pct / 100);
Duration::from_millis(jittered.max(0) as u64)
}
fn jitter_percent() -> i64 {
let mut buf = [0u8; 1];
let _ = getrandom::getrandom(&mut buf);
((buf[0] as i64) % 41) - 20
}
fn timeout_msg(stage: &str, name: &str, t: Duration) -> String {
format!("{stage} {name}: timeout after {}s", t.as_secs())
}
fn cap_schema(qname: &str, schema: Value) -> Value {
let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0);
if size <= MAX_SCHEMA_BYTES {
return schema;
}
tracing::warn!(
"tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object"
);
Value::Object(Map::new())
}
#[cfg(unix)]
fn killpg(pgid: u32, name: &str, stage: &str) {
use nix::sys::signal::{killpg as nix_killpg, Signal};
use nix::unistd::Pid;
let result = nix_killpg(Pid::from_raw(pgid as i32), Signal::SIGKILL);
tracing::info!(
"killpg MCP {name} ({stage}) pgid={pgid} ok={}",
result.is_ok()
);
}
#[cfg(not(unix))]
fn killpg(_pgid: u32, name: &str, stage: &str) {
tracing::info!("relying on Drop to kill MCP {name} ({stage})");
}
fn valid_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_NAME_LEN
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn truncate_at_boundary(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut cut = max;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
&s[..cut]
}
fn push_bounded(out: &mut String, s: &str, max: usize) {
let remaining = max.saturating_sub(out.len());
if remaining > 0 {
out.push_str(truncate_at_boundary(s, remaining));
}
}
fn collapse_content(blocks: &[rmcp::model::Content], max_bytes: usize) -> String {
use rmcp::model::RawContent;
let mut out = String::new();
let mut truncated = false;
let short = |s: &str| truncate_at_boundary(s, MARKER_FIELD_MAX).to_owned();
for c in blocks {
if out.len() >= max_bytes {
truncated = true;
break;
}
if !out.is_empty() {
push_bounded(&mut out, "\n", max_bytes);
}
let chunk: String = match &c.raw {
RawContent::Text(t) => t.text.clone(),
RawContent::Image(i) => {
format!(
"[image elided: {}, {} bytes]",
short(&i.mime_type),
i.data.len()
)
}
RawContent::Audio(a) => {
format!(
"[audio elided: {}, {} bytes]",
short(&a.mime_type),
a.data.len()
)
}
RawContent::ResourceLink(r) => format!("[resource: {}]", short(&r.uri)),
RawContent::Resource(_) => "[resource elided]".into(),
};
let before = out.len();
push_bounded(&mut out, &chunk, max_bytes);
if out.len() - before < chunk.len() {
truncated = true;
}
}
if truncated {
out.push_str("\n[content truncated]");
}
out
}

View File

@@ -0,0 +1,170 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone)]
pub enum HistoryItem {
User(String),
Assistant {
text: String,
tool_calls: Vec<ToolCall>,
},
ToolResult(ToolResult),
}
impl HistoryItem {
pub fn estimated_bytes(&self) -> usize {
match self {
Self::User(s) => s.len(),
Self::Assistant { text, tool_calls } => {
text.len()
+ tool_calls
.iter()
.map(|c| {
c.provider_id.len()
+ c.name.len()
+ serde_json::to_vec(&c.arguments)
.map(|b| b.len())
.unwrap_or(0)
})
.sum::<usize>()
}
Self::ToolResult(r) => r.provider_id.len() + r.text.len(),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolCall {
pub provider_id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub provider_id: String,
pub text: String,
pub is_error: bool,
}
#[derive(Debug, Clone)]
pub struct LlmResponse {
pub text: String,
pub tool_calls: Vec<ToolCall>,
pub stop: ProviderStop,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProviderStop {
EndTurn,
ToolUse,
MaxTokens,
Refusal,
Other,
}
#[derive(Debug, Clone)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StopReason {
EndTurn,
Cancelled,
MaxTokens,
MaxTurnRequests,
Refusal,
}
impl StopReason {
pub fn as_wire(self) -> &'static str {
match self {
Self::EndTurn => "end_turn",
Self::Cancelled => "cancelled",
Self::MaxTokens => "max_tokens",
Self::MaxTurnRequests => "max_turn_requests",
Self::Refusal => "refusal",
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct McpServerStdio {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<EnvVar>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct EnvVar {
pub name: String,
pub value: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
ResourceLink {
uri: String,
},
#[serde(other)]
Unsupported,
}
#[derive(Debug)]
pub enum AgentError {
InvalidParams(String),
Llm(String),
LlmAuth(String),
Mcp(String),
Cancelled,
}
impl std::fmt::Display for AgentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidParams(s) => write!(f, "invalid params: {s}"),
Self::Llm(s) => write!(f, "llm: {s}"),
Self::LlmAuth(s) => write!(f, "llm auth: {s}"),
Self::Mcp(s) => write!(f, "mcp: {s}"),
Self::Cancelled => write!(f, "cancelled"),
}
}
}
impl std::error::Error for AgentError {}
impl AgentError {
pub fn json_rpc_code(&self) -> i32 {
match self {
Self::InvalidParams(_) => -32602,
_ => -32000,
}
}
}
pub fn clamp(mut s: String, max: usize) -> String {
if s.len() <= max {
return s;
}
const MARKER: &str = "\n[truncated]";
let budget = max.saturating_sub(MARKER.len());
let mut cut = budget;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
if max >= MARKER.len() {
s.push_str(MARKER);
}
s
}

View File

@@ -0,0 +1,180 @@
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
use crate::types::{ContentBlock, McpServerStdio};
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub enum WireMsg {
Notify(Value),
}
pub type WireSender = mpsc::Sender<WireMsg>;
#[derive(Debug)]
pub enum Inbound {
Request {
id: Value,
method: String,
params: Value,
},
Notification {
method: String,
params: Value,
},
Ignored,
Invalid {
id: Value,
code: i32,
message: String,
},
}
#[derive(Debug, Deserialize)]
pub struct InitializeParams {
#[serde(rename = "protocolVersion")]
pub protocol_version: u32,
#[serde(default, rename = "clientCapabilities")]
pub _client_capabilities: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
pub cwd: String,
#[serde(default)]
pub mcp_servers: Vec<McpServerStdio>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
pub fn classify(msg: &Value) -> Inbound {
if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Inbound::Invalid {
id: msg.get("id").cloned().unwrap_or(Value::Null),
code: INVALID_REQUEST,
message: "jsonrpc: missing or invalid version".into(),
};
}
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).map(str::to_owned);
let params = msg.get("params").cloned().unwrap_or(Value::Null);
match (method, id) {
(Some(m), Some(id)) => Inbound::Request {
id,
method: m,
params,
},
(Some(m), None) => Inbound::Notification { method: m, params },
// Bare responses (id present, no method) are unexpected — sprout-agent
// does not issue requests to the client. Ignore silently.
(None, Some(_)) => Inbound::Ignored,
(None, None) => Inbound::Invalid {
id: Value::Null,
code: INVALID_REQUEST,
message: "jsonrpc: missing method and id".into(),
},
}
}
pub fn ok(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
pub fn err(id: Value, code: i32, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
pub fn session_update(sid: &str, update: Value) -> Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": { "sessionId": sid, "update": update },
})
}
pub async fn send(wire: &WireSender, msg: Value) {
let _ = wire.send(WireMsg::Notify(msg)).await;
}
pub async fn read_bounded_line<R: AsyncBufRead + Unpin>(
stdin: &mut R,
max: usize,
) -> std::io::Result<Option<String>> {
let mut buf: Vec<u8> = Vec::new();
loop {
let chunk = stdin.fill_buf().await?;
if chunk.is_empty() {
if !buf.is_empty() {
tracing::error!(
"io: unterminated frame at EOF ({} bytes dropped)",
buf.len()
);
}
return Ok(None);
}
let take = chunk
.iter()
.position(|b| *b == b'\n')
.map_or(chunk.len(), |i| i + 1);
if buf.len().saturating_add(take) > max {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("io: line exceeds max ({max} bytes)"),
));
}
buf.extend_from_slice(&chunk[..take]);
stdin.consume(take);
if buf.ends_with(b"\n") {
buf.pop();
if buf.ends_with(b"\r") {
buf.pop();
}
match String::from_utf8(buf) {
Ok(s) => return Ok(Some(s)),
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"io: frame contains invalid UTF-8",
))
}
}
}
}
}
pub async fn writer_task(mut rx: mpsc::Receiver<WireMsg>) {
let mut stdout = tokio::io::stdout();
while let Some(msg) = rx.recv().await {
let WireMsg::Notify(v) = msg;
let mut s = match serde_json::to_string(&v) {
Ok(s) => s,
Err(e) => {
tracing::error!("io: serialize: {e}");
continue;
}
};
s.push('\n');
if stdout.write_all(s.as_bytes()).await.is_err() {
return;
}
let _ = stdout.flush().await;
}
}

View File

@@ -0,0 +1,291 @@
//! Tiny fake MCP server for integration tests.
//!
//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by
//! environment variables so tests can simulate misbehavior:
//!
//! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout)
//! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout)
//! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1)
//! FAKE_MCP_HUGE_DESC=1 — every tool description is 100 KB
//! FAKE_MCP_DESC_SIZE=N — every tool description is N bytes (overrides HUGE_DESC)
//! FAKE_MCP_TOOL_DELAY=N — `tools/call` sleeps N seconds before replying
//! (use a large value, e.g. 999, to simulate hang)
//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup
//! (for tests that want to verify the child died)
//! FAKE_MCP_SPAWN_GRANDCHILD=1
//! — on `tools/call`, spawn a `sleep 999`
//! grandchild before hanging. Its PID is
//! written to FAKE_MCP_GRANDCHILD_PID_FILE
//! so a test can verify the entire process
//! tree dies on timeout.
//! FAKE_MCP_GRANDCHILD_PID_FILE=path
//! — path to write the grandchild PID to.
//! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool
//! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going")
//! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying
//! (use a large value to simulate hang/timeout)
//! FAKE_MCP_STOP_COUNT=N — `_Stop` returns STOP_TEXT for the first N
//! invocations; empty string thereafter. If
//! unset, every call returns STOP_TEXT.
//! FAKE_MCP_POSTCOMPACT_HOOK=1
//! — expose a `_PostCompact` hook tool
//! FAKE_MCP_POSTCOMPACT_TEXT=text
//! — `_PostCompact` returns this (default: "")
use std::io::{BufRead, Write};
use serde_json::{json, Value};
fn env_flag(k: &str) -> bool {
std::env::var(k).map(|v| v != "0").unwrap_or(false)
}
fn env_usize(k: &str, default: usize) -> usize {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn env_u64(k: &str, default: u64) -> u64 {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn write_response(id: Value, result: Value) {
let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result });
let mut s = serde_json::to_string(&msg).expect("serialize");
s.push('\n');
let mut out = std::io::stdout().lock();
out.write_all(s.as_bytes()).expect("write");
out.flush().expect("flush");
}
fn hang_forever() -> ! {
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
fn make_tools(
count: usize,
desc: &str,
include_stop_hook: bool,
include_post_compact_hook: bool,
) -> Vec<Value> {
let mut tools: Vec<Value> = (0..count)
.map(|i| {
json!({
"name": format!("tool_{i}"),
"description": desc,
"inputSchema": { "type": "object", "properties": {} },
})
})
.collect();
if include_stop_hook {
tools.push(json!({
"name": "_Stop",
"description": "stop hook",
"inputSchema": { "type": "object", "properties": {} },
}));
}
if include_post_compact_hook {
tools.push(json!({
"name": "_PostCompact",
"description": "post compact hook",
"inputSchema": { "type": "object", "properties": {} },
}));
}
tools
}
fn main() {
// Optional: write our own PID so a test can later check the process is gone.
if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") {
let pid = std::process::id().to_string();
let _ = std::fs::write(&path, pid);
}
let hang_init = env_flag("FAKE_MCP_HANG_INIT");
let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS");
let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1);
// FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set.
let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE")
.ok()
.and_then(|v| v.parse::<usize>().ok())
{
"x".repeat(n)
} else if env_flag("FAKE_MCP_HUGE_DESC") {
"x".repeat(100_000)
} else {
"fake tool".to_owned()
};
let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0);
let stop_hook = env_flag("FAKE_MCP_STOP_HOOK");
let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned());
let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0);
// 0 means "unset" → unlimited; any positive value caps the number of
// calls that return STOP_TEXT before flipping to empty string.
let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX);
let mut stop_calls_seen: usize = 0;
let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK");
let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default();
// Use a channel-based stdin reader so notifications (which carry no id)
// are captured even while the main thread is sleeping during a tool call.
let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok();
let (tx, rx) = std::sync::mpsc::channel::<(Value, Option<Value>)>();
let cancel_log_for_thread = cancel_log_path.clone();
std::thread::spawn(move || {
let stdin = std::io::stdin();
let lines = stdin.lock().lines();
for line in lines {
let line = match line {
Ok(l) => l,
Err(_) => return,
};
if line.trim().is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let id = msg.get("id").cloned();
// Notifications carry no id. Log cancellations if configured.
if id.is_none() || id == Some(Value::Null) {
if method == "notifications/cancelled" {
if let Some(ref path) = cancel_log_for_thread {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{}", line.trim());
}
}
}
continue;
}
// Send requests (with id) to the main processing loop.
let _ = tx.send((msg, id));
}
});
while let Ok((msg, id_opt)) = rx.recv() {
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let id = id_opt.unwrap_or(Value::Null);
match method {
"initialize" => {
if hang_init {
hang_forever();
}
write_response(
id,
json!({
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "fake-mcp", "version": "0.0.0" },
}),
);
}
"tools/list" => {
if hang_tools {
hang_forever();
}
write_response(
id,
json!({
"tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook)
}),
);
}
"tools/call" => {
// Signal that the request was received (for tests that
// need to wait until the call is in-flight before cancelling).
// Write the request id so tests can correlate with cancel.
if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") {
let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into());
let _ = std::fs::write(&path, id_str);
}
let called_name = msg
.get("params")
.and_then(|p| p.get("name"))
.and_then(Value::as_str)
.unwrap_or("");
// Optionally spawn a long-sleeping grandchild so the test
// can verify process-group killing reaches the whole tree.
if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") {
let child = std::process::Command::new("sleep")
.arg("999")
.spawn()
.expect("spawn grandchild");
if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") {
let _ = std::fs::write(&path, child.id().to_string());
}
std::mem::forget(child);
}
if called_name == "_Stop" {
if stop_delay_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs));
}
// Once we exceed the configured count, return empty
// text so the agent treats it as no objection. This
// lets a test exercise the "objected then cleared"
// path without relying on the rejection budget.
let payload = if stop_calls_seen < stop_count_limit {
stop_text.clone()
} else {
String::new()
};
stop_calls_seen = stop_calls_seen.saturating_add(1);
write_response(
id,
json!({
"content": [{ "type": "text", "text": payload }],
"isError": false,
}),
);
continue;
}
if called_name == "_PostCompact" {
write_response(
id,
json!({
"content": [{ "type": "text", "text": post_compact_text }],
"isError": false,
}),
);
continue;
}
if tool_delay_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs));
}
write_response(
id,
json!({
"content": [{ "type": "text", "text": "ok" }],
"isError": false,
}),
);
}
_ => {
// Unknown method: respond with an error so rmcp doesn't hang.
let err = json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": format!("method not found: {method}") },
});
let mut s = serde_json::to_string(&err).unwrap();
s.push('\n');
let mut out = std::io::stdout().lock();
let _ = out.write_all(s.as_bytes());
let _ = out.flush();
}
}
}
}

View File

@@ -0,0 +1,337 @@
//! Integration test: fake LLM HTTP server + sprout-agent subprocess.
//!
//! Drives the agent through the ACP wire protocol and verifies:
//! - initialize / session/new responses
//! - tool_call (pending) → request_permission → tool_call_update
//! - session/prompt response with stopReason=end_turn
//! - concurrent prompt rejection
use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
// ─── Fake LLM server ────────────────────────────────────────────────────────
async fn spawn_fake_llm(responses: Vec<Value>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
tokio::spawn(async move {
loop {
let (mut sock, _) = match listener.accept().await {
Ok(p) => p,
Err(_) => return,
};
let queue = queue.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
if buf.len() > 1_000_000 {
return;
}
}
let body = queue
.lock()
.await
.pop_front()
.unwrap_or_else(|| json!({ "error": "no canned response" }));
let body_s = serde_json::to_string(&body).unwrap();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body_s.len(), body_s,
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
url
}
// ─── ACP harness ────────────────────────────────────────────────────────────
struct Harness {
child: tokio::process::Child,
stdin: tokio::process::ChildStdin,
stdout: BufReader<tokio::process::ChildStdout>,
next_id: i64,
}
impl Harness {
async fn spawn(base_url: &str) -> Self {
let bin = env!("CARGO_BIN_EXE_sprout-agent");
let mut cmd = tokio::process::Command::new(bin);
cmd.env("SPROUT_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("OPENAI_COMPAT_BASE_URL", base_url)
.env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5")
.env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5")
.env("SPROUT_AGENT_MAX_ROUNDS", "4")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
let mut child = cmd.spawn().expect("spawn sprout-agent");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self {
child,
stdin,
stdout,
next_id: 1,
}
}
async fn send(&mut self, method: &str, params: Value) -> i64 {
let id = self.next_id;
self.next_id += 1;
self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
.await;
id
}
async fn write(&mut self, msg: Value) {
let mut s = serde_json::to_string(&msg).unwrap();
s.push('\n');
self.stdin.write_all(s.as_bytes()).await.unwrap();
self.stdin.flush().await.unwrap();
}
async fn recv(&mut self) -> Value {
let mut line = String::new();
let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line))
.await
.expect("recv timeout")
.expect("read line");
assert!(n > 0, "agent EOF");
serde_json::from_str(&line).expect("non-JSON line")
}
/// Read messages until one matches `pred`.
async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
loop {
let v = self.recv().await;
if pred(&v) {
return v;
}
}
}
async fn shutdown(mut self) {
drop(self.stdin);
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
let _ = self.child.start_kill();
}
}
// ─── Canned LLM responses (OpenAI-compat shape) ─────────────────────────────
fn openai_text(content: &str) -> Value {
json!({
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": content },
"finish_reason": "stop",
}],
})
}
fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
json!({
"id": "cc-2", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant", "content": null,
"tool_calls": [{
"id": id, "type": "function",
"function": { "name": name, "arguments": args.to_string() },
}],
},
"finish_reason": "tool_calls",
}],
})
}
async fn init_session(h: &mut Harness) -> String {
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let r = h.recv().await;
assert_eq!(r["result"]["protocolVersion"], 1);
assert_eq!(r["result"]["agentInfo"]["name"], "sprout-agent");
h.send("session/new", json!({"cwd":"/tmp","mcpServers":[]}))
.await;
let r = h.recv().await;
let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
assert!(sid.starts_with("ses_"));
sid
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn text_only_end_turn() {
let url = spawn_fake_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn(&url).await;
let sid = init_session(&mut h).await;
let p_id = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{ "type": "text", "text": "hi" }],
}),
)
.await;
let v = h.recv_until(|v| v["id"] == json!(p_id)).await;
assert_eq!(v["result"]["stopReason"], "end_turn");
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_call_then_end_turn() {
// Round 1: tool call (will fail with "unknown tool" since no MCP registered).
// Round 2: text response → end_turn.
let url = spawn_fake_llm(vec![
openai_tool_call("call_xyz", "fake__do_thing", json!({"foo": "bar"})),
openai_text("ok"),
])
.await;
let mut h = Harness::spawn(&url).await;
let sid = init_session(&mut h).await;
let p_id = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{"type":"text","text":"do something"}],
}),
)
.await;
// Tool unknown: agent emits failed tool_call_update directly (no permission ask).
let v = h
.recv_until(|v| {
v.get("method") == Some(&json!("session/update"))
&& v["params"]["update"]["sessionUpdate"] == "tool_call_update"
&& v["params"]["update"]["status"] == "failed"
})
.await;
assert_eq!(v["params"]["update"]["toolCallId"], "call_xyz");
// Final response.
let v = h.recv_until(|v| v["id"] == json!(p_id)).await;
assert_eq!(v["result"]["stopReason"], "end_turn");
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rejects_concurrent_prompts() {
// Slow first response so the second prompt arrives mid-flight.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
let n = sock.read(&mut tmp).await.unwrap_or(0);
if n == 0 {
return;
}
buf.extend_from_slice(&tmp[..n]);
}
tokio::time::sleep(Duration::from_millis(500)).await;
let body = openai_text("done").to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
let mut h = Harness::spawn(&url).await;
let sid = init_session(&mut h).await;
let p1 = h
.send(
"session/prompt",
json!({
"sessionId": sid, "prompt": [{"type":"text","text":"go"}],
}),
)
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
let p2 = h
.send(
"session/prompt",
json!({
"sessionId": sid, "prompt": [{"type":"text","text":"go again"}],
}),
)
.await;
let mut saw_p2_err = false;
let mut saw_p1_ok = false;
for _ in 0..10 {
let v = h.recv().await;
if v["id"] == json!(p2) {
assert_eq!(v["error"]["code"], -32602);
saw_p2_err = true;
} else if v["id"] == json!(p1) {
assert_eq!(v["result"]["stopReason"], "end_turn");
saw_p1_ok = true;
}
if saw_p1_ok && saw_p2_err {
break;
}
}
assert!(saw_p2_err, "expected concurrent prompt rejection");
assert!(saw_p1_ok, "first prompt didn't complete");
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rejects_oversized_line() {
// Set a tiny max line and send something larger; agent must abort with an
// io error and not OOM.
let url = spawn_fake_llm(vec![]).await;
let bin = env!("CARGO_BIN_EXE_sprout-agent");
let mut cmd = tokio::process::Command::new(bin);
cmd.env("SPROUT_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("OPENAI_COMPAT_BASE_URL", &url)
.env("SPROUT_AGENT_MAX_LINE_BYTES", "256")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
let mut child = cmd.spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
// 1024-byte line — agent should reject and exit.
let big = "x".repeat(1024);
let _ = stdin.write_all(big.as_bytes()).await;
let _ = stdin.write_all(b"\n").await;
drop(stdin);
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait())
.await
.expect("agent didn't exit after oversized line");
}

View File

@@ -0,0 +1,542 @@
use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
struct Harness {
child: tokio::process::Child,
stdin: tokio::process::ChildStdin,
stdout: BufReader<tokio::process::ChildStdout>,
next_id: i64,
}
impl Harness {
async fn spawn(extra: &[(&str, &str)]) -> Self {
let bin = env!("CARGO_BIN_EXE_sprout-agent");
let mut cmd = tokio::process::Command::new(bin);
cmd.env("SPROUT_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5")
.env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5")
.env("SPROUT_AGENT_MAX_ROUNDS", "4")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
for (k, v) in extra {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sprout-agent");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self {
child,
stdin,
stdout,
next_id: 1,
}
}
async fn send(&mut self, method: &str, params: Value) -> i64 {
let id = self.next_id;
self.next_id += 1;
self.write_json(json!({
"jsonrpc": "2.0", "id": id, "method": method, "params": params
}))
.await;
id
}
async fn notify(&mut self, method: &str, params: Value) {
self.write_json(json!({
"jsonrpc": "2.0", "method": method, "params": params
}))
.await;
}
async fn write_json(&mut self, msg: Value) {
let mut s = serde_json::to_string(&msg).unwrap();
s.push('\n');
self.stdin.write_all(s.as_bytes()).await.unwrap();
self.stdin.flush().await.unwrap();
}
async fn write_raw(&mut self, raw: &[u8]) {
let _ = self.stdin.write_all(raw).await;
let _ = self.stdin.flush().await;
}
async fn recv(&mut self) -> Value {
let mut line = String::new();
let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line))
.await
.expect("recv timeout")
.expect("read line");
assert!(n > 0, "agent EOF");
serde_json::from_str(&line).expect("non-JSON line")
}
async fn recv_for_id(&mut self, id: i64) -> Value {
loop {
let v = self.recv().await;
if v["id"] == json!(id) {
return v;
}
}
}
async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
loop {
let v = self.recv().await;
if pred(&v) {
return v;
}
}
}
async fn shutdown(mut self) {
drop(self.stdin);
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
let _ = self.child.start_kill();
}
}
async fn spawn_fake_llm(responses: Vec<Value>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
tokio::spawn(async move {
loop {
let (mut sock, _) = match listener.accept().await {
Ok(p) => p,
Err(_) => return,
};
let queue = queue.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
if buf.len() > 1_000_000 {
return;
}
}
let body = queue
.lock()
.await
.pop_front()
.unwrap_or_else(|| json!({ "error": "no canned response" }));
let body_s = serde_json::to_string(&body).unwrap();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body_s.len(),
body_s,
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
url
}
fn openai_text(content: &str) -> Value {
json!({
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": content },
"finish_reason": "stop",
}],
})
}
fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
json!({
"id": "cc-2", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant", "content": null,
"tool_calls": [{
"id": id, "type": "function",
"function": { "name": name, "arguments": args.to_string() },
}],
},
"finish_reason": "tool_calls",
}],
})
}
async fn handshake(h: &mut Harness) -> String {
let init_id = h
.send(
"initialize",
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
)
.await;
let init = h.recv_for_id(init_id).await;
assert_eq!(init["result"]["protocolVersion"], 1);
assert_eq!(init["result"]["agentInfo"]["name"], "sprout-agent");
assert_eq!(
init["result"]["agentCapabilities"]["promptCapabilities"]["image"],
false
);
let new_id = h
.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
.await;
let new = h.recv_for_id(new_id).await;
let sid = new["result"]["sessionId"].as_str().unwrap().to_owned();
assert!(sid.starts_with("ses_"));
sid
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_text_only_response() {
let url = spawn_fake_llm(vec![openai_text("hello back")]).await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let sid = handshake(&mut h).await;
let p = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{ "type": "text", "text": "hi" }],
}),
)
.await;
let result = h.recv_for_id(p).await;
assert_eq!(result["result"]["stopReason"], "end_turn");
assert!(result.get("error").is_none());
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_full_tool_call_transcript() {
let url = spawn_fake_llm(vec![
openai_tool_call("call_xyz", "fake__do_thing", json!({ "foo": "bar" })),
openai_text("done"),
])
.await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let sid = handshake(&mut h).await;
let p = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{ "type": "text", "text": "use the tool" }],
}),
)
.await;
let failed = h
.recv_until(|v| {
v.get("method") == Some(&json!("session/update"))
&& v["params"]["update"]["sessionUpdate"] == "tool_call_update"
&& v["params"]["update"]["status"] == "failed"
})
.await;
assert_eq!(failed["params"]["sessionId"], sid);
assert_eq!(failed["params"]["update"]["toolCallId"], "call_xyz");
assert_eq!(
failed["params"]["update"]["rawOutput"]["error"],
"unknown tool: fake__do_thing"
);
let final_resp = h.recv_for_id(p).await;
assert_eq!(final_resp["result"]["stopReason"], "end_turn");
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_permission_denied_continues() {
let url = spawn_fake_llm(vec![openai_text("ok with no tool")]).await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let sid = handshake(&mut h).await;
let p = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{ "type": "text", "text": "hi" }],
}),
)
.await;
let final_resp = h.recv_for_id(p).await;
assert_eq!(final_resp["result"]["stopReason"], "end_turn");
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_initialize_version_check() {
let url = spawn_fake_llm(vec![]).await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let id = h
.send(
"initialize",
json!({ "protocolVersion": 99, "clientCapabilities": {} }),
)
.await;
let resp = h.recv_for_id(id).await;
assert_eq!(resp["result"]["protocolVersion"], 1);
let id2 = h
.send(
"initialize",
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
)
.await;
let ok = h.recv_for_id(id2).await;
assert_eq!(ok["result"]["protocolVersion"], 1);
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_session_new_rejects_relative_cwd() {
let url = spawn_fake_llm(vec![]).await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let _ = h
.send(
"initialize",
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
)
.await;
let _ = h.recv().await;
let id = h
.send(
"session/new",
json!({ "cwd": "relative/path", "mcpServers": [] }),
)
.await;
let resp = h.recv_for_id(id).await;
assert_eq!(resp["error"]["code"], -32602);
assert!(resp["error"]["message"]
.as_str()
.unwrap()
.contains("cwd must be an absolute path"));
let id_empty = h
.send("session/new", json!({ "cwd": "", "mcpServers": [] }))
.await;
let resp = h.recv_for_id(id_empty).await;
assert_eq!(resp["error"]["code"], -32602);
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_malformed_json_rpc() {
let url = spawn_fake_llm(vec![]).await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
h.write_raw(b"this is not json\n").await;
let v = h.recv().await;
assert_eq!(v["error"]["code"], -32700);
assert_eq!(v["id"], Value::Null);
h.write_json(json!({ "jsonrpc": "1.0", "method": "initialize", "id": 1 }))
.await;
let v = h.recv().await;
assert_eq!(v["error"]["code"], -32600);
h.write_json(json!({ "jsonrpc": "2.0" })).await;
let v = h.recv().await;
assert_eq!(v["error"]["code"], -32600);
let init_id = h
.send(
"initialize",
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
)
.await;
let ok = h.recv_for_id(init_id).await;
assert_eq!(ok["result"]["protocolVersion"], 1);
let bad_id = h.send("nonsense/method", json!({})).await;
let v = h.recv_for_id(bad_id).await;
assert_eq!(v["error"]["code"], -32601);
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unsupported_content_block() {
let url = spawn_fake_llm(vec![openai_text("ok")]).await;
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let sid = handshake(&mut h).await;
let p = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{ "type": "image", "data": "..." }],
}),
)
.await;
let resp = h.recv_for_id(p).await;
assert_eq!(resp["error"]["code"], -32602);
assert!(resp["error"]["message"]
.as_str()
.unwrap()
.contains("unsupported content block"));
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_concurrent_prompt_rejected() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
let n = sock.read(&mut tmp).await.unwrap_or(0);
if n == 0 {
return;
}
buf.extend_from_slice(&tmp[..n]);
}
tokio::time::sleep(Duration::from_millis(500)).await;
let body = openai_text("done").to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let sid = handshake(&mut h).await;
let p1 = h
.send(
"session/prompt",
json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }),
)
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
let p2 = h
.send(
"session/prompt",
json!({ "sessionId": sid, "prompt": [{"type":"text","text":"again"}] }),
)
.await;
let mut p1_ok = false;
let mut p2_err = false;
for _ in 0..10 {
let v = h.recv().await;
if v["id"] == json!(p1) {
assert_eq!(v["result"]["stopReason"], "end_turn");
p1_ok = true;
} else if v["id"] == json!(p2) {
assert_eq!(v["error"]["code"], -32602);
p2_err = true;
}
if p1_ok && p2_err {
break;
}
}
assert!(p1_ok && p2_err, "expected p1=ok, p2=busy");
h.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_oversized_line_kills_agent() {
let url = spawn_fake_llm(vec![]).await;
let bin = env!("CARGO_BIN_EXE_sprout-agent");
let mut cmd = tokio::process::Command::new(bin);
cmd.env("SPROUT_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("OPENAI_COMPAT_BASE_URL", &url)
.env("SPROUT_AGENT_MAX_LINE_BYTES", "256")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
let mut child = cmd.spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let big = "x".repeat(1024);
let _ = stdin.write_all(big.as_bytes()).await;
let _ = stdin.write_all(b"\n").await;
drop(stdin);
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait())
.await
.expect("agent did not exit on oversized line");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cancel_notification_no_reply() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
let n = sock.read(&mut tmp).await.unwrap_or(0);
if n == 0 {
return;
}
buf.extend_from_slice(&tmp[..n]);
}
tokio::time::sleep(Duration::from_millis(800)).await;
let body = openai_text("done").to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
let sid = handshake(&mut h).await;
let p = h
.send(
"session/prompt",
json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }),
)
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
h.notify("session/cancel", json!({ "sessionId": sid }))
.await;
let final_resp = h.recv_for_id(p).await;
let stop = final_resp["result"]["stopReason"].as_str().unwrap_or("");
assert!(
stop == "cancelled" || stop == "end_turn",
"unexpected stopReason {stop}"
);
h.shutdown().await;
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,14 +17,10 @@ nostr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
jsonwebtoken = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true }
rand = { workspace = true }
uuid = { workspace = true }
subtle = "2"
url = { workspace = true }

View File

@@ -23,31 +23,6 @@ pub enum AuthError {
#[error("auth event timestamp outside ±60s window")]
EventExpired,
/// JWT validation failed (bad signature, expired, wrong issuer/audience, missing claim, etc.).
///
/// The inner string provides diagnostics for server logs. Do **not** forward
/// this detail to unauthenticated WebSocket clients.
#[error("invalid JWT: {0}")]
InvalidJwt(String),
/// The API token hash does not match, or the token has expired.
#[error("api token invalid or expired")]
TokenInvalid,
/// The API token was found in the database but has been revoked.
///
/// Distinct from [`TokenInvalid`] so the relay can return `401 token_revoked`
/// rather than the generic `invalid_token` error code.
#[error("api token has been revoked")]
TokenRevoked,
/// The API token was found in the database but has passed its expiry timestamp.
///
/// Distinct from [`TokenInvalid`] so the relay can return `401 token_expired`
/// rather than the generic `invalid_token` error code.
#[error("api token has expired")]
TokenExpired,
/// NIP-98 HTTP Auth event (kind:27235) failed verification.
///
/// The inner string describes the specific failure (signature, timestamp, URL, etc.)
@@ -55,7 +30,7 @@ pub enum AuthError {
#[error("NIP-98 HTTP Auth verification failed: {0}")]
Nip98Invalid(String),
/// The pubkey in the NIP-42 event does not match the identity in the JWT or API token.
/// The pubkey in the auth event does not match the expected identity.
#[error("pubkey mismatch: event pubkey does not match authenticated identity")]
PubkeyMismatch,
@@ -72,12 +47,6 @@ pub enum AuthError {
#[error("channel access denied")]
ChannelAccessDenied,
/// The JWKS endpoint returned an error or an unparseable response.
///
/// The inner string provides diagnostics for server logs.
#[error("jwks fetch error: {0}")]
JwksFetchError(String),
/// An unexpected internal error occurred (e.g. a `spawn_blocking` panic).
#[error("internal auth error: {0}")]
Internal(String),

View File

@@ -7,13 +7,13 @@
//! | Path | Transport | Description |
//! |------|-----------|-------------|
//! | NIP-42 | WebSocket | Challenge/response; client signs kind:22242 event |
//! | Okta JWT | NIP-42 `auth_token` tag | SSO via Okta JWKS validation |
//! | API token | NIP-42 `auth_token` tag | Hash stored in DB; see below |
//! | NIP-98 | HTTP | Signed kind:27235 event in `Authorization: Nostr` header |
//!
//! ## Security invariants
//!
//! - **AUTH events (kind:22242) are NEVER stored or logged.**
//! - All paths produce an [`AuthContext`] bound to the WebSocket connection.
//! - All paths produce an [`AuthContext`] bound to the connection.
//! - No JWT validation, no token management, no IdP runtime dependency.
/// Channel access checking trait and helpers.
pub mod access;
@@ -23,25 +23,19 @@ pub mod error;
pub mod nip42;
/// NIP-98 HTTP Auth verification (kind:27235).
pub mod nip98;
/// Okta OIDC integration and JWKS validation.
pub mod okta;
/// Per-connection rate limiting.
pub mod rate_limit;
/// OAuth scope parsing and enforcement.
pub mod scope;
/// API token hashing and verification.
pub mod token;
pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
pub use error::AuthError;
pub use nip42::{generate_challenge, verify_nip42_event};
pub use nip98::verify_nip98_event;
pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig};
pub use rate_limit::{
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
};
pub use scope::{is_self_mintable, parse_scopes, Scope, SELF_MINTABLE_SCOPES};
pub use token::{generate_token, hash_token, verify_token_hash};
pub use scope::{parse_scopes, Scope};
#[cfg(any(test, feature = "test-utils"))]
pub use access::MockAccessChecker;
@@ -51,29 +45,30 @@ pub use rate_limit::AlwaysAllowRateLimiter;
/// How the connection was authenticated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthMethod {
/// NIP-42 challenge/response only — no JWT or API token present.
///
/// Only possible when `require_token = false` (dev/open-relay mode).
Nip42PubkeyOnly,
/// NIP-42 with an Okta JWT bearer token in the `auth_token` tag.
Nip42Okta,
/// NIP-42 with a `sprout_` API token in the `auth_token` tag.
Nip42ApiToken,
/// NIP-42 challenge/response — Schnorr signature over kind:22242.
Nip42,
/// NIP-98 HTTP Auth — Schnorr signature over kind:27235.
Nip98,
}
/// The result of a successful authentication, bound to a WebSocket connection.
/// The result of a successful authentication, bound to a connection.
#[derive(Debug, Clone)]
pub struct AuthContext {
/// The authenticated Nostr public key.
pub pubkey: nostr::PublicKey,
/// Permission scopes granted to this connection.
pub scopes: Vec<Scope>,
/// Token-level channel restriction, if authentication used a scoped API token.
/// Channel restriction (reserved for future per-channel access control).
///
/// `None` means unrestricted or not token-authenticated.
/// `None` means unrestricted.
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// How the connection was authenticated.
pub auth_method: AuthMethod,
/// NIP-OA verified owner pubkey (if authenticated via owner attestation).
///
/// `None` for direct relay members or non-NIP-OA auth paths.
/// Set by the relay membership gate when NIP-OA fallback succeeds.
pub agent_owner_pubkey: Option<nostr::PublicKey>,
}
impl AuthContext {
@@ -86,56 +81,39 @@ impl AuthContext {
/// Top-level authentication configuration, typically loaded from the relay's TOML config file.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct AuthConfig {
/// Okta OIDC settings (issuer, audience, JWKS URI, etc.).
#[serde(default)]
pub okta: OktaConfig,
/// Per-user and per-IP rate limit thresholds.
#[serde(default)]
pub rate_limits: RateLimitConfig,
}
/// Primary authentication service.
///
/// Holds shared state (JWKS cache, HTTP client, config). Clone-cheap (Arc internals).
///
/// **API token auth** is not handled here — `AuthService` has no database access.
/// The relay layer must intercept API tokens from the `auth_token` tag and call
/// [`AuthService::verify_api_token_against_hash`] after fetching the token record.
/// Simplified auth service — NIP-42 and NIP-98 only.
/// No JWT validation, no token management, no IdP runtime dependency.
#[derive(Debug, Clone)]
pub struct AuthService {
config: AuthConfig,
jwks_cache: std::sync::Arc<JwksCache>,
http_client: reqwest::Client,
}
impl AuthService {
/// Create a new `AuthService` with the given configuration.
///
/// Initialises a fresh JWKS cache and a shared `reqwest::Client`.
/// Intended to be constructed once at startup and shared via `Arc<AuthService>`.
pub fn new(config: AuthConfig) -> Self {
Self {
config,
jwks_cache: JwksCache::new(),
http_client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("SAFETY: default builder with timeout config cannot fail"),
}
Self { config }
}
/// Return a reference to the auth configuration.
pub fn config(&self) -> &AuthConfig {
&self.config
}
/// Verify a NIP-42 AUTH event and return an [`AuthContext`].
///
/// Validates event structure, signature, challenge, relay URL, timestamp,
/// then dispatches to Okta JWT validation if a bearer token is present.
/// The `auth_event` is **not** retained after this call.
/// Pure cryptographic verification — no network calls, no JWT, no tokens.
pub async fn verify_auth_event(
&self,
auth_event: nostr::Event,
expected_challenge: &str,
relay_url: &str,
) -> Result<AuthContext, AuthError> {
// Verify NIP-42 signature (spawn_blocking for CPU-bound Schnorr verify)
let event_clone = auth_event.clone();
let challenge_owned = expected_challenge.to_string();
let relay_owned = relay_url.to_string();
@@ -145,189 +123,23 @@ impl AuthService {
.await
.map_err(|_| AuthError::Internal("spawn_blocking panicked".into()))??;
// ⚠️ SECURITY: Do NOT log auth_token — it contains a bearer token.
let auth_token = auth_event
.tags
.iter()
.find(|t| t.kind().to_string() == "auth_token")
.and_then(|t| t.content())
.map(|s| s.to_string());
let (verified_pubkey, scopes, auth_method) = match auth_token.as_deref() {
Some(token) if token.starts_with("eyJ") => {
let (pk, sc) = self.verify_okta_jwt(token, &auth_event.pubkey).await?;
(pk, sc, AuthMethod::Nip42Okta)
}
Some(_) => {
// API tokens require a DB lookup the relay must perform before
// calling verify_auth_event. Reaching here means the relay
// hasn't intercepted the token.
return Err(AuthError::TokenInvalid);
}
None => {
if self.config.okta.require_token {
return Err(AuthError::InvalidJwt(
"auth_token tag required in production mode".into(),
));
}
// Default-open: no token present and require_token=false.
// Grant all scopes so the connection is fully usable in dev mode.
// The ingest pipeline enforces per-kind scope checks, so NIP-42
// pubkey-only connections need the full set — including admin
// scopes for kind:9000 (add member), kind:9001 (remove member),
// kind:9008 (delete group), etc.
(
auth_event.pubkey,
Scope::all_known(),
AuthMethod::Nip42PubkeyOnly,
)
}
};
if verified_pubkey != auth_event.pubkey {
return Err(AuthError::PubkeyMismatch);
}
// In pure Nostr mode, all authenticated connections get full scopes.
// Per-channel access is enforced by the relay's membership checks (NIP-29).
Ok(AuthContext {
pubkey: verified_pubkey,
scopes,
pubkey: auth_event.pubkey,
scopes: Scope::all_known(),
channel_ids: None,
auth_method,
auth_method: AuthMethod::Nip42,
agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA
})
}
async fn verify_okta_jwt(
&self,
jwt: &str,
claimed_pubkey: &nostr::PublicKey,
) -> Result<(nostr::PublicKey, Vec<Scope>), AuthError> {
let cached = self
.jwks_cache
.get_or_refresh(
&self.config.okta.jwks_uri,
self.config.okta.jwks_refresh_secs,
&self.http_client,
)
.await?;
let claims = cached.validate(jwt, &self.config.okta.issuer, &self.config.okta.audience)?;
let pubkey_hex = claims
.get(&self.config.okta.pubkey_claim)
.and_then(|v| v.as_str())
.ok_or_else(|| {
AuthError::InvalidJwt(format!(
"missing '{}' claim in JWT",
self.config.okta.pubkey_claim
))
})?;
let pubkey = nostr::PublicKey::from_hex(pubkey_hex)
.map_err(|_| AuthError::InvalidJwt("invalid pubkey hex in JWT claim".into()))?;
if &pubkey != claimed_pubkey {
return Err(AuthError::PubkeyMismatch);
}
let scopes = extract_scopes_from_claims(&claims);
Ok((pubkey, scopes))
}
/// Validate a raw JWT Bearer token (no Nostr event wrapper).
///
/// Returns the authenticated pubkey and scopes. Used by HTTP REST API endpoints
/// where there is no NIP-42 Nostr event to compare against — only a raw JWT.
///
/// Reuses the existing JWKS validation logic but skips the pubkey cross-check
/// (there is no claimed_pubkey from a Nostr event in the HTTP path).
pub async fn validate_bearer_jwt(
&self,
jwt: &str,
) -> Result<(nostr::PublicKey, Vec<Scope>), AuthError> {
let cached = self
.jwks_cache
.get_or_refresh(
&self.config.okta.jwks_uri,
self.config.okta.jwks_refresh_secs,
&self.http_client,
)
.await?;
let claims = cached.validate(jwt, &self.config.okta.issuer, &self.config.okta.audience)?;
let pubkey_hex = claims
.get(&self.config.okta.pubkey_claim)
.and_then(|v| v.as_str())
.ok_or_else(|| {
AuthError::InvalidJwt(format!(
"missing '{}' claim in JWT",
self.config.okta.pubkey_claim
))
})?;
let pubkey = nostr::PublicKey::from_hex(pubkey_hex)
.map_err(|_| AuthError::InvalidJwt("invalid pubkey hex in JWT claim".into()))?;
// Extract scopes from the JWT claims. `extract_scopes_from_claims` returns
// `[MessagesRead]` when no scope claim is present (read-only safe default).
// HTTP REST callers additionally need `ChannelsRead` to list channels, so we
// always ensure that scope is present regardless of what the token says.
let scopes = {
let mut extracted = extract_scopes_from_claims(&claims);
if !extracted.contains(&Scope::ChannelsRead) {
extracted.push(Scope::ChannelsRead);
}
extracted
};
Ok((pubkey, scopes))
}
/// Verify a raw API token against a pre-fetched hash from the database.
///
/// The relay layer is responsible for fetching the token record (hash, owner pubkey,
/// expiry, scopes) from the database before calling this method. This keeps
/// `sprout-auth` free of database dependencies.
///
/// Returns `(owner_pubkey, scopes)` on success.
///
/// # Errors
///
/// - [`AuthError::TokenInvalid`] — hash mismatch or token expired.
/// - [`AuthError::PubkeyMismatch`] — `claimed_pubkey` does not match the token owner.
pub fn verify_api_token_against_hash(
&self,
raw_token: &str,
stored_hash: &[u8],
owner_pubkey: &nostr::PublicKey,
claimed_pubkey: &nostr::PublicKey,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
scopes_raw: &[String],
) -> Result<(nostr::PublicKey, Vec<Scope>), AuthError> {
if !verify_token_hash(raw_token, stored_hash) {
return Err(AuthError::TokenInvalid);
}
if let Some(exp) = expires_at {
if exp < chrono::Utc::now() {
return Err(AuthError::TokenInvalid);
}
}
if owner_pubkey != claimed_pubkey {
return Err(AuthError::PubkeyMismatch);
}
let scopes = parse_scopes(scopes_raw);
Ok((*owner_pubkey, scopes))
}
}
/// Derive a deterministic Nostr pubkey from a username string.
///
/// Uses `SHA-256("sprout-test-key:{username}")` as the secret key material.
/// This matches the derivation used by the desktop's `set_test_identity` function,
/// allowing the relay to resolve Keycloak usernames to Nostr pubkeys in dev mode.
/// allowing the relay to resolve usernames to Nostr pubkeys in dev mode.
///
/// # ⚠️ SECURITY — Dev/test only
///
@@ -336,23 +148,6 @@ impl AuthService {
///
/// - The derived keys are deterministic and predictable from the username alone.
/// - Any attacker who knows a username can compute the corresponding private key.
/// - In production, JWTs must contain a real `nostr_pubkey` claim issued by Okta.
///
/// ## When it is compiled in
///
/// | Build command | Included? | Reason |
/// |---|---|---|
/// | `cargo test` | ✅ Yes | `test` cfg |
/// | `cargo build` (debug) | ❌ No | Not included without `dev` feature |
/// | `cargo build --release` | ❌ No | Neither `test` nor `dev` feature |
/// | `cargo build --release --features dev` | ✅ Yes | `dev` feature — use only for integration harnesses |
///
/// ## The `dev` feature
///
/// The `dev` feature exists solely to enable this function (and other dev-mode
/// helpers) in release-mode integration test harnesses. It must **not** be
/// enabled in production relay deployments. Check `sprout-relay/Cargo.toml` to
/// ensure `sprout-auth` is not listed with `features = ["dev"]` in production.
#[cfg(any(test, feature = "dev"))]
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
use sha2::{Digest, Sha256};
@@ -363,44 +158,9 @@ pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, A
Ok(nostr::Keys::new(secret_key).public_key())
}
/// Extract scopes from JWT claims (`scp` array or `scope` space-delimited string).
///
/// Checks `scp` (Okta array format) first, then `scope` (RFC 8693 space-delimited string).
///
/// # Missing scope claim
///
/// When a **valid, signature-verified JWT** contains no `scp` or `scope` claim at all,
/// this function returns **read-only** (`[MessagesRead]`). This is a deliberate
/// security default: a token that omits scopes entirely should not silently gain
/// write access. Production Okta configurations must include explicit scope claims.
///
/// Note: the `None` (no token) path in [`AuthService::verify_auth_event`] grants
/// `[MessagesRead, MessagesWrite]` when `require_token = false` — that is a
/// separate, intentional dev-mode behaviour documented there.
fn extract_scopes_from_claims(
claims: &std::collections::HashMap<String, serde_json::Value>,
) -> Vec<Scope> {
if let Some(scp) = claims.get("scp").and_then(|v| v.as_array()) {
let raw: Vec<String> = scp
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
return parse_scopes(&raw);
}
if let Some(scope_str) = claims.get("scope").and_then(|v| v.as_str()) {
let raw: Vec<String> = scope_str.split_whitespace().map(str::to_string).collect();
return parse_scopes(&raw);
}
// JWT is valid (signature verified) but contains no scope claim.
// Default to read-only — never silently grant write access from a scopeless token.
// Production Okta configs must include explicit `scp` or `scope` claims.
vec![Scope::MessagesRead]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token;
use nostr::{EventBuilder, Keys, Kind, Url};
fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> nostr::Event {
@@ -410,10 +170,8 @@ mod tests {
.expect("signing failed")
}
fn open_mode_service() -> AuthService {
let mut config = AuthConfig::default();
config.okta.require_token = false;
AuthService::new(config)
fn test_service() -> AuthService {
AuthService::new(AuthConfig::default())
}
#[test]
@@ -423,26 +181,27 @@ mod tests {
pubkey: keys.public_key(),
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
channel_ids: None,
auth_method: AuthMethod::Nip42PubkeyOnly,
auth_method: AuthMethod::Nip42,
agent_owner_pubkey: None,
};
assert!(ctx.has_scope(&Scope::MessagesRead));
assert!(!ctx.has_scope(&Scope::MessagesWrite));
}
#[tokio::test]
async fn open_mode_auth_succeeds() {
async fn nip42_auth_succeeds() {
let keys = Keys::generate();
let challenge = generate_challenge();
let relay = "wss://relay.example.com";
let event = make_auth_event(&keys, &challenge, relay);
let ctx = open_mode_service()
let ctx = test_service()
.verify_auth_event(event, &challenge, relay)
.await
.expect("open-mode auth should succeed");
.expect("NIP-42 auth should succeed");
assert_eq!(ctx.pubkey, keys.public_key());
assert_eq!(ctx.auth_method, AuthMethod::Nip42PubkeyOnly);
assert_eq!(ctx.auth_method, AuthMethod::Nip42);
assert!(ctx.has_scope(&Scope::MessagesRead));
assert!(ctx.has_scope(&Scope::MessagesWrite));
}
@@ -454,7 +213,7 @@ mod tests {
let relay = "wss://relay.example.com";
let event = make_auth_event(&keys, &challenge, relay);
let result = open_mode_service()
let result = test_service()
.verify_auth_event(event, "wrong-challenge", relay)
.await;
assert!(matches!(result, Err(AuthError::ChallengeMismatch)));
@@ -467,133 +226,9 @@ mod tests {
.sign_with_keys(&keys)
.expect("sign");
let result = open_mode_service()
let result = test_service()
.verify_auth_event(event, &generate_challenge(), "wss://relay.example.com")
.await;
assert!(matches!(result, Err(AuthError::InvalidSignature)));
}
#[tokio::test]
async fn require_token_enforced() {
let keys = Keys::generate();
let challenge = generate_challenge();
let relay = "wss://relay.example.com";
let event = make_auth_event(&keys, &challenge, relay);
let result = AuthService::new(AuthConfig::default())
.verify_auth_event(event, &challenge, relay)
.await;
assert!(matches!(result, Err(AuthError::InvalidJwt(_))));
}
#[test]
fn extract_scopes_from_scp_array() {
let mut claims = std::collections::HashMap::new();
claims.insert(
"scp".to_string(),
serde_json::json!(["messages:read", "channels:write"]),
);
let scopes = extract_scopes_from_claims(&claims);
assert!(scopes.contains(&Scope::MessagesRead));
assert!(scopes.contains(&Scope::ChannelsWrite));
}
#[test]
fn extract_scopes_from_scope_string() {
let mut claims = std::collections::HashMap::new();
claims.insert(
"scope".to_string(),
serde_json::json!("messages:read messages:write"),
);
let scopes = extract_scopes_from_claims(&claims);
assert!(scopes.contains(&Scope::MessagesRead));
assert!(scopes.contains(&Scope::MessagesWrite));
}
#[test]
fn extract_scopes_defaults_when_absent() {
// A JWT with no scope claim should default to read-only, NOT read+write.
// Silently granting write access from a scopeless token would be a privilege escalation.
let scopes = extract_scopes_from_claims(&std::collections::HashMap::new());
assert!(scopes.contains(&Scope::MessagesRead));
assert!(
!scopes.contains(&Scope::MessagesWrite),
"scopeless JWT must NOT grant write access"
);
assert_eq!(scopes.len(), 1, "default is exactly [MessagesRead]");
}
#[test]
fn verify_api_token_valid() {
let service = open_mode_service();
let keys = Keys::generate();
let pubkey = keys.public_key();
let raw = token::generate_token();
let hash = token::hash_token(&raw);
let scopes_raw = vec!["messages:read".to_string(), "messages:write".to_string()];
let result =
service.verify_api_token_against_hash(&raw, &hash, &pubkey, &pubkey, None, &scopes_raw);
assert!(result.is_ok());
let (pk, scopes) = result.unwrap();
assert_eq!(pk, pubkey);
assert!(scopes.contains(&Scope::MessagesRead));
assert!(scopes.contains(&Scope::MessagesWrite));
}
#[test]
fn verify_api_token_wrong_hash_rejected() {
let service = open_mode_service();
let keys = Keys::generate();
let pubkey = keys.public_key();
let raw = token::generate_token();
let wrong_hash = token::hash_token("not-the-right-token");
let result =
service.verify_api_token_against_hash(&raw, &wrong_hash, &pubkey, &pubkey, None, &[]);
assert!(matches!(result, Err(AuthError::TokenInvalid)));
}
#[test]
fn verify_api_token_expired_rejected() {
let service = open_mode_service();
let keys = Keys::generate();
let pubkey = keys.public_key();
let raw = token::generate_token();
let hash = token::hash_token(&raw);
let expired = chrono::Utc::now() - chrono::Duration::seconds(1);
let result = service.verify_api_token_against_hash(
&raw,
&hash,
&pubkey,
&pubkey,
Some(expired),
&[],
);
assert!(matches!(result, Err(AuthError::TokenInvalid)));
}
#[test]
fn verify_api_token_pubkey_mismatch_rejected() {
let service = open_mode_service();
let owner_keys = Keys::generate();
let claimed_keys = Keys::generate();
let raw = token::generate_token();
let hash = token::hash_token(&raw);
let result = service.verify_api_token_against_hash(
&raw,
&hash,
&owner_keys.public_key(),
&claimed_keys.public_key(),
None,
&[],
);
assert!(matches!(result, Err(AuthError::PubkeyMismatch)));
}
}

View File

@@ -291,36 +291,4 @@ mod tests {
let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
assert!(result.is_ok());
}
#[test]
fn is_self_mintable_all_eight() {
use crate::scope::{is_self_mintable, Scope};
assert!(is_self_mintable(&Scope::MessagesRead));
assert!(is_self_mintable(&Scope::MessagesWrite));
assert!(is_self_mintable(&Scope::ChannelsRead));
assert!(is_self_mintable(&Scope::ChannelsWrite));
assert!(is_self_mintable(&Scope::UsersRead));
assert!(is_self_mintable(&Scope::UsersWrite));
assert!(is_self_mintable(&Scope::FilesRead));
assert!(is_self_mintable(&Scope::FilesWrite));
}
#[test]
fn is_self_mintable_admin_scopes_false() {
use crate::scope::{is_self_mintable, Scope};
assert!(!is_self_mintable(&Scope::AdminChannels));
assert!(!is_self_mintable(&Scope::AdminUsers));
assert!(!is_self_mintable(&Scope::JobsRead));
assert!(!is_self_mintable(&Scope::JobsWrite));
assert!(!is_self_mintable(&Scope::SubscriptionsRead));
assert!(!is_self_mintable(&Scope::SubscriptionsWrite));
}
#[test]
fn is_self_mintable_unknown_false() {
use crate::scope::{is_self_mintable, Scope};
assert!(!is_self_mintable(&Scope::Unknown(
"future:scope".to_string()
)));
}
}

View File

@@ -1,354 +0,0 @@
//! Okta JWT validation via JWKS.
//!
//! Fetches and caches the JWKS, validates JWTs (signature, expiry, issuer, audience).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::error::AuthError;
/// Default TTL for the JWKS cache in seconds (5 minutes).
///
/// After this interval the next auth attempt will trigger a background re-fetch.
/// Tune via [`OktaConfig::jwks_refresh_secs`] for environments with faster key rotation.
pub const JWKS_CACHE_TTL_SECS: u64 = 300;
/// A JSON Web Key Set as returned by the OIDC `/keys` endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct Jwks {
/// The list of public keys in this set.
pub keys: Vec<Jwk>,
}
/// A single JSON Web Key (RSA or EC public key).
///
/// Only the fields required for signature verification are used.
/// Unknown fields are ignored during deserialization.
#[derive(Debug, Clone, Deserialize)]
pub struct Jwk {
/// Key type: `"RSA"` or `"EC"`.
pub kty: String,
/// Key ID — matched against the JWT `kid` header to select the right key.
pub kid: Option<String>,
/// Algorithm hint (e.g. `"RS256"`, `"ES256"`).
pub alg: Option<String>,
/// RSA modulus (base64url-encoded).
pub n: Option<String>,
/// RSA public exponent (base64url-encoded).
pub e: Option<String>,
/// EC curve name (e.g. `"P-256"`).
pub crv: Option<String>,
/// EC public key x-coordinate (base64url-encoded).
pub x: Option<String>,
/// EC public key y-coordinate (base64url-encoded).
pub y: Option<String>,
}
/// A fetched JWKS together with the [`Instant`] it was retrieved.
///
/// Used by [`JwksCache`] to determine whether the cached keys are still fresh.
#[derive(Debug, Clone)]
pub struct CachedJwks {
/// The fetched key set.
pub jwks: Jwks,
/// Wall-clock time at which this entry was populated.
pub fetched_at: Instant,
}
impl CachedJwks {
/// Validate a JWT and return decoded claims.
pub fn validate(
&self,
jwt: &str,
issuer: &str,
audience: &str,
) -> Result<HashMap<String, Value>, AuthError> {
let header = decode_header(jwt)
.map_err(|e| AuthError::InvalidJwt(format!("bad jwt header: {e}")))?;
let kid = header.kid.as_deref();
let jwk = self
.find_key(kid, &header.alg)
.ok_or_else(|| AuthError::InvalidJwt("no matching key in JWKS".into()))?;
let decoding_key = Self::decoding_key_from_jwk(jwk)?;
let mut validation = Validation::new(header.alg);
validation.set_issuer(&[issuer]);
validation.set_audience(&[audience]);
let token_data = decode::<HashMap<String, Value>>(jwt, &decoding_key, &validation)
.map_err(|e| AuthError::InvalidJwt(format!("jwt validation failed: {e}")))?;
Ok(token_data.claims)
}
fn find_key(&self, kid: Option<&str>, alg: &Algorithm) -> Option<&Jwk> {
self.jwks.keys.iter().find(|k| {
let kid_match = kid.is_none_or(|id| k.kid.as_deref() == Some(id));
let alg_match = k.alg.as_ref().is_none_or(|a| matches_algorithm(a, alg));
kid_match && alg_match
})
}
fn decoding_key_from_jwk(jwk: &Jwk) -> Result<DecodingKey, AuthError> {
match jwk.kty.as_str() {
"RSA" => {
let n = jwk
.n
.as_deref()
.ok_or_else(|| AuthError::InvalidJwt("RSA key missing 'n'".into()))?;
let e = jwk
.e
.as_deref()
.ok_or_else(|| AuthError::InvalidJwt("RSA key missing 'e'".into()))?;
DecodingKey::from_rsa_components(n, e)
.map_err(|e| AuthError::InvalidJwt(format!("invalid RSA key: {e}")))
}
"EC" => {
let x = jwk
.x
.as_deref()
.ok_or_else(|| AuthError::InvalidJwt("EC key missing 'x'".into()))?;
let y = jwk
.y
.as_deref()
.ok_or_else(|| AuthError::InvalidJwt("EC key missing 'y'".into()))?;
DecodingKey::from_ec_components(x, y)
.map_err(|e| AuthError::InvalidJwt(format!("invalid EC key: {e}")))
}
other => Err(AuthError::InvalidJwt(format!(
"unsupported key type: {other}"
))),
}
}
/// Returns `true` if this entry was fetched within the last `ttl_secs` seconds.
pub fn is_fresh(&self, ttl_secs: u64) -> bool {
self.fetched_at.elapsed() < Duration::from_secs(ttl_secs)
}
}
/// Returns `true` if the string `alg_str` (from a JWK's `alg` field) matches
/// the [`Algorithm`] decoded from the JWT header.
fn matches_algorithm(alg_str: &str, alg: &Algorithm) -> bool {
match alg {
Algorithm::RS256 => alg_str == "RS256",
Algorithm::RS384 => alg_str == "RS384",
Algorithm::RS512 => alg_str == "RS512",
Algorithm::ES256 => alg_str == "ES256",
Algorithm::ES384 => alg_str == "ES384",
Algorithm::PS256 => alg_str == "PS256",
Algorithm::PS384 => alg_str == "PS384",
Algorithm::PS512 => alg_str == "PS512",
_ => false,
}
}
/// Thread-safe in-process JWKS cache. Wrap in `Arc` and share across tasks.
///
/// Uses double-checked locking with an **unlocked HTTP fetch** to prevent two
/// failure modes simultaneously:
///
/// 1. **Thundering herd**: N concurrent cache misses each triggering N HTTP
/// requests. The final write-lock re-check ensures only one result is stored.
///
/// 2. **Global DoS via lock-held fetch** *(the bug this design avoids)*: holding
/// the write lock across the HTTP call would block every reader (every in-flight
/// auth attempt) for the full duration of the OIDC endpoint round-trip. If the
/// endpoint is slow or unreachable, the relay becomes completely unavailable.
///
/// The trade-off: two concurrent stale-cache threads may both fetch from the OIDC
/// endpoint. This is safe — fetches are idempotent and the second writer simply
/// finds a fresh entry and discards its result.
#[derive(Debug, Default)]
pub struct JwksCache {
inner: RwLock<Option<CachedJwks>>,
}
impl JwksCache {
/// Create a new empty cache wrapped in an `Arc`.
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: RwLock::new(None),
})
}
/// Return cached JWKS if fresh, otherwise fetch and cache a new one.
///
/// # Locking protocol
///
/// 1. Acquire **read** lock → return if fresh (fast path, no contention).
/// 2. Drop read lock.
/// 3. Acquire **read** lock again → re-check freshness (another thread may
/// have already refreshed while we were waiting).
/// 4. Drop read lock.
/// 5. Fetch JWKS with **no lock held** — readers are never blocked.
/// 6. Acquire **write** lock → re-check one final time, then store if still stale.
///
/// Step 5 is the critical fix: the HTTP fetch never holds the write lock,
/// so readers are never blocked by a slow or hung OIDC endpoint.
/// Two concurrent threads may both fetch; that is intentional and safe
/// (idempotent). The write-lock re-check in step 6 ensures only one result
/// is stored.
pub async fn get_or_refresh(
&self,
jwks_uri: &str,
ttl_secs: u64,
client: &reqwest::Client,
) -> Result<CachedJwks, AuthError> {
{
let guard = self.inner.read().await;
if let Some(cached) = guard.as_ref() {
if cached.is_fresh(ttl_secs) {
debug!("JWKS cache hit");
return Ok(cached.clone());
}
}
}
// Pre-fetch re-check: another thread may have refreshed between our
// first read-lock drop and now. Use a second read lock (not write) so
// we don't block other readers while deciding whether to fetch.
{
let guard = self.inner.read().await;
if let Some(cached) = guard.as_ref() {
if cached.is_fresh(ttl_secs) {
debug!("JWKS cache hit (pre-fetch re-check)");
return Ok(cached.clone());
}
}
}
// *** CRITICAL: fetch with NO lock held ***
//
// Holding the write lock across an HTTP call would block ALL readers
// (every in-flight auth attempt) for the entire round-trip duration.
// A slow or hung OIDC endpoint would cause a global relay DoS.
//
// Two concurrent threads may both reach this point and both issue a
// fetch. That is intentional — fetches are idempotent. The write-lock
// re-check below ensures only one result is stored.
debug!("JWKS cache miss — fetching from {jwks_uri}");
let jwks = fetch_jwks(jwks_uri, client).await?;
let fetched = CachedJwks {
jwks,
fetched_at: Instant::now(),
};
// Final re-check: another thread may have stored a fresh entry while
// we were fetching. If so, discard our result and return theirs.
let mut guard = self.inner.write().await;
if let Some(cached) = guard.as_ref() {
if cached.is_fresh(ttl_secs) {
debug!("JWKS cache hit (stored by concurrent fetcher — discarding our result)");
return Ok(cached.clone());
}
}
*guard = Some(fetched.clone());
Ok(fetched)
}
}
async fn fetch_jwks(uri: &str, client: &reqwest::Client) -> Result<Jwks, AuthError> {
let response = client
.get(uri)
.send()
.await
.map_err(|e| AuthError::JwksFetchError(format!("request failed: {e}")))?;
if !response.status().is_success() {
let status = response.status();
warn!("JWKS fetch returned HTTP {status}");
return Err(AuthError::JwksFetchError(format!(
"HTTP {status} from JWKS endpoint"
)));
}
response
.json::<Jwks>()
.await
.map_err(|e| AuthError::JwksFetchError(format!("failed to parse JWKS: {e}")))
}
/// Okta OIDC configuration for JWT validation.
///
/// Loaded from relay config (TOML/env). All fields except `pubkey_claim`,
/// `jwks_refresh_secs`, and `require_token` are required in production.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OktaConfig {
/// Expected `iss` claim in incoming JWTs (e.g. `https://example.okta.com/oauth2/default`).
pub issuer: String,
/// Expected `aud` claim in incoming JWTs (the Okta application client ID or custom audience).
pub audience: String,
/// URL of the OIDC JWKS endpoint (e.g. `https://example.okta.com/oauth2/default/v1/keys`).
pub jwks_uri: String,
/// JWT claim name that holds the user's Nostr public key (hex). Default: `"nostr_pubkey"`.
pub pubkey_claim: String,
/// How often to refresh the JWKS cache, in seconds. Default: 300 (5 minutes).
#[serde(default = "default_jwks_refresh_secs")]
pub jwks_refresh_secs: u64,
/// If `true` (production default), every NIP-42 AUTH event must include an `auth_token` tag
/// containing a valid JWT or API token. If `false` (dev/open-relay mode), connections without
/// a token are accepted and granted baseline `[MessagesRead, MessagesWrite]` scopes.
///
/// ⚠️ **Never set `require_token = false` in production.** It disables all token-based
/// authentication and allows any Nostr keypair to connect and send messages.
#[serde(default = "default_require_token")]
pub require_token: bool,
}
fn default_jwks_refresh_secs() -> u64 {
300
}
fn default_require_token() -> bool {
true
}
impl Default for OktaConfig {
fn default() -> Self {
Self {
issuer: String::new(),
audience: String::new(),
jwks_uri: String::new(),
pubkey_claim: "nostr_pubkey".into(),
jwks_refresh_secs: 300,
require_token: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn okta_config_defaults() {
let cfg = OktaConfig::default();
assert_eq!(cfg.pubkey_claim, "nostr_pubkey");
assert_eq!(cfg.jwks_refresh_secs, 300);
assert!(cfg.require_token);
}
#[test]
fn jwks_freshness() {
let fresh = CachedJwks {
jwks: Jwks { keys: vec![] },
fetched_at: Instant::now(),
};
assert!(fresh.is_fresh(300));
let stale = CachedJwks {
jwks: Jwks { keys: vec![] },
fetched_at: Instant::now() - Duration::from_secs(400),
};
assert!(!stale.is_fresh(300));
}
}

View File

@@ -1,6 +1,8 @@
//! API token scopes.
//! Authorization scopes.
//!
//! Stored as `TEXT[]` in the database so new scopes don't require migrations.
//! Scopes control what operations an authenticated connection may perform.
//! In pure Nostr mode, all NIP-42 authenticated connections receive the full
//! scope set; per-channel access is enforced by NIP-29 membership checks.
use std::fmt;
use std::str::FromStr;
@@ -40,6 +42,18 @@ pub enum Scope {
FilesRead,
/// Upload files and attachments.
FilesWrite,
/// Clone git repositories.
///
/// Reserved for future use. Not currently enforced by git HTTP routes —
/// those use NIP-98 auth directly. Will be enforced when collaborator
/// access (read-only, maintainer) is added in v2.
ReposRead,
/// Push to git repositories and create repos (kind:30617).
///
/// Enforced for kind:30617/30618 events via WebSocket ingest, but NOT
/// enforced by git HTTP push routes (which use NIP-98 + owner check).
/// Full enforcement deferred to v2 collaborator model.
ReposWrite,
/// Submit events on behalf of other pubkeys (proxy service accounts only).
ProxySubmit,
/// A scope string not recognised by this version of the relay.
@@ -69,6 +83,8 @@ impl Scope {
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
Self::ReposRead,
Self::ReposWrite,
]
}
@@ -91,6 +107,8 @@ impl Scope {
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
Self::ReposRead,
Self::ReposWrite,
]
}
@@ -111,6 +129,8 @@ impl Scope {
Self::SubscriptionsWrite => "subscriptions:write",
Self::FilesRead => "files:read",
Self::FilesWrite => "files:write",
Self::ReposRead => "repos:read",
Self::ReposWrite => "repos:write",
Self::ProxySubmit => "proxy:submit",
Self::Unknown(s) => s.as_str(),
}
@@ -142,51 +162,14 @@ impl FromStr for Scope {
"subscriptions:write" => Self::SubscriptionsWrite,
"files:read" => Self::FilesRead,
"files:write" => Self::FilesWrite,
"repos:read" => Self::ReposRead,
"repos:write" => Self::ReposWrite,
"proxy:submit" => Self::ProxySubmit,
other => Self::Unknown(other.to_string()),
})
}
}
/// Scopes that can be self-minted via `POST /api/tokens`.
///
/// Admin-only scopes (`AdminChannels`, `AdminUsers`, `JobsRead`, `JobsWrite`,
/// `SubscriptionsRead`, `SubscriptionsWrite`) are intentionally excluded — they require
/// `sprout-admin mint-token`.
///
/// `UsersWrite` is included because it guards self-profile endpoints
/// (`PUT /api/users/me/profile`, `PUT /api/users/me/channel-add-policy`)
/// and contact list (kind:3) publishing.
pub const SELF_MINTABLE_SCOPES: &[Scope] = &[
Scope::MessagesRead,
Scope::MessagesWrite,
Scope::ChannelsRead,
Scope::ChannelsWrite,
Scope::UsersRead,
Scope::UsersWrite,
Scope::FilesRead,
Scope::FilesWrite,
];
/// Returns `true` if the given scope may be requested via `POST /api/tokens`.
///
/// Admin-only scopes and `Scope::Unknown` always return `false`.
/// Unknown scope strings are rejected at mint time rather than silently accepted —
/// a client sending an unrecognised scope string likely has a bug.
pub fn is_self_mintable(scope: &Scope) -> bool {
matches!(
scope,
Scope::MessagesRead
| Scope::MessagesWrite
| Scope::ChannelsRead
| Scope::ChannelsWrite
| Scope::UsersRead
| Scope::UsersWrite
| Scope::FilesRead
| Scope::FilesWrite
)
}
/// Parse a slice of scope strings into `Vec<Scope>`.
pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
raw.iter()
@@ -226,12 +209,12 @@ mod tests {
#[test]
fn all_non_admin_excludes_admin_scopes() {
let scopes = Scope::all_non_admin();
assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants");
assert_eq!(scopes.len(), 14, "expected 14 non-admin scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
assert_eq!(
unique.len(),
12,
14,
"all_non_admin() must not contain duplicates"
);
// Verify no Unknown variants
@@ -255,10 +238,10 @@ mod tests {
#[test]
fn all_known_returns_all_14_variants() {
let all = Scope::all_known();
assert_eq!(all.len(), 14, "expected 14 known scope variants");
assert_eq!(all.len(), 16, "expected 16 known scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
assert_eq!(unique.len(), 14, "all_known() must not contain duplicates");
assert_eq!(unique.len(), 16, "all_known() must not contain duplicates");
// Verify no Unknown variants
for scope in &all {
assert!(

View File

@@ -1,65 +0,0 @@
//! API token creation, hashing, and validation.
//!
//! Only the SHA-256 hash is stored — the raw token is shown once at creation.
//! Format: `sprout_<32-random-bytes-as-hex>` (71 characters).
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
const TOKEN_PREFIX: &str = "sprout_";
/// Generate a new random API token (CSPRNG, 32 bytes, hex-encoded with prefix).
pub fn generate_token() -> String {
let bytes: [u8; 32] = rand::random();
format!("{}{}", TOKEN_PREFIX, hex::encode(bytes))
}
/// SHA-256 hash of a raw token (the value stored in `api_tokens.token_hash`).
pub fn hash_token(token: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.finalize().to_vec()
}
/// Constant-time verification that `raw_token` matches `expected_hash`.
pub fn verify_token_hash(raw_token: &str, expected_hash: &[u8]) -> bool {
let computed = hash_token(raw_token);
computed.ct_eq(expected_hash).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_format_and_length() {
let token = generate_token();
assert!(token.starts_with("sprout_"));
assert_eq!(token.len(), 7 + 64);
}
#[test]
fn tokens_are_unique() {
assert_ne!(generate_token(), generate_token());
}
#[test]
fn hash_verify_round_trip() {
let token = generate_token();
let hash = hash_token(&token);
assert_eq!(hash.len(), 32);
assert!(verify_token_hash(&token, &hash));
}
#[test]
fn wrong_token_rejected() {
let hash = hash_token(&generate_token());
assert!(!verify_token_hash(&generate_token(), &hash));
}
#[test]
fn hash_is_deterministic() {
let token = "sprout_test_abc123";
assert_eq!(hash_token(token), hash_token(token));
}
}

View File

@@ -7,6 +7,10 @@ license.workspace = true
repository.workspace = true
description = "Agent-first CLI for Sprout relay"
[lib]
name = "sprout_cli"
path = "src/lib.rs"
[[bin]]
name = "sprout"
path = "src/main.rs"
@@ -40,8 +44,17 @@ sprout-sdk = { workspace = true }
# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
# SHA-256 — NIP-98 payload hash tag
# SHA-256 — NIP-98 payload hash tag and Blossom file hash
sha2 = "0.11"
# Hex encoding — SHA-256 hash output for Blossom uploads
hex = { workspace = true }
# MIME type detection via magic bytes — file upload validation
infer = "0.19"
# URL parsing — extract server domain for Blossom auth tag
url = { workspace = true }
# Persona pack parsing, validation, and resolution
sprout-persona = { path = "../sprout-persona" }

View File

@@ -10,25 +10,21 @@ cargo install --path crates/sprout-cli
## Authentication
Three modes, checked in order:
Two modes, checked in order:
| Priority | Env Var | Mode | Use Case |
|----------|---------|------|----------|
| 1 | `SPROUT_API_TOKEN` | Bearer token | Production — fastest, no extra HTTP call |
| 2 | `SPROUT_PRIVATE_KEY` | Auto-mint short-lived token via NIP-98 | Agents with a keypair |
| 3 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development |
| 1 | `SPROUT_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
| 2 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development |
```bash
# Option 1: Pre-minted token
export SPROUT_API_TOKEN="sprout_tok_..."
sprout list-channels
# Option 2: Private key (auto-mints a 1-day token at startup)
# Option 1: Private key (NIP-98 signed requests)
export SPROUT_PRIVATE_KEY="nsec1..."
sprout list-channels
# Option 3: Mint a long-lived token explicitly
export SPROUT_API_TOKEN=$(SPROUT_PRIVATE_KEY=nsec1... sprout auth)
# Option 2: Dev mode (no auth)
export SPROUT_PUBKEY="<hex>"
sprout list-channels
```
## Usage
@@ -83,17 +79,11 @@ sprout vote-on-post --event <event-id> --direction up
sprout get-canvas --channel <uuid>
sprout set-canvas --channel <uuid> --content "# Welcome"
# Tokens
sprout auth # mint token, print to stdout
sprout list-tokens
sprout delete-token --id <uuid>
sprout delete-all-tokens
# Pipe to jq
sprout list-channels | jq '.[].name'
```
## All 48 Commands
## All 44 Commands
| Command | Description |
|---------|-------------|
@@ -141,10 +131,6 @@ sprout list-channels | jq '.[].name'
| `approve-step` | Approve/deny a workflow step |
| `get-feed` | Get your activity feed |
| `vote-on-post` | Vote on a forum post |
| `auth` | Mint a long-lived API token |
| `list-tokens` | List your API tokens |
| `delete-token` | Delete a token |
| `delete-all-tokens` | Delete all tokens |
## Architecture

View File

@@ -1,16 +1,118 @@
use std::time::Duration;
use nostr::Keys;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
use crate::error::CliError;
// ---------------------------------------------------------------------------
// Auth
// Blob / Media types
// ---------------------------------------------------------------------------
pub enum Auth {
Bearer(String), // SPROUT_API_TOKEN or auto-minted via SPROUT_PRIVATE_KEY
DevMode(String), // SPROUT_PUBKEY — X-Pubkey header, relay must have require_auth_token=false
/// Descriptor returned by the relay after a successful upload.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BlobDescriptor {
/// Public URL of the uploaded blob.
pub url: String,
/// Hex-encoded SHA-256 of the file content.
pub sha256: String,
/// File size in bytes.
pub size: u64,
/// MIME type (e.g. `image/jpeg`).
#[serde(rename = "type")]
pub mime_type: String,
/// Unix timestamp when the file was uploaded.
pub uploaded: i64,
/// Image dimensions as `<width>x<height>` (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub dim: Option<String>,
/// Blurhash placeholder string (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
/// Thumbnail URL (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
/// Duration in seconds for video/audio (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
let mut tag = vec![
"imeta".to_string(),
format!("url {}", d.url),
format!("m {}", d.mime_type),
format!("x {}", d.sha256),
format!("size {}", d.size),
];
if let Some(ref dim) = d.dim {
tag.push(format!("dim {dim}"));
}
if let Some(ref bh) = d.blurhash {
tag.push(format!("blurhash {bh}"));
}
if let Some(ref th) = d.thumb {
tag.push(format!("thumb {th}"));
}
if let Some(dur) = d.duration {
tag.push(format!("duration {dur}"));
}
tag
}
/// MIME types accepted for upload.
const ALLOWED_MIMES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
];
/// Maximum file size for image uploads (50 MB).
const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
// ---------------------------------------------------------------------------
// NIP-98 HTTP Auth
// ---------------------------------------------------------------------------
/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
///
/// The event includes:
/// - `u` tag: the full request URL
/// - `method` tag: HTTP method (GET, POST, PUT, DELETE)
/// - `payload` tag: SHA-256 hex of the request body (if present)
fn sign_nip98(
keys: &Keys,
method: &str,
url: &str,
body: Option<&[u8]>,
) -> Result<String, CliError> {
let mut tags = vec![
Tag::parse(&["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(&["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
// Nonce prevents replay rejection for rapid-fire requests with identical bodies.
Tag::parse(&["nonce", &uuid::Uuid::new_v4().to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
];
if let Some(b) = body {
let hash = hex::encode(Sha256::digest(b));
tags.push(
Tag::parse(&["payload", &hash])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
);
}
let event = EventBuilder::new(Kind::Custom(27235), "", tags)
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?;
let json = event.as_json();
Ok(format!("Nostr {}", B64.encode(json.as_bytes())))
}
// ---------------------------------------------------------------------------
@@ -20,12 +122,20 @@ pub enum Auth {
pub struct SproutClient {
http: reqwest::Client,
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.sprout.place"
auth: Auth,
keys: Option<Keys>, // retained for event signing (write operations)
keys: Keys,
/// Optional NIP-OA auth tag injected into every signed event.
auth_tag: Option<Tag>,
/// Raw JSON of the auth tag for the `x-auth-tag` HTTP header.
auth_tag_json: Option<String>,
}
impl SproutClient {
pub fn new(relay_url: String, auth: Auth) -> Result<Self, CliError> {
pub fn new(
relay_url: String,
keys: Keys,
auth_tag: Option<Tag>,
auth_tag_json: Option<String>,
) -> Result<Self, CliError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
@@ -34,47 +144,246 @@ impl SproutClient {
Ok(Self {
http,
relay_url,
auth,
keys: None,
keys,
auth_tag,
auth_tag_json,
})
}
/// Attach a keypair for signing write operations.
pub fn with_keys(mut self, keys: Keys) -> Self {
self.keys = Some(keys);
self
/// Get the keypair.
pub fn keys(&self) -> &Keys {
&self.keys
}
/// Get the retained keypair, if available.
pub fn keys(&self) -> Option<&Keys> {
self.keys.as_ref()
/// Get the relay base URL.
#[allow(dead_code)]
pub fn relay_url(&self) -> &str {
&self.relay_url
}
// -----------------------------------------------------------------------
// Core request method
// -----------------------------------------------------------------------
async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<String, CliError> {
let url = format!("{}{}", self.relay_url, path);
let builder = self.http.request(method, &url);
let builder = self.apply_auth(builder);
let builder = match body {
Some(b) => builder.json(b),
None => builder,
/// Sign an event builder, injecting the NIP-OA auth tag if configured.
///
/// All event creation should go through this method to ensure consistent
/// auth tag injection. Callers MUST NOT add `auth` tags to the builder
/// before calling this method.
pub fn sign_event(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
let builder = if let Some(ref tag) = self.auth_tag {
builder.add_tags([tag.clone()])
} else {
builder
};
let event = builder
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let resp = builder.send().await?; // reqwest::Error → CliError::Network via From
// Enforce: auth tags may only come from self.auth_tag injection.
let auth_count = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.count();
let expected = if self.auth_tag.is_some() { 1 } else { 0 };
if auth_count != expected {
return Err(CliError::Other(format!(
"event has {auth_count} auth tags — expected {expected}; \
callers must not add auth tags manually"
)));
}
Ok(event)
}
/// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation).
fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match self.auth_tag_json {
Some(ref json) => req.header("x-auth-tag", json),
None => req,
}
}
// -----------------------------------------------------------------------
// HTTP Bridge: POST /query
// -----------------------------------------------------------------------
/// Execute a one-shot query via the HTTP bridge.
/// `filter` is a Nostr filter object (will be wrapped in an array).
/// Returns the raw JSON response (array of events).
pub async fn query(&self, filter: &serde_json::Value) -> Result<String, CliError> {
let url = format!("{}/query", self.relay_url);
let body_bytes = serde_json::to_vec(&[filter])
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
/// Execute a one-shot count via the HTTP bridge.
/// Returns the count as a JSON string.
#[allow(dead_code)]
pub async fn count(&self, filter: &serde_json::Value) -> Result<String, CliError> {
let url = format!("{}/count", self.relay_url);
let body_bytes = serde_json::to_vec(&[filter])
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
// -----------------------------------------------------------------------
// HTTP Bridge: POST /events
// -----------------------------------------------------------------------
/// Submit a signed Nostr event via POST /events.
pub async fn submit_event(&self, event: nostr::Event) -> Result<String, CliError> {
let url = format!("{}/events", self.relay_url);
let body_bytes = serde_json::to_vec(&event)
.map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
// -----------------------------------------------------------------------
// File upload (Blossom protocol)
// -----------------------------------------------------------------------
/// Upload a file to the relay's Blossom endpoint.
/// Returns a BlobDescriptor on success.
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
// 1. Read file — validate it exists and is a regular file
let metadata = std::fs::metadata(file_path)
.map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?;
if !metadata.is_file() {
return Err(CliError::Usage(format!("{file_path} is not a file")));
}
let bytes = std::fs::read(file_path)
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
// 2. Detect MIME from magic bytes
let mime = infer::get(&bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !ALLOWED_MIMES.contains(&mime.as_str()) {
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
}
// 3. Size check
let max = if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else {
MAX_IMAGE_BYTES
};
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {})",
bytes.len(),
max
)));
}
// 4. SHA-256
let sha256 = hex::encode(Sha256::digest(&bytes));
// 5. Sign Blossom auth event (kind:24242)
use nostr::Timestamp;
let now = Timestamp::now().as_u64();
let expiry = if mime.starts_with("video/") {
3600
} else {
600
};
let exp_str = (now + expiry).to_string();
let mut blossom_tags = vec![
Tag::parse(&["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(&["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(&["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
];
// Extract server domain from relay URL for BUD-11 server tag
if let Ok(parsed) = url::Url::parse(&self.relay_url) {
if let Some(host) = parsed.host_str() {
let domain = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
blossom_tags.push(
Tag::parse(&["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?,
);
}
}
let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", blossom_tags)
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
// 6. Base64url encode the auth event for the header
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let auth_header = format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
// 7. PUT request to /media/upload — with generous per-request timeout.
let upload_timeout = if mime.starts_with("video/") {
Duration::from_secs(600)
} else {
Duration::from_secs(120)
};
let url = format!("{}/media/upload", self.relay_url);
let req = self
.http
.put(&url)
.timeout(upload_timeout)
.header("Authorization", &auth_header)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);
let resp = self.with_auth_tag(req).body(bytes).send().await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Relay { status, body });
}
resp.json::<BlobDescriptor>()
.await
.map_err(|e| CliError::Other(format!("invalid upload response: {e}")))
}
// -----------------------------------------------------------------------
// Response handling
// -----------------------------------------------------------------------
async fn handle_response(&self, resp: reqwest::Response) -> Result<String, CliError> {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
// Try to extract relay's error message from JSON body
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
@@ -89,90 +398,8 @@ impl SproutClient {
body: message,
});
}
Ok(resp.text().await?)
}
pub(crate) fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.auth {
Auth::Bearer(token) => builder.header("Authorization", format!("Bearer {}", token)),
Auth::DevMode(pk) => builder.header("X-Pubkey", pk),
}
}
// -----------------------------------------------------------------------
// Signed event submission
// -----------------------------------------------------------------------
/// Submit a signed Nostr event via POST /api/events.
pub async fn submit_event(&self, event: nostr::Event) -> Result<String, CliError> {
let body = serde_json::to_vec(&event)
.map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?;
let url = format!("{}/api/events", self.relay_url);
let builder = self.http.post(&url);
let builder = self.apply_auth(builder);
let resp = builder
.header("content-type", "application/json")
.body(body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("error")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
.unwrap_or(body);
return Err(CliError::Relay {
status,
body: message,
});
}
Ok(resp.text().await?)
}
// -----------------------------------------------------------------------
// Convenience wrappers — print response to stdout
// -----------------------------------------------------------------------
pub async fn run_get(&self, path: &str) -> Result<(), CliError> {
let resp = self.request(reqwest::Method::GET, path, None).await?;
println!("{resp}");
Ok(())
}
pub async fn run_post(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> {
let resp = self
.request(reqwest::Method::POST, path, Some(body))
.await?;
println!("{resp}");
Ok(())
}
pub async fn run_put(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> {
let resp = self.request(reqwest::Method::PUT, path, Some(body)).await?;
println!("{resp}");
Ok(())
}
pub async fn run_delete(&self, path: &str) -> Result<(), CliError> {
let resp = self.request(reqwest::Method::DELETE, path, None).await?;
println!("{resp}");
Ok(())
}
// For commands that need the raw response string (e.g. get_users multi-dispatch)
pub async fn get_raw(&self, path: &str) -> Result<String, CliError> {
self.request(reqwest::Method::GET, path, None).await
}
pub async fn post_raw(&self, path: &str, body: &serde_json::Value) -> Result<String, CliError> {
self.request(reqwest::Method::POST, path, Some(body)).await
}
}
// ---------------------------------------------------------------------------
@@ -187,105 +414,3 @@ pub fn normalize_relay_url(url: &str) -> String {
.trim_end_matches('/')
.to_string()
}
// ---------------------------------------------------------------------------
// Auto-mint token (NIP-98)
// ---------------------------------------------------------------------------
/// Mint a short-lived Bearer token using NIP-98 HTTP auth.
/// Called at startup when SPROUT_PRIVATE_KEY is set.
/// Returns `(token, keys)` so the caller can retain the keypair for signed writes.
pub async fn auto_mint_token(
relay_url: &str,
private_key_str: &str,
) -> Result<(String, Keys), CliError> {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Kind, Tag};
use sha2::{Digest, Sha256};
let keys = Keys::parse(private_key_str)
.map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?;
let token_url = format!("{}/api/tokens", relay_url);
// Body bytes for payload hash
let body = serde_json::json!({
"name": "sprout-cli-auto",
"scopes": [
"messages:read", "messages:write",
"channels:read", "channels:write",
"users:read", "users:write",
"files:read", "files:write"
],
"expires_in_days": 1
});
let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?;
// SHA-256 hex of body bytes (NIP-98 payload tag)
let hash = Sha256::digest(&body_bytes);
let sha256_hex = hash
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
// Build NIP-98 event
let event = EventBuilder::new(
Kind::HttpAuth,
"",
[
Tag::parse(&["u", &token_url]).map_err(|e| CliError::Key(format!("tag error: {e}")))?,
Tag::parse(&["method", "POST"])
.map_err(|e| CliError::Key(format!("tag error: {e}")))?,
Tag::parse(&["payload", &sha256_hex])
.map_err(|e| CliError::Key(format!("tag error: {e}")))?,
],
)
.sign_with_keys(&keys)
.map_err(|e| CliError::Key(format!("signing failed: {e}")))?;
let auth_header = format!("Nostr {}", B64.encode(event.as_json()));
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
// Tell the relay which scheme we signed so the canonical URL matches.
// The relay defaults x-forwarded-proto to "https"; without this header,
// http:// URLs fail NIP-98 verification on localhost.
let proto = if token_url.starts_with("https://") {
"https"
} else {
"http"
};
let resp = http
.post(&token_url)
.header("Authorization", auth_header)
.header("x-forwarded-proto", proto)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Auth(format!(
"auto-mint failed ({status}): {body}"
)));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| CliError::Other(format!("invalid auto-mint response: {e}")))?;
let token = json
.get("token")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| CliError::Other("auto-mint response missing 'token' field".into()))?;
Ok((token, keys))
}

View File

@@ -1,124 +0,0 @@
use crate::error::CliError;
use crate::validate::validate_uuid;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
/// Mint a long-lived (7-day) API token via NIP-98 and print it to stdout.
///
/// `private_key` is the --private-key CLI flag value; falls back to SPROUT_PRIVATE_KEY env var.
/// Caller stores the printed token (e.g. `export SPROUT_API_TOKEN=$(sprout auth)`).
pub async fn cmd_auth(relay_url: &str, private_key: Option<&str>) -> Result<(), CliError> {
let env_key;
let private_key_str = match private_key {
Some(k) => k,
None => {
env_key = std::env::var("SPROUT_PRIVATE_KEY").map_err(|_| {
CliError::Auth(
"SPROUT_PRIVATE_KEY not set (use --private-key or set env var)".into(),
)
})?;
&env_key
}
};
let keys = Keys::parse(private_key_str)
.map_err(|e| CliError::Key(format!("invalid private key: {e}")))?;
let token_url = format!("{}/api/tokens", relay_url);
// Request body — long-lived token, 7 days
let body = serde_json::json!({
"name": "sprout-cli",
"scopes": [
"messages:read", "messages:write",
"channels:read", "channels:write",
"users:read", "users:write",
"files:read", "files:write"
],
"expires_in_days": 7
});
let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?;
// SHA-256 hex of body bytes (NIP-98 payload tag)
let hash = Sha256::digest(&body_bytes);
let sha256_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
// Build NIP-98 event
let event = EventBuilder::new(
Kind::HttpAuth,
"",
[
Tag::parse(&["u", &token_url])
.map_err(|e| CliError::Key(format!("tag build failed: {e}")))?,
Tag::parse(&["method", "POST"])
.map_err(|e| CliError::Key(format!("tag build failed: {e}")))?,
Tag::parse(&["payload", &sha256_hex])
.map_err(|e| CliError::Key(format!("tag build failed: {e}")))?,
],
)
.sign_with_keys(&keys)
.map_err(|e| CliError::Key(format!("signing failed: {e}")))?;
let auth_header = format!("Nostr {}", B64.encode(event.as_json()));
// POST /api/tokens
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
let resp = http
.post(&token_url)
.header("Authorization", auth_header)
.header(
"x-forwarded-proto",
if token_url.starts_with("https://") {
"https"
} else {
"http"
},
)
.json(&body)
.send()
.await
.map_err(CliError::Network)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body_text = resp.text().await.unwrap_or_default();
return Err(CliError::Auth(format!(
"token mint failed ({status}): {body_text}"
)));
}
let resp_body: serde_json::Value = resp
.json()
.await
.map_err(|e| CliError::Other(format!("invalid response: {e}")))?;
let token = resp_body
.get("token")
.and_then(|t| t.as_str())
.ok_or_else(|| CliError::Other("response missing 'token' field".into()))?;
println!("{token}");
Ok(())
}
pub async fn cmd_list_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> {
client.run_get("/api/tokens").await
}
pub async fn cmd_delete_token(
client: &crate::client::SproutClient,
id: &str,
) -> Result<(), CliError> {
validate_uuid(id)?;
client.run_delete(&format!("/api/tokens/{}", id)).await
}
pub async fn cmd_delete_all_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> {
client.run_delete("/api/tokens").await
}

View File

@@ -2,62 +2,58 @@ use uuid::Uuid;
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, read_or_stdin, validate_hex64, validate_uuid};
use crate::validate::{read_or_stdin, validate_hex64, validate_uuid};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Require keys on the client — fail fast with a clear error if absent.
macro_rules! require_keys {
($client:expr) => {
$client.keys().ok_or_else(|| {
CliError::Key(
"private key required for write operations (set SPROUT_PRIVATE_KEY)".into(),
)
})?
};
}
fn parse_uuid(s: &str) -> Result<Uuid, CliError> {
Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}")))
}
fn sign_and_submit_builder(
builder: nostr::EventBuilder,
keys: &nostr::Keys,
client: &SproutClient,
) -> Result<nostr::Event, CliError> {
builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))
client.sign_event(builder)
}
// ---------------------------------------------------------------------------
// Read commands (unchanged)
// Read commands — POST /query
// ---------------------------------------------------------------------------
pub async fn cmd_list_channels(
client: &SproutClient,
visibility: Option<&str>,
member: Option<bool>,
_visibility: Option<&str>,
_member: Option<bool>,
) -> Result<(), CliError> {
let mut path = "/api/channels".to_string();
let mut sep = '?';
if let Some(v) = visibility {
path.push_str(&format!("{}visibility={}", sep, percent_encode(v)));
sep = '&';
// Query kind:39002 channel metadata events.
// If member=true, filter by #p tag containing our pubkey.
let my_pk = client.keys().public_key().to_hex();
let mut filter = serde_json::json!({
"kinds": [39002]
});
// When member filter is requested, query channels where we're a participant
if _member == Some(true) {
filter["#p"] = serde_json::json!([my_pk]);
}
if let Some(m) = member {
path.push_str(&format!("{}member={}", sep, m));
}
client.run_get(&path).await
// Visibility filtering is done client-side from the returned events
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}", channel_id))
.await
// Query kind:39002 with #h tag matching the channel UUID
let filter = serde_json::json!({
"kinds": [39002],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_channel_members(
@@ -65,20 +61,30 @@ pub async fn cmd_list_channel_members(
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}/members", channel_id))
.await
// Query kind:39002 channel metadata — members are in the p-tags
let filter = serde_json::json!({
"kinds": [39002],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_canvas(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}/canvas", channel_id))
.await
// Canvas is kind:40100 with #h tag
let filter = serde_json::json!({
"kinds": [40100],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Write commands — signed events
// Write commands — signed events via POST /events
// ---------------------------------------------------------------------------
pub async fn cmd_create_channel(
@@ -105,25 +111,23 @@ pub async fn cmd_create_channel(
}
}
let keys = require_keys!(client);
// Generate a new UUID client-side for the channel
let channel_uuid = Uuid::new_v4();
let vis = match visibility {
"open" => sprout_sdk::Visibility::Open,
"private" => sprout_sdk::Visibility::Private,
_ => unreachable!(), // validated above
_ => unreachable!(),
};
let ct = match channel_type {
"stream" => sprout_sdk::ChannelKind::Stream,
"forum" => sprout_sdk::ChannelKind::Forum,
_ => unreachable!(), // validated above
_ => unreachable!(),
};
let builder =
sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -141,13 +145,12 @@ pub async fn cmd_update_channel(
));
}
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_update_channel(channel_uuid, name, description)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -159,13 +162,12 @@ pub async fn cmd_set_channel_topic(
topic: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_set_topic(channel_uuid, topic)
.map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -177,13 +179,12 @@ pub async fn cmd_set_channel_purpose(
purpose: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose)
.map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -191,13 +192,12 @@ pub async fn cmd_set_channel_purpose(
pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_join(channel_uuid)
.map_err(|e| CliError::Other(format!("build_join failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -205,13 +205,12 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result
pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_leave(channel_uuid)
.map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -219,13 +218,12 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul
pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_archive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -236,13 +234,12 @@ pub async fn cmd_unarchive_channel(
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_unarchive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -250,13 +247,12 @@ pub async fn cmd_unarchive_channel(
pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_delete_channel(channel_uuid)
.map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -270,7 +266,6 @@ pub async fn cmd_add_channel_member(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let typed_role = match role {
@@ -289,7 +284,7 @@ pub async fn cmd_add_channel_member(
let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role)
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -302,13 +297,12 @@ pub async fn cmd_remove_channel_member(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey)
.map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -321,13 +315,12 @@ pub async fn cmd_set_canvas(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let content = read_or_stdin(content)?;
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_set_canvas(channel_uuid, &content)
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
let event = sign_and_submit_builder(builder, keys)?;
let event = sign_and_submit_builder(builder, client)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())

View File

@@ -1,24 +1,24 @@
use nostr::{EventBuilder, Kind, Tag};
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, validate_hex64, validate_uuid};
use crate::validate::{validate_hex64, validate_uuid};
pub async fn cmd_list_dms(
client: &SproutClient,
cursor: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
let mut path = "/api/dms".to_string();
let mut sep = '?';
if let Some(c) = cursor {
path.push_str(&format!("{}cursor={}", sep, percent_encode(c)));
sep = '&';
}
if let Some(l) = limit {
path.push_str(&format!("{}limit={}", sep, l));
}
client.run_get(&path).await
/// List DM conversations by querying kind:41010 (DM open) events authored by us.
pub async fn cmd_list_dms(client: &SproutClient, limit: Option<u32>) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(50).min(200);
let filter = serde_json::json!({
"kinds": [41010],
"authors": [my_pk],
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Open a DM with one or more users — sign and submit a kind:41010 event.
pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> {
if pubkeys.is_empty() || pubkeys.len() > 8 {
return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into()));
@@ -26,11 +26,21 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<()
for pk in pubkeys {
validate_hex64(pk)?;
}
client
.run_post("/api/dms", &serde_json::json!({ "pubkeys": pubkeys }))
.await
let mut tags: Vec<Tag> = Vec::new();
for pk in pubkeys {
tags.push(Tag::parse(&["p", pk]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
}
let builder = EventBuilder::new(Kind::Custom(41010), "", tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Add a member to a DM group — sign and submit a kind:41011 event.
pub async fn cmd_add_dm_member(
client: &SproutClient,
channel_id: &str,
@@ -38,10 +48,16 @@ pub async fn cmd_add_dm_member(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
client
.run_post(
&format!("/api/dms/{}/members", channel_id),
&serde_json::json!({ "pubkeys": [pubkey] }),
)
.await
let tags = vec![
Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(&["p", pubkey]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
];
let builder = EventBuilder::new(Kind::Custom(41011), "", tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

View File

@@ -1,20 +1,26 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::percent_encode;
/// Get activity feed — query events mentioning our pubkey (via p-tag).
pub async fn cmd_get_feed(
client: &SproutClient,
since: Option<i64>,
limit: Option<u32>,
types: Option<&str>,
_types: Option<&str>,
) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(20).min(50);
let mut path = format!("/api/feed?limit={}", limit);
let mut filter = serde_json::json!({
"#p": [my_pk],
"limit": limit
});
if let Some(s) = since {
path.push_str(&format!("&since={s}"));
filter["since"] = serde_json::json!(s);
}
if let Some(t) = types {
path.push_str(&format!("&types={}", percent_encode(t)));
}
client.run_get(&path).await
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}

View File

@@ -5,26 +5,14 @@ use uuid::Uuid;
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{
extract_at_names, infer_language, merge_mentions, normalize_mention_pubkeys, percent_encode,
read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid,
MAX_DIFF_BYTES,
extract_at_names, infer_language, merge_mentions, normalize_mention_pubkeys, read_or_stdin,
truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Require keys on the client — fail fast with a clear error if absent.
macro_rules! require_keys {
($client:expr) => {
$client.keys().ok_or_else(|| {
CliError::Key(
"private key required for write operations (set SPROUT_PRIVATE_KEY)".into(),
)
})?
};
}
/// Parse a 64-char hex string into a nostr::EventId.
fn parse_event_id(hex: &str) -> Result<EventId, CliError> {
EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
@@ -35,16 +23,25 @@ fn parse_uuid(s: &str) -> Result<Uuid, CliError> {
Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}")))
}
/// Resolve the channel UUID for an event by fetching GET /api/events/{id}.
/// Extracts the `h` tag value from the returned tags array.
/// Resolve the channel UUID for an event by querying for it via POST /query.
/// Extracts the `h` tag value from the returned event's tags.
async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result<Uuid, CliError> {
let raw = client.get_raw(&format!("/api/events/{}", event_id)).await?;
let v: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse event response: {e}")))?;
let tags = v
let filter = serde_json::json!({
"ids": [event_id]
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
let arr = events
.as_array()
.ok_or_else(|| CliError::Other("query response is not an array".into()))?;
let event = arr
.first()
.ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?;
let tags = event
.get("tags")
.and_then(|t| t.as_array())
.ok_or_else(|| CliError::Other("event response missing 'tags' field".into()))?;
.ok_or_else(|| CliError::Other("event missing 'tags' field".into()))?;
for tag in tags {
if let Some(arr) = tag.as_array() {
if arr.first().and_then(|v| v.as_str()) == Some("h") {
@@ -61,7 +58,7 @@ async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result<Uui
)))
}
/// Resolve @names in content against channel members.
/// Resolve @names in content against channel members (queried from channel metadata).
/// Returns matching pubkeys. On any error, returns empty vec — never blocks a send.
async fn resolve_content_mentions(
client: &SproutClient,
@@ -72,30 +69,31 @@ async fn resolve_content_mentions(
if names.is_empty() {
return vec![];
}
let body = client
.get_raw(&format!("/api/channels/{channel_id}/members"))
.await
.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
let Some(members) = parsed["members"].as_array() else {
// Query channel metadata to get member list from p-tags
let filter = serde_json::json!({
"kinds": [39002],
"#h": [channel_id]
});
let raw = client.query(&filter).await.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_default();
// Channel metadata is returned as an array of events
let Some(events) = parsed.as_array() else {
return vec![];
};
let mut pubkeys = Vec::new();
for m in members {
let Some(dn) = m["display_name"].as_str() else {
continue;
};
if names.iter().any(|n| n.eq_ignore_ascii_case(dn)) {
if let Some(pk) = m["pubkey"].as_str() {
pubkeys.push(pk.to_ascii_lowercase());
}
}
}
pubkeys
let Some(event) = events.first() else {
return vec![];
};
let Some(tags) = event.get("tags").and_then(|t| t.as_array()) else {
return vec![];
};
// p-tags contain member pubkeys; we can't resolve display names without profiles
// For now, return empty — @mention resolution requires profile lookup
let _ = (tags, names);
vec![]
}
// ---------------------------------------------------------------------------
// Read commands (unchanged)
// Read commands — POST /query
// ---------------------------------------------------------------------------
pub async fn cmd_get_messages(
@@ -108,41 +106,54 @@ pub async fn cmd_get_messages(
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let limit = limit.unwrap_or(50).min(200);
let mut path = format!("/api/channels/{}/messages?limit={}", channel_id, limit);
let mut filter = serde_json::json!({
"kinds": [9, 40002],
"#h": [channel_id],
"limit": limit
});
// If specific kinds requested, override
if let Some(k) = kinds {
let kind_list: Vec<u64> = k.split(',').filter_map(|s| s.trim().parse().ok()).collect();
if !kind_list.is_empty() {
filter["kinds"] = serde_json::json!(kind_list);
}
}
if let Some(b) = before {
path.push_str(&format!("&before={b}"));
filter["until"] = serde_json::json!(b);
}
if let Some(s) = since {
path.push_str(&format!("&since={s}"));
filter["since"] = serde_json::json!(s);
}
if let Some(k) = kinds {
path.push_str(&format!("&kinds={}", percent_encode(k)));
}
client.run_get(&path).await
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_thread(
client: &SproutClient,
channel_id: &str,
event_id: &str,
depth_limit: Option<u32>,
_depth_limit: Option<u32>,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(event_id)?;
let limit = limit.unwrap_or(100).min(500);
let mut path = format!(
"/api/channels/{}/threads/{}?limit={}",
channel_id, event_id, limit
);
if let Some(d) = depth_limit {
path.push_str(&format!("&depth_limit={d}"));
}
if let Some(c) = cursor {
path.push_str(&format!("&cursor={}", percent_encode(c)));
}
client.run_get(&path).await
// Get the root event and all replies referencing it via e-tag
let filter = serde_json::json!({
"kinds": [9, 40002],
"#h": [channel_id],
"#e": [event_id],
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_search(
@@ -151,37 +162,67 @@ pub async fn cmd_search(
limit: Option<u32>,
) -> Result<(), CliError> {
let limit = limit.unwrap_or(20).min(100);
let path = format!("/api/search?q={}&limit={}", percent_encode(query), limit);
client.run_get(&path).await
let filter = serde_json::json!({
"search": query,
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Write commands — signed events
// Write commands — signed events via POST /events
// ---------------------------------------------------------------------------
pub async fn cmd_send_message(
client: &SproutClient,
channel_id: &str,
content: &str,
_kind: Option<u16>,
reply_to: Option<&str>,
broadcast: bool,
mentions: &[String],
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_content_size(content)?;
if let Some(r) = reply_to {
pub struct SendMessageParams {
pub channel_id: String,
pub content: String,
#[allow(dead_code)] // reserved for future kind routing
pub kind: Option<u16>,
pub reply_to: Option<String>,
pub broadcast: bool,
pub mentions: Vec<String>,
pub files: Vec<String>,
}
pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> {
validate_uuid(&p.channel_id)?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
}
for m in mentions {
for m in &p.mentions {
validate_hex64(m)?;
}
let keys = require_keys!(client);
let channel_uuid = parse_uuid(channel_id)?;
let channel_uuid = parse_uuid(&p.channel_id)?;
// Upload files and build imeta tags
let mut media_tags: Vec<Vec<String>> = Vec::new();
let mut media_content = String::new();
for file_path in &p.files {
let desc = client
.upload_file(file_path)
.await
.map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?;
media_tags.push(crate::client::build_imeta_tag(&desc));
if desc.mime_type.starts_with("video/") {
media_content.push_str("\n![video](");
} else {
media_content.push_str("\n![image](");
}
media_content.push_str(&desc.url);
media_content.push(')');
}
let final_content = if media_content.is_empty() {
p.content.clone()
} else {
format!("{}{media_content}", p.content)
};
// Build thread ref if replying
let thread_ref = if let Some(r) = reply_to {
let thread_ref = if let Some(ref r) = p.reply_to {
let eid = parse_event_id(r)?;
Some(ThreadRef {
root_event_id: eid,
@@ -192,24 +233,22 @@ pub async fn cmd_send_message(
};
// Normalize explicit mentions, then merge auto-resolved up to SDK cap of 50.
let mut merged: Vec<String> = normalize_mention_pubkeys(mentions, "");
let auto_resolved = resolve_content_mentions(client, channel_id, content).await;
let mut merged: Vec<String> = normalize_mention_pubkeys(&p.mentions, "");
let auto_resolved = resolve_content_mentions(client, &p.channel_id, &final_content).await;
merge_mentions(&mut merged, &auto_resolved, 50);
let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect();
let builder = sprout_sdk::build_message(
channel_uuid,
content,
&final_content,
thread_ref.as_ref(),
&mention_refs,
broadcast,
&[],
p.broadcast,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
@@ -250,7 +289,6 @@ pub async fn cmd_send_diff_message(
_ => {}
}
let keys = require_keys!(client);
let channel_uuid = parse_uuid(&p.channel_id)?;
// Read diff from stdin if "--diff -"
@@ -304,9 +342,7 @@ pub async fn cmd_send_diff_message(
sprout_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref())
.map_err(|e| CliError::Other(format!("build_diff_message failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
@@ -315,18 +351,15 @@ pub async fn cmd_send_diff_message(
pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let keys = require_keys!(client);
// Resolve channel_id from the event's h-tag (needed by build_delete_message)
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = sprout_sdk::build_delete_message(channel_uuid, target_eid)
.map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
@@ -341,18 +374,15 @@ pub async fn cmd_edit_message(
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
let keys = require_keys!(client);
// Resolve channel_id from the event's h-tag (needed by build_edit)
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = sprout_sdk::build_edit(channel_uuid, target_eid, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
@@ -376,18 +406,14 @@ pub async fn cmd_vote_on_post(
}
};
let keys = require_keys!(client);
// Resolve channel_id from the event's h-tag (needed by build_vote)
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = sprout_sdk::build_vote(channel_uuid, target_eid, vote_dir)
.map_err(|e| CliError::Other(format!("build_vote failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");

View File

@@ -1,4 +1,3 @@
pub mod auth;
pub mod channels;
pub mod dms;
pub mod feed;

View File

@@ -3,7 +3,6 @@
//! These commands operate on local pack directories. No relay connection needed.
use std::path::Path;
use std::process;
use crate::error::CliError;
@@ -16,12 +15,10 @@ use crate::error::CliError;
pub fn cmd_validate(path: &str) -> Result<(), CliError> {
let pack_dir = Path::new(path);
if !pack_dir.exists() {
eprintln!("error: path does not exist: {path}");
process::exit(1);
return Err(CliError::Usage(format!("path does not exist: {path}")));
}
if !pack_dir.is_dir() {
eprintln!("error: not a directory: {path}");
process::exit(1);
return Err(CliError::Usage(format!("not a directory: {path}")));
}
let report = sprout_persona::validate::validate_pack(pack_dir);
@@ -38,8 +35,7 @@ pub fn cmd_validate(path: &str) -> Result<(), CliError> {
}
if report.has_errors() {
eprintln!("\nValidation failed.");
process::exit(1);
return Err(CliError::Usage("Validation failed.".into()));
} else if report.has_warnings() {
println!("Valid (with warnings).");
} else {
@@ -56,12 +52,10 @@ pub fn cmd_validate(path: &str) -> Result<(), CliError> {
pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
let pack_dir = Path::new(path);
if !pack_dir.exists() {
eprintln!("error: path does not exist: {path}");
process::exit(1);
return Err(CliError::Usage(format!("path does not exist: {path}")));
}
if !pack_dir.is_dir() {
eprintln!("error: not a directory: {path}");
process::exit(1);
return Err(CliError::Usage(format!("not a directory: {path}")));
}
// Resolve the pack — shows fully effective config (post-merge, post-split).

View File

@@ -2,18 +2,7 @@ use nostr::EventId;
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, validate_hex64};
/// Require keys on the client — fail fast with a clear error if absent.
macro_rules! require_keys {
($client:expr) => {
$client.keys().ok_or_else(|| {
CliError::Key(
"private key required for write operations (set SPROUT_PRIVATE_KEY)".into(),
)
})?
};
}
use crate::validate::validate_hex64;
pub async fn cmd_add_reaction(
client: &SproutClient,
@@ -21,17 +10,13 @@ pub async fn cmd_add_reaction(
emoji: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let keys = require_keys!(client);
let target_eid =
EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?;
let builder = sprout_sdk::build_reaction(target_eid, emoji)
.map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
@@ -44,94 +29,54 @@ pub async fn cmd_remove_reaction(
emoji: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let keys = require_keys!(client);
let keys = client.keys();
// To build a NIP-05 deletion event we need the original reaction event ID.
// Fetch the reactions list and find the caller's reaction for this emoji.
let reaction_event_id = find_my_reaction_event_id(client, event_id, emoji).await?;
// Find our reaction event by querying kind:7 reactions on this event from us
let my_pk = keys.public_key().to_hex();
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id],
"authors": [my_pk]
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse reactions query: {e}")))?;
let arr = events
.as_array()
.ok_or_else(|| CliError::Other("reactions query response is not an array".into()))?;
let builder = sprout_sdk::build_remove_reaction(reaction_event_id)
// Find the reaction event matching the emoji
let reaction_event_id = arr
.iter()
.find(|ev| ev.get("content").and_then(|c| c.as_str()) == Some(emoji))
.and_then(|ev| ev.get("id").and_then(|id| id.as_str()))
.ok_or_else(|| {
CliError::Other(format!(
"no reaction with emoji '{emoji}' found for your pubkey on event {event_id}"
))
})?;
let reaction_eid = EventId::parse(reaction_event_id)
.map_err(|e| CliError::Other(format!("invalid reaction event ID: {e}")))?;
let builder = sprout_sdk::build_remove_reaction(reaction_eid)
.map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Fetch GET /api/messages/{id}/reactions and find the caller's reaction event ID
/// for the given emoji. The caller's pubkey is inferred from the retained keys.
///
/// The reactions list groups by emoji and includes per-user `reaction_event_id` if
/// the relay exposes it. If the field is absent, falls back to REST DELETE.
async fn find_my_reaction_event_id(
client: &SproutClient,
message_event_id: &str,
emoji: &str,
) -> Result<EventId, CliError> {
let keys = require_keys!(client);
let my_pubkey = keys.public_key().to_hex();
let raw = client
.get_raw(&format!(
"/api/messages/{}/reactions",
percent_encode(message_event_id)
))
.await?;
let v: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse reactions response: {e}")))?;
// Response shape: { "reactions": [ { "emoji": "👍", "users": [ { "pubkey": "...", "reaction_event_id": "..." } ] } ] }
let reactions = v
.get("reactions")
.and_then(|r| r.as_array())
.ok_or_else(|| CliError::Other("reactions response missing 'reactions' array".into()))?;
let empty_vec = vec![];
for group in reactions {
let group_emoji = group.get("emoji").and_then(|e| e.as_str()).unwrap_or("");
if group_emoji != emoji {
continue;
}
let users = group
.get("users")
.and_then(|u| u.as_array())
.unwrap_or(&empty_vec);
for user in users {
let pubkey = user.get("pubkey").and_then(|p| p.as_str()).unwrap_or("");
if !pubkey.eq_ignore_ascii_case(&my_pubkey) {
continue;
}
// Found our reaction — extract the reaction_event_id if present
if let Some(reid) = user.get("reaction_event_id").and_then(|r| r.as_str()) {
return EventId::parse(reid)
.map_err(|e| CliError::Other(format!("invalid reaction_event_id: {e}")));
}
// TODO: relay does not yet expose reaction_event_id in list response.
// When it does, this branch will be unreachable.
return Err(CliError::Other(
"relay does not expose reaction_event_id in reactions list; \
cannot build signed deletion event"
.into(),
));
}
}
Err(CliError::Other(format!(
"no reaction with emoji '{emoji}' found for your pubkey on event {message_event_id}"
)))
}
pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
client
.run_get(&format!(
"/api/messages/{}/reactions",
percent_encode(event_id),
))
.await
// Query kind:7 reactions referencing this event
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}

View File

@@ -5,18 +5,7 @@ use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::validate_hex64;
/// Each command module defines its own `require_keys!` macro.
macro_rules! require_keys {
($client:expr) => {
$client.keys().ok_or_else(|| {
CliError::Key(
"private key required for write operations (set SPROUT_PRIVATE_KEY)".into(),
)
})?
};
}
/// Per-module helper (same pattern as messages.rs).
/// Per-module helper.
fn parse_event_id(hex: &str) -> Result<EventId, CliError> {
EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}
@@ -40,15 +29,12 @@ pub async fn cmd_publish_note(
validate_hex64(r)?;
}
let keys = require_keys!(client);
let reply_id = reply_to.map(parse_event_id).transpose()?;
let builder = sprout_sdk::build_note(content, reply_id)
.map_err(|e| CliError::Other(format!("build error: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
@@ -59,7 +45,6 @@ pub async fn cmd_set_contact_list(
client: &SproutClient,
contacts_json: &str,
) -> Result<(), CliError> {
let keys = require_keys!(client);
let entries: Vec<ContactEntry> = serde_json::from_str(contacts_json)
.map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?;
@@ -77,55 +62,58 @@ pub async fn cmd_set_contact_list(
let builder = sprout_sdk::build_contact_list(&contacts)
.map_err(|e| CliError::Other(format!("build error: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Get a single event by ID via POST /query.
pub async fn cmd_get_event(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let path = format!("/api/events/{event_id}");
client.run_get(&path).await
let filter = serde_json::json!({
"ids": [event_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get user notes (kind:1) by author pubkey.
pub async fn cmd_get_user_notes(
client: &SproutClient,
pubkey: &str,
limit: Option<u32>,
before: Option<i64>,
before_id: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
if let Some(bid) = before_id {
validate_hex64(bid)?;
}
if before_id.is_some() && before.is_none() {
return Err(CliError::Usage("before_id requires before".to_string()));
}
let mut path = format!("/api/users/{pubkey}/notes");
let mut params = vec![];
if let Some(l) = limit {
params.push(format!("limit={l}"));
}
let limit = limit.unwrap_or(50).min(100);
let mut filter = serde_json::json!({
"kinds": [1],
"authors": [pubkey],
"limit": limit
});
if let Some(b) = before {
params.push(format!("before={b}"));
filter["until"] = serde_json::json!(b);
}
if let Some(bid) = before_id {
params.push(format!("before_id={bid}"));
}
if !params.is_empty() {
path.push('?');
path.push_str(&params.join("&"));
}
client.run_get(&path).await
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get a user's contact list (kind:3) by pubkey.
pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let path = format!("/api/users/{pubkey}/contact-list");
client.run_get(&path).await
let filter = serde_json::json!({
"kinds": [3],
"authors": [pubkey],
"limit": 1
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}

View File

@@ -1,22 +1,13 @@
use nostr::{EventBuilder, Kind, Tag};
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, validate_hex64};
use crate::validate::validate_hex64;
/// Require keys on the client — fail fast with a clear error if absent.
macro_rules! require_keys {
($client:expr) => {
$client.keys().ok_or_else(|| {
CliError::Key(
"private key required for write operations (set SPROUT_PRIVATE_KEY)".into(),
)
})?
};
}
/// 3-way dispatch based on pubkey count:
/// 0 pubkeys → GET /api/users/me/profile
/// 1 pubkey → GET /api/users/{pk}/profile
/// 2+ pubkeys → POST /api/users/batch
/// Get user profiles (kind:0 metadata events).
/// 0 pubkeys → query our own profile
/// 1 pubkey → query that user's profile
/// 2+ pubkeys → query batch
pub async fn cmd_get_users(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> {
for pk in pubkeys {
validate_hex64(pk)?;
@@ -25,25 +16,19 @@ pub async fn cmd_get_users(client: &SproutClient, pubkeys: &[String]) -> Result<
return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into()));
}
let resp = match pubkeys.len() {
0 => client.get_raw("/api/users/me/profile").await?,
1 => {
client
.get_raw(&format!(
"/api/users/{}/profile",
percent_encode(&pubkeys[0])
))
.await?
}
_ => {
client
.post_raw(
"/api/users/batch",
&serde_json::json!({ "pubkeys": pubkeys }),
)
.await?
}
let my_pk = client.keys().public_key().to_hex();
let authors: Vec<&str> = if pubkeys.is_empty() {
vec![my_pk.as_str()]
} else {
pubkeys.iter().map(|s| s.as_str()).collect()
};
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len()
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
@@ -61,11 +46,7 @@ pub async fn cmd_set_profile(
));
}
let keys = require_keys!(client);
// Read-merge-write: fetch current profile, merge in the new fields, then sign.
// This preserves fields the caller didn't specify (e.g. existing avatar stays
// intact when only --name is passed).
let current = fetch_current_profile(client).await?;
// Merge: caller-supplied fields win; fall back to current profile values.
@@ -104,60 +85,77 @@ pub async fn cmd_set_profile(
let builder = sprout_sdk::build_profile(
merged_name.as_deref(),
None, // `name` field (username) — not exposed by CLI; preserve via display_name
None, // `name` field (username) — not exposed by CLI
merged_picture.as_deref(),
merged_about.as_deref(),
merged_nip05.as_deref(),
)
.map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?;
let event = builder
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Fetch the current user's profile metadata from GET /api/users/me/profile.
/// Returns the parsed JSON object (kind:0 content fields), or an empty object
/// if the profile hasn't been set yet.
/// Fetch the current user's profile metadata via POST /query (kind:0).
/// Returns the parsed content JSON object, or an empty object if no profile exists.
async fn fetch_current_profile(
client: &SproutClient,
) -> Result<serde_json::Map<String, serde_json::Value>, CliError> {
let raw = client.get_raw("/api/users/me/profile").await;
match raw {
Ok(body) => {
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| CliError::Other(format!("failed to parse profile response: {e}")))?;
// The relay may return the profile fields at top level or nested under "profile"
let obj = if let Some(profile) = v.get("profile").and_then(|p| p.as_object()) {
profile.clone()
} else if let Some(obj) = v.as_object() {
obj.clone()
} else {
serde_json::Map::new()
};
Ok(obj)
}
// 404 = no profile yet — start fresh
Err(CliError::Relay { status: 404, .. }) => Ok(serde_json::Map::new()),
Err(e) => Err(e),
}
let my_pk = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [0],
"authors": [my_pk],
"limit": 1
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse profile query: {e}")))?;
let Some(arr) = events.as_array() else {
return Ok(serde_json::Map::new());
};
let Some(event) = arr.first() else {
return Ok(serde_json::Map::new());
};
// kind:0 content is a JSON string containing the profile fields
let content_str = event
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("{}");
let content: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default();
Ok(content.as_object().cloned().unwrap_or_default())
}
/// Get presence status for users — query kind:40902 presence snapshot events.
pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Result<(), CliError> {
for pk in pubkeys_csv.split(',') {
let pk = pk.trim();
if !pk.is_empty() {
validate_hex64(pk)?;
}
let pubkeys: Vec<&str> = pubkeys_csv
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for pk in &pubkeys {
validate_hex64(pk)?;
}
let path = format!("/api/presence?pubkeys={}", percent_encode(pubkeys_csv));
client.run_get(&path).await
let filter = serde_json::json!({
"kinds": [40902],
"authors": pubkeys,
"limit": pubkeys.len()
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Set presence status — sign and submit a kind:20001 presence update event.
///
/// NOTE: Kind 20001 is ephemeral and only accepted via WebSocket connections.
/// The CLI uses the HTTP bridge (POST /events) which rejects ephemeral kinds.
/// This will fail until the CLI gains a WS publish path. The kind is correct
/// per the protocol spec (KIND_PRESENCE_UPDATE = 20001).
pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), CliError> {
match status {
"online" | "away" | "offline" => {}
@@ -167,27 +165,17 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(),
)))
}
}
client
.run_put("/api/presence", &serde_json::json!({ "status": status }))
.await
}
pub async fn cmd_set_channel_add_policy(
client: &SproutClient,
policy: &str,
) -> Result<(), CliError> {
match policy {
"anyone" | "owner_only" | "nobody" => {}
_ => {
return Err(CliError::Usage(format!(
"--policy must be one of: anyone, owner_only, nobody (got: {policy})"
)))
}
}
client
.run_put(
"/api/users/me/channel-add-policy",
&serde_json::json!({ "channel_add_policy": policy }),
)
.await
let tags =
vec![Tag::parse(&["status", status])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
// KIND_PRESENCE_UPDATE (20001) — ephemeral, WS-only. HTTP bridge will reject this
// until the CLI gains a WebSocket publish path.
let builder = EventBuilder::new(Kind::Custom(20001), "", tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

View File

@@ -1,64 +1,39 @@
use nostr::{EventBuilder, Kind, Tag};
use sha2::{Digest, Sha256};
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{read_or_stdin, validate_uuid};
// ---------------------------------------------------------------------------
// Read commands — POST /query
// ---------------------------------------------------------------------------
/// List workflows in a channel — query kind:30620 workflow definition events.
pub async fn cmd_list_workflows(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}/workflows", channel_id))
.await
let filter = serde_json::json!({
"kinds": [30620],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_create_workflow(
client: &SproutClient,
channel_id: &str,
yaml: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let yaml_definition = read_or_stdin(yaml)?;
client
.run_post(
&format!("/api/channels/{}/workflows", channel_id),
&serde_json::json!({ "yaml_definition": yaml_definition }),
)
.await
}
pub async fn cmd_update_workflow(
client: &SproutClient,
workflow_id: &str,
yaml: &str,
) -> Result<(), CliError> {
/// Get a single workflow definition.
pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let yaml_definition = read_or_stdin(yaml)?;
client
.run_put(
&format!("/api/workflows/{}", workflow_id),
&serde_json::json!({ "yaml_definition": yaml_definition }),
)
.await
}
pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
client
.run_delete(&format!("/api/workflows/{}", workflow_id))
.await
}
pub async fn cmd_trigger_workflow(
client: &SproutClient,
workflow_id: &str,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
client
.run_post(
&format!("/api/workflows/{}/trigger", workflow_id),
&serde_json::json!({}),
)
.await
let filter = serde_json::json!({
"kinds": [30620],
"#d": [workflow_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get workflow run history — query kind:46020 trigger events for this workflow.
pub async fn cmd_get_workflow_runs(
client: &SproutClient,
workflow_id: &str,
@@ -66,18 +41,105 @@ pub async fn cmd_get_workflow_runs(
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let limit = limit.unwrap_or(20).min(100);
let path = format!("/api/workflows/{}/runs?limit={}", workflow_id, limit);
client.run_get(&path).await
let filter = serde_json::json!({
"kinds": [46020],
"#d": [workflow_id],
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> {
// ---------------------------------------------------------------------------
// Write commands — signed events via POST /events
// ---------------------------------------------------------------------------
/// Create a workflow — sign and submit a kind:30620 event.
pub async fn cmd_create_workflow(
client: &SproutClient,
channel_id: &str,
yaml: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let yaml_definition = read_or_stdin(yaml)?;
// Generate a unique d-tag for this workflow
let workflow_id = uuid::Uuid::new_v4().to_string();
let tags = vec![
Tag::parse(&["d", &workflow_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
];
let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Update a workflow — sign and submit an updated kind:30620 event with same d-tag.
pub async fn cmd_update_workflow(
client: &SproutClient,
workflow_id: &str,
yaml: &str,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
client
.run_get(&format!("/api/workflows/{}", workflow_id))
.await
let yaml_definition = read_or_stdin(yaml)?;
let tags =
vec![Tag::parse(&["d", workflow_id])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Route is /grant or /deny based on the `approved` flag.
/// Delete a workflow — sign and submit a kind:5 deletion event.
pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let keys = client.keys();
// NIP-09 deletion targeting the parameterized replaceable event
let tags = vec![Tag::parse(&[
"a",
&format!("30620:{}:{}", keys.public_key().to_hex(), workflow_id),
])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder = EventBuilder::new(Kind::Custom(5), "", tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Trigger a workflow — sign and submit a kind:46020 event.
pub async fn cmd_trigger_workflow(
client: &SproutClient,
workflow_id: &str,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let tags =
vec![Tag::parse(&["d", workflow_id])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder = EventBuilder::new(Kind::Custom(46020), "", tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Approve or deny a workflow step — sign and submit a kind:46030 (grant) or 46031 (deny) event.
pub async fn cmd_approve_step(
client: &SproutClient,
approval_token: &str,
@@ -85,15 +147,20 @@ pub async fn cmd_approve_step(
note: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(approval_token)?;
let route = if approved { "grant" } else { "deny" };
let mut body = serde_json::json!({});
if let Some(n) = note {
body["note"] = n.into();
}
client
.run_post(
&format!("/api/approvals/{}/{}", approval_token, route),
&body,
)
.await
let kind = if approved { 46030 } else { 46031 };
let content = note.unwrap_or("");
// The relay expects d-tag = hex(SHA256(token)), not the raw token UUID.
let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes()));
let tags =
vec![Tag::parse(&["d", &token_hash])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder = EventBuilder::new(Kind::Custom(kind), content, tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

View File

@@ -0,0 +1,946 @@
mod client;
mod commands;
mod error;
mod validate;
use clap::{Parser, Subcommand};
use client::SproutClient;
use error::CliError;
use nostr::Keys;
/// Run the Sprout CLI from raw arguments (including `argv[0]`).
///
/// Returns a process exit code (0 = success).
///
/// # Example
///
/// ```ignore
/// let code = sprout_cli::run_from_args(std::env::args()).await;
/// std::process::exit(code);
/// ```
pub async fn run_from_args<I, S>(args: I) -> i32
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(e) => {
if e.use_stderr() {
error::print_error(&CliError::Usage(e.to_string()));
return 1;
} else {
// --help and --version: print normally (intentional human output)
let _ = e.print();
return 0;
}
}
};
match run(cli).await {
Ok(()) => 0,
Err(e) => {
error::print_error(&e);
error::exit_code(&e)
}
}
}
// ---------------------------------------------------------------------------
// Top-level CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")]
struct Cli {
#[arg(
long,
env = "SPROUT_RELAY_URL",
default_value = "http://localhost:3000"
)]
relay: String,
/// Nostr private key (hex or nsec). This is the CLI's identity.
#[arg(long, env = "SPROUT_PRIVATE_KEY")]
private_key: Option<String>,
/// NIP-OA auth tag JSON (owner attestation). Injected into every signed event.
#[arg(long, env = "SPROUT_AUTH_TAG")]
auth_tag: Option<String>,
#[command(subcommand)]
command: Cmd,
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
enum Cmd {
// ---- Messages ----------------------------------------------------------
/// Send a message to a channel
SendMessage {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
#[arg(long)]
kind: Option<u16>,
#[arg(long)]
reply_to: Option<String>,
#[arg(long, default_value_t = false)]
broadcast: bool,
#[arg(long = "mention")]
mentions: Vec<String>,
/// Attach file(s) — uploads and includes as imeta tags
#[arg(long = "file")]
files: Vec<String>,
},
/// Send a diff/code-review message
SendDiffMessage {
#[arg(long)]
channel: String,
#[arg(long)]
diff: String,
#[arg(long)]
repo: String,
#[arg(long)]
commit: String,
#[arg(long)]
file: Option<String>,
#[arg(long)]
parent_commit: Option<String>,
#[arg(long)]
source_branch: Option<String>,
#[arg(long)]
target_branch: Option<String>,
#[arg(long)]
pr: Option<u32>,
#[arg(long)]
lang: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
reply_to: Option<String>,
},
/// Delete a message by event ID
DeleteMessage {
#[arg(long)]
event: String,
},
/// Get messages from a channel
GetMessages {
#[arg(long)]
channel: String,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
before: Option<i64>,
#[arg(long)]
since: Option<i64>,
#[arg(long)]
kinds: Option<String>,
},
/// Get a message thread
GetThread {
#[arg(long)]
channel: String,
#[arg(long)]
event: String,
#[arg(long)]
depth_limit: Option<u32>,
#[arg(long)]
limit: Option<u32>,
},
/// Search messages
Search {
#[arg(long)]
query: String,
#[arg(long)]
limit: Option<u32>,
},
/// Edit a message you previously sent
EditMessage {
/// Event ID of the message to edit (64-char hex)
#[arg(long)]
event: String,
/// New message content
#[arg(long)]
content: String,
},
/// Vote on a forum post or comment (up or down)
VoteOnPost {
/// Event ID of the post to vote on (64-char hex)
#[arg(long)]
event: String,
/// Vote direction: "up" or "down"
#[arg(long)]
direction: String,
},
/// Upload a file to the relay (returns BlobDescriptor JSON)
UploadFile {
/// Path to the file to upload
#[arg(long)]
file: String,
},
// ---- Channels ----------------------------------------------------------
/// List channels
ListChannels {
#[arg(long)]
visibility: Option<String>,
#[arg(long, default_value_t = false)]
member: bool,
},
/// Get a channel by ID
GetChannel {
#[arg(long)]
channel: String,
},
/// Create a new channel
CreateChannel {
#[arg(long)]
name: String,
#[arg(long = "type")]
channel_type: String,
#[arg(long)]
visibility: String,
#[arg(long)]
description: Option<String>,
},
/// Update a channel's name or description
UpdateChannel {
#[arg(long)]
channel: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Set a channel's topic
SetChannelTopic {
#[arg(long)]
channel: String,
#[arg(long)]
topic: String,
},
/// Set a channel's purpose
SetChannelPurpose {
#[arg(long)]
channel: String,
#[arg(long)]
purpose: String,
},
/// Join a channel
JoinChannel {
#[arg(long)]
channel: String,
},
/// Leave a channel
LeaveChannel {
#[arg(long)]
channel: String,
},
/// Archive a channel
ArchiveChannel {
#[arg(long)]
channel: String,
},
/// Unarchive a channel
UnarchiveChannel {
#[arg(long)]
channel: String,
},
/// Delete a channel
DeleteChannel {
#[arg(long)]
channel: String,
},
/// List channel members
ListChannelMembers {
#[arg(long)]
channel: String,
},
/// Add a member to a channel
AddChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
#[arg(long)]
role: Option<String>,
},
/// Remove a member from a channel
RemoveChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
/// Get a channel's canvas
GetCanvas {
#[arg(long)]
channel: String,
},
/// Set a channel's canvas content
SetCanvas {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
},
// ---- Reactions ---------------------------------------------------------
/// Add a reaction to a message
AddReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Remove a reaction from a message
RemoveReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Get reactions on a message
GetReactions {
#[arg(long)]
event: String,
},
// ---- DMs ---------------------------------------------------------------
/// List DM conversations
ListDms {
#[arg(long)]
limit: Option<u32>,
},
/// Open a DM with one or more users (1–8 pubkeys)
OpenDm {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Add a member to a DM group
AddDmMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
// ---- Users -------------------------------------------------------------
/// Get user profiles (0 = self, 1 = single, 2+ = batch)
GetUsers {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Update your profile
SetProfile {
#[arg(long)]
name: Option<String>,
#[arg(long)]
avatar: Option<String>,
#[arg(long)]
about: Option<String>,
#[arg(long)]
nip05: Option<String>,
},
/// Get presence status for users (comma-separated pubkeys)
GetPresence {
#[arg(long)]
pubkeys: String,
},
/// Set your presence status
SetPresence {
#[arg(long)]
status: String,
},
// ---- Workflows ---------------------------------------------------------
/// List workflows in a channel
ListWorkflows {
#[arg(long)]
channel: String,
},
/// Create a workflow in a channel
CreateWorkflow {
#[arg(long)]
channel: String,
#[arg(long)]
yaml: String,
},
/// Update a workflow
UpdateWorkflow {
#[arg(long)]
workflow: String,
#[arg(long)]
yaml: String,
},
/// Delete a workflow
DeleteWorkflow {
#[arg(long)]
workflow: String,
},
/// Trigger a workflow manually
TriggerWorkflow {
#[arg(long)]
workflow: String,
},
/// Get workflow run history
GetWorkflowRuns {
#[arg(long)]
workflow: String,
#[arg(long)]
limit: Option<u32>,
},
/// Get a workflow definition
GetWorkflow {
#[arg(long)]
workflow: String,
},
/// Approve or deny a workflow approval step
ApproveStep {
/// The approval token UUID (from the approval request)
#[arg(long)]
token: String,
/// Whether to approve: "true" or "false"
#[arg(long)]
approved: String,
#[arg(long)]
note: Option<String>,
},
// ---- Feed --------------------------------------------------------------
/// Get your activity feed
GetFeed {
#[arg(long)]
since: Option<i64>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
types: Option<String>,
},
// Social
/// Publish a short text note (kind:1) to the global feed.
#[command(name = "publish-note")]
PublishNote {
/// Text content of the note.
#[arg(long)]
content: String,
/// 64-char hex event ID to reply to.
#[arg(long)]
reply_to: Option<String>,
},
/// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list.
#[command(name = "set-contact-list")]
SetContactList {
/// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}]
#[arg(long)]
contacts: String,
},
/// Get a single event by event ID (notes, profiles, contacts, articles, channel events).
#[command(name = "get-event")]
GetEvent {
/// 64-char hex event ID.
#[arg(long)]
event: String,
},
/// List kind:1 text notes by a specific user.
#[command(name = "get-user-notes")]
GetUserNotes {
/// 64-char hex pubkey of the author.
#[arg(long)]
pubkey: String,
/// Maximum number of notes to return (default 50, max 100).
#[arg(long)]
limit: Option<u32>,
/// Unix timestamp cursor — return notes created before this time.
#[arg(long)]
before: Option<i64>,
},
/// Get a user's contact/follow list (kind:3) by hex pubkey.
#[command(name = "get-contact-list")]
GetContactList {
/// 64-char hex pubkey.
#[arg(long)]
pubkey: String,
},
// ---- Pack (local) ------------------------------------------------------
/// Persona pack operations (local, no relay connection needed)
#[command(subcommand)]
Pack(PackCmd),
}
/// Subcommands for `sprout pack`.
#[derive(Subcommand)]
enum PackCmd {
/// Validate a persona pack directory
Validate {
/// Path to the pack directory
path: String,
},
/// Inspect a persona pack — show metadata and effective config
Inspect {
/// Path to the pack directory
path: String,
},
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Parse a string flag that must be "true" or "false".
fn parse_bool_flag(flag_name: &str, value: &str) -> Result<bool, CliError> {
match value {
"true" => Ok(true),
"false" => Ok(false),
other => Err(CliError::Usage(format!(
"{flag_name} must be 'true' or 'false' (got: {other})"
))),
}
}
async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);
// Pack commands are local-only — no relay connection needed.
if let Cmd::Pack(ref sub) = cli.command {
return match sub {
PackCmd::Validate { path } => commands::pack::cmd_validate(path),
PackCmd::Inspect { path } => commands::pack::cmd_inspect(path),
};
}
// Auth: private key is required for all relay operations.
// The keypair IS the identity — no tokens, no other auth.
let private_key_str = cli.private_key.ok_or_else(|| {
CliError::Auth("SPROUT_PRIVATE_KEY is required (use --private-key or set env var)".into())
})?;
let keys = Keys::parse(&private_key_str)
.map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?;
// NIP-OA: parse and verify the auth tag if provided.
let (auth_tag, auth_tag_json) = match cli.auth_tag {
Some(ref json) if !json.is_empty() => {
let tag = sprout_sdk::nip_oa::parse_auth_tag(json)
.map_err(|e| CliError::Auth(format!("SPROUT_AUTH_TAG is malformed: {e}")))?;
sprout_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| {
CliError::Auth(format!(
"SPROUT_AUTH_TAG verification failed for pubkey {}: {e}",
keys.public_key().to_hex()
))
})?;
(Some(tag), Some(json.clone()))
}
_ => (None, None),
};
let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
match cli.command {
// ---- Messages ------------------------------------------------------
Cmd::SendMessage {
channel,
content,
kind,
reply_to,
broadcast,
mentions,
files,
} => {
commands::messages::cmd_send_message(
&client,
commands::messages::SendMessageParams {
channel_id: channel,
content,
kind,
reply_to,
broadcast,
mentions,
files,
},
)
.await
}
Cmd::SendDiffMessage {
channel,
diff,
repo,
commit,
file,
parent_commit,
source_branch,
target_branch,
pr,
lang,
description,
reply_to,
} => {
commands::messages::cmd_send_diff_message(
&client,
commands::messages::SendDiffParams {
channel_id: channel,
diff,
repo_url: repo,
commit_sha: commit,
file_path: file,
parent_commit_sha: parent_commit,
source_branch,
target_branch,
pr_number: pr,
language: lang,
description,
reply_to,
},
)
.await
}
Cmd::DeleteMessage { event } => {
commands::messages::cmd_delete_message(&client, &event).await
}
Cmd::GetMessages {
channel,
limit,
before,
since,
kinds,
} => {
commands::messages::cmd_get_messages(
&client,
&channel,
limit,
before,
since,
kinds.as_deref(),
)
.await
}
Cmd::GetThread {
channel,
event,
depth_limit,
limit,
} => {
commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).await
}
Cmd::Search { query, limit } => {
commands::messages::cmd_search(&client, &query, limit).await
}
Cmd::EditMessage { event, content } => {
commands::messages::cmd_edit_message(&client, &event, &content).await
}
Cmd::VoteOnPost { event, direction } => {
commands::messages::cmd_vote_on_post(&client, &event, &direction).await
}
Cmd::UploadFile { file } => {
let desc = client.upload_file(&file).await?;
println!(
"{}",
serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
);
Ok(())
}
// ---- Channels ------------------------------------------------------
Cmd::ListChannels { visibility, member } => {
commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member))
.await
}
Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await,
Cmd::CreateChannel {
name,
channel_type,
visibility,
description,
} => {
commands::channels::cmd_create_channel(
&client,
&name,
&channel_type,
&visibility,
description.as_deref(),
)
.await
}
Cmd::UpdateChannel {
channel,
name,
description,
} => {
commands::channels::cmd_update_channel(
&client,
&channel,
name.as_deref(),
description.as_deref(),
)
.await
}
Cmd::SetChannelTopic { channel, topic } => {
commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await
}
Cmd::SetChannelPurpose { channel, purpose } => {
commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await
}
Cmd::JoinChannel { channel } => {
commands::channels::cmd_join_channel(&client, &channel).await
}
Cmd::LeaveChannel { channel } => {
commands::channels::cmd_leave_channel(&client, &channel).await
}
Cmd::ArchiveChannel { channel } => {
commands::channels::cmd_archive_channel(&client, &channel).await
}
Cmd::UnarchiveChannel { channel } => {
commands::channels::cmd_unarchive_channel(&client, &channel).await
}
Cmd::DeleteChannel { channel } => {
commands::channels::cmd_delete_channel(&client, &channel).await
}
Cmd::ListChannelMembers { channel } => {
commands::channels::cmd_list_channel_members(&client, &channel).await
}
Cmd::AddChannelMember {
channel,
pubkey,
role,
} => {
commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref())
.await
}
Cmd::RemoveChannelMember { channel, pubkey } => {
commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await
}
Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await,
Cmd::SetCanvas { channel, content } => {
commands::channels::cmd_set_canvas(&client, &channel, &content).await
}
// ---- Reactions -----------------------------------------------------
Cmd::AddReaction { event, emoji } => {
commands::reactions::cmd_add_reaction(&client, &event, &emoji).await
}
Cmd::RemoveReaction { event, emoji } => {
commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await
}
Cmd::GetReactions { event } => {
commands::reactions::cmd_get_reactions(&client, &event).await
}
// ---- DMs -----------------------------------------------------------
Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, limit).await,
Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await,
Cmd::AddDmMember { channel, pubkey } => {
commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await
}
// ---- Users ---------------------------------------------------------
Cmd::GetUsers { pubkeys } => commands::users::cmd_get_users(&client, &pubkeys).await,
Cmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
commands::users::cmd_set_profile(
&client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await,
Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await,
// ---- Workflows -----------------------------------------------------
Cmd::ListWorkflows { channel } => {
commands::workflows::cmd_list_workflows(&client, &channel).await
}
Cmd::CreateWorkflow { channel, yaml } => {
commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await
}
Cmd::UpdateWorkflow { workflow, yaml } => {
commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await
}
Cmd::DeleteWorkflow { workflow } => {
commands::workflows::cmd_delete_workflow(&client, &workflow).await
}
Cmd::TriggerWorkflow { workflow } => {
commands::workflows::cmd_trigger_workflow(&client, &workflow).await
}
Cmd::GetWorkflowRuns { workflow, limit } => {
commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await
}
Cmd::GetWorkflow { workflow } => {
commands::workflows::cmd_get_workflow(&client, &workflow).await
}
Cmd::ApproveStep {
token,
approved,
note,
} => {
let approved = parse_bool_flag("--approved", &approved)?;
commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await
}
// ---- Feed ----------------------------------------------------------
Cmd::GetFeed {
since,
limit,
types,
} => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
// ---- Social --------------------------------------------------------
Cmd::PublishNote { content, reply_to } => {
commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await
}
Cmd::SetContactList { contacts } => {
commands::social::cmd_set_contact_list(&client, &contacts).await
}
Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await,
Cmd::GetUserNotes {
pubkey,
limit,
before,
} => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await,
Cmd::GetContactList { pubkey } => {
commands::social::cmd_get_contact_list(&client, &pubkey).await
}
// ---- Pack (local) --------------------------------------------------
Cmd::Pack(_) => unreachable!("handled above"),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// Smoke test: CLI definition is valid and parseable.
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
/// Regression: parse_bool_flag rejects values other than "true"/"false".
#[test]
fn parse_bool_flag_accepts_true() {
assert!(super::parse_bool_flag("--approved", "true").unwrap());
}
#[test]
fn parse_bool_flag_accepts_false() {
assert!(!super::parse_bool_flag("--approved", "false").unwrap());
}
#[test]
fn parse_bool_flag_rejects_invalid() {
let err = super::parse_bool_flag("--approved", "maybe").unwrap_err();
match err {
super::CliError::Usage(msg) => {
assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}");
assert!(msg.contains("maybe"), "got: {msg}");
}
other => panic!("expected Usage error, got: {other:?}"),
}
}
#[test]
fn parse_bool_flag_rejects_empty() {
assert!(super::parse_bool_flag("--approved", "").is_err());
}
/// Parity: the CLI exposes exactly the expected set of commands.
/// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens).
/// SetChannelAddPolicy removed (relay-side policy now).
/// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes.
#[test]
fn command_inventory_is_stable() {
let expected: Vec<&str> = vec![
"add-channel-member",
"add-dm-member",
"add-reaction",
"approve-step",
"archive-channel",
"create-channel",
"create-workflow",
"delete-channel",
"delete-message",
"delete-workflow",
"edit-message",
"get-canvas",
"get-channel",
"get-contact-list",
"get-event",
"get-feed",
"get-messages",
"get-presence",
"get-reactions",
"get-thread",
"get-user-notes",
"get-users",
"get-workflow",
"get-workflow-runs",
"join-channel",
"leave-channel",
"list-channel-members",
"list-channels",
"list-dms",
"list-workflows",
"open-dm",
"pack",
"publish-note",
"remove-channel-member",
"remove-reaction",
"search",
"send-diff-message",
"send-message",
"set-canvas",
"set-channel-purpose",
"set-channel-topic",
"set-contact-list",
"set-presence",
"set-profile",
"trigger-workflow",
"unarchive-channel",
"update-channel",
"update-workflow",
"upload-file",
"vote-on-post",
];
let cmd = Cli::command();
let mut actual: Vec<String> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.filter(|n| n != "help") // clap auto-adds "help"
.collect();
actual.sort();
assert_eq!(
actual.len(),
expected.len(),
"Expected {} commands, got {}. Actual: {:?}",
expected.len(),
actual.len(),
actual
);
assert_eq!(actual, expected, "Command inventory drift detected");
}
}

View File

@@ -1,974 +1,4 @@
mod client;
mod commands;
mod error;
mod validate;
use clap::{Parser, Subcommand};
use client::{Auth, SproutClient};
use error::CliError;
// ---------------------------------------------------------------------------
// Top-level CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")]
struct Cli {
#[arg(
long,
env = "SPROUT_RELAY_URL",
default_value = "http://localhost:3000"
)]
relay: String,
#[arg(long, env = "SPROUT_API_TOKEN")]
token: Option<String>,
#[arg(long, env = "SPROUT_PRIVATE_KEY", hide = true)]
private_key: Option<String>,
#[arg(long, env = "SPROUT_PUBKEY")]
pubkey: Option<String>,
#[command(subcommand)]
command: Cmd,
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
enum Cmd {
// ---- Messages ----------------------------------------------------------
/// Send a message to a channel
SendMessage {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
#[arg(long)]
kind: Option<u16>,
#[arg(long)]
reply_to: Option<String>,
#[arg(long, default_value_t = false)]
broadcast: bool,
#[arg(long = "mention")]
mentions: Vec<String>,
},
/// Send a diff/code-review message
SendDiffMessage {
#[arg(long)]
channel: String,
#[arg(long)]
diff: String,
#[arg(long)]
repo: String,
#[arg(long)]
commit: String,
#[arg(long)]
file: Option<String>,
#[arg(long)]
parent_commit: Option<String>,
#[arg(long)]
source_branch: Option<String>,
#[arg(long)]
target_branch: Option<String>,
#[arg(long)]
pr: Option<u32>,
#[arg(long)]
lang: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
reply_to: Option<String>,
},
/// Delete a message by event ID
DeleteMessage {
#[arg(long)]
event: String,
},
/// Get messages from a channel
GetMessages {
#[arg(long)]
channel: String,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
before: Option<i64>,
#[arg(long)]
since: Option<i64>,
#[arg(long)]
kinds: Option<String>,
},
/// Get a message thread
GetThread {
#[arg(long)]
channel: String,
#[arg(long)]
event: String,
#[arg(long)]
depth_limit: Option<u32>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
cursor: Option<String>,
},
/// Search messages
Search {
#[arg(long)]
query: String,
#[arg(long)]
limit: Option<u32>,
},
/// Edit a message you previously sent
EditMessage {
/// Event ID of the message to edit (64-char hex)
#[arg(long)]
event: String,
/// New message content
#[arg(long)]
content: String,
},
/// Vote on a forum post or comment (up or down)
VoteOnPost {
/// Event ID of the post to vote on (64-char hex)
#[arg(long)]
event: String,
/// Vote direction: "up" or "down"
#[arg(long)]
direction: String,
},
// ---- Channels ----------------------------------------------------------
/// List channels
ListChannels {
#[arg(long)]
visibility: Option<String>,
#[arg(long, default_value_t = false)]
member: bool,
},
/// Get a channel by ID
GetChannel {
#[arg(long)]
channel: String,
},
/// Create a new channel
CreateChannel {
#[arg(long)]
name: String,
#[arg(long = "type")]
channel_type: String,
#[arg(long)]
visibility: String,
#[arg(long)]
description: Option<String>,
},
/// Update a channel's name or description
UpdateChannel {
#[arg(long)]
channel: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Set a channel's topic
SetChannelTopic {
#[arg(long)]
channel: String,
#[arg(long)]
topic: String,
},
/// Set a channel's purpose
SetChannelPurpose {
#[arg(long)]
channel: String,
#[arg(long)]
purpose: String,
},
/// Join a channel
JoinChannel {
#[arg(long)]
channel: String,
},
/// Leave a channel
LeaveChannel {
#[arg(long)]
channel: String,
},
/// Archive a channel
ArchiveChannel {
#[arg(long)]
channel: String,
},
/// Unarchive a channel
UnarchiveChannel {
#[arg(long)]
channel: String,
},
/// Delete a channel
DeleteChannel {
#[arg(long)]
channel: String,
},
/// List channel members
ListChannelMembers {
#[arg(long)]
channel: String,
},
/// Add a member to a channel
AddChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
#[arg(long)]
role: Option<String>,
},
/// Remove a member from a channel
RemoveChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
/// Get a channel's canvas
GetCanvas {
#[arg(long)]
channel: String,
},
/// Set a channel's canvas content
SetCanvas {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
},
// ---- Reactions ---------------------------------------------------------
/// Add a reaction to a message
AddReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Remove a reaction from a message
RemoveReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Get reactions on a message
GetReactions {
#[arg(long)]
event: String,
},
// ---- DMs ---------------------------------------------------------------
/// List DM conversations
ListDms {
#[arg(long)]
cursor: Option<String>,
#[arg(long)]
limit: Option<u32>,
},
/// Open a DM with one or more users (1–8 pubkeys)
OpenDm {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Add a member to a DM group
AddDmMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
// ---- Users -------------------------------------------------------------
/// Get user profiles (0 = self, 1 = single, 2+ = batch)
GetUsers {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Update your profile
SetProfile {
#[arg(long)]
name: Option<String>,
#[arg(long)]
avatar: Option<String>,
#[arg(long)]
about: Option<String>,
#[arg(long)]
nip05: Option<String>,
},
/// Get presence status for users (comma-separated pubkeys)
GetPresence {
#[arg(long)]
pubkeys: String,
},
/// Set your presence status
SetPresence {
#[arg(long)]
status: String,
},
/// Set who can add you to channels
SetChannelAddPolicy {
#[arg(long)]
policy: String,
},
// ---- Workflows ---------------------------------------------------------
/// List workflows in a channel
ListWorkflows {
#[arg(long)]
channel: String,
},
/// Create a workflow in a channel
CreateWorkflow {
#[arg(long)]
channel: String,
#[arg(long)]
yaml: String,
},
/// Update a workflow
UpdateWorkflow {
#[arg(long)]
workflow: String,
#[arg(long)]
yaml: String,
},
/// Delete a workflow
DeleteWorkflow {
#[arg(long)]
workflow: String,
},
/// Trigger a workflow manually
TriggerWorkflow {
#[arg(long)]
workflow: String,
},
/// Get workflow run history
GetWorkflowRuns {
#[arg(long)]
workflow: String,
#[arg(long)]
limit: Option<u32>,
},
/// Get a workflow definition
GetWorkflow {
#[arg(long)]
workflow: String,
},
/// Approve or deny a workflow approval step
ApproveStep {
#[arg(long)]
token: String,
/// Whether to approve: "true" or "false"
#[arg(long)]
approved: String,
#[arg(long)]
note: Option<String>,
},
// ---- Feed --------------------------------------------------------------
/// Get your activity feed
GetFeed {
#[arg(long)]
since: Option<i64>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
types: Option<String>,
},
// ---- Auth & Tokens -----------------------------------------------------
/// Mint a long-lived API token (prints token to stdout)
Auth,
/// List your API tokens
ListTokens,
/// Delete an API token by ID
DeleteToken {
#[arg(long)]
id: String,
},
/// Delete all your API tokens
DeleteAllTokens,
// Social
/// Publish a short text note (kind:1) to the global feed.
#[command(name = "publish-note")]
PublishNote {
/// Text content of the note.
#[arg(long)]
content: String,
/// 64-char hex event ID to reply to.
#[arg(long)]
reply_to: Option<String>,
},
/// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list.
#[command(name = "set-contact-list")]
SetContactList {
/// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}]
#[arg(long)]
contacts: String,
},
/// Get a single event by event ID (notes, profiles, contacts, articles, channel events).
#[command(name = "get-event")]
GetEvent {
/// 64-char hex event ID.
#[arg(long)]
event: String,
},
/// List kind:1 text notes by a specific user.
#[command(name = "get-user-notes")]
GetUserNotes {
/// 64-char hex pubkey of the author.
#[arg(long)]
pubkey: String,
/// Maximum number of notes to return (default 50, max 100).
#[arg(long)]
limit: Option<u32>,
/// Unix timestamp cursor — return notes created before this time.
/// Use with --before-id for stable composite cursor pagination.
#[arg(long)]
before: Option<i64>,
/// Hex event ID cursor for composite keyset pagination. Use together with
/// --before to avoid skipping same-second events. Pass the before_id value
/// from the previous page's next_cursor response.
#[arg(long)]
before_id: Option<String>,
},
/// Get a user's contact/follow list (kind:3) by hex pubkey.
#[command(name = "get-contact-list")]
GetContactList {
/// 64-char hex pubkey.
#[arg(long)]
pubkey: String,
},
// ---- Pack (local) ------------------------------------------------------
/// Persona pack operations (local, no relay connection needed)
#[command(subcommand)]
Pack(PackCmd),
}
/// Subcommands for `sprout pack`.
#[derive(Subcommand)]
enum PackCmd {
/// Validate a persona pack directory
Validate {
/// Path to the pack directory
path: String,
},
/// Inspect a persona pack — show metadata and effective config
Inspect {
/// Path to the pack directory
path: String,
},
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
if e.use_stderr() {
error::print_error(&CliError::Usage(e.to_string()));
std::process::exit(1);
} else {
// --help and --version: print normally (intentional human output)
let _ = e.print();
std::process::exit(0);
}
}
};
match run(cli).await {
Ok(()) => {}
Err(e) => {
error::print_error(&e);
std::process::exit(error::exit_code(&e));
}
}
}
/// Parse a string flag that must be "true" or "false".
fn parse_bool_flag(flag_name: &str, value: &str) -> Result<bool, CliError> {
match value {
"true" => Ok(true),
"false" => Ok(false),
other => Err(CliError::Usage(format!(
"{flag_name} must be 'true' or 'false' (got: {other})"
))),
}
}
async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);
// Auth command is special — runs before SproutClient creation.
// Passes --private-key flag; cmd_auth falls back to SPROUT_PRIVATE_KEY env var.
if let Cmd::Auth = &cli.command {
return commands::auth::cmd_auth(&relay_url, cli.private_key.as_deref()).await;
}
// Pack commands are local-only — no relay connection needed.
if let Cmd::Pack(ref sub) = cli.command {
return match sub {
PackCmd::Validate { path } => commands::pack::cmd_validate(path),
PackCmd::Inspect { path } => commands::pack::cmd_inspect(path),
};
}
// Auth resolution: token > private_key (auto-mint) > pubkey > error
//
// When SPROUT_PRIVATE_KEY is set, auto_mint_token returns (token, keys).
// The keys are retained on the client for signing write operations.
let (auth, retained_keys) = if let Some(token) = cli.token {
(Auth::Bearer(token), None)
} else if let Some(key) = cli.private_key {
let (minted, keys) = client::auto_mint_token(&relay_url, &key).await?;
(Auth::Bearer(minted), Some(keys))
} else if let Some(pk) = cli.pubkey {
(Auth::DevMode(pk), None)
} else {
return Err(CliError::Auth(
"Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY".into(),
));
};
let client = {
let c = SproutClient::new(relay_url, auth)?;
match retained_keys {
Some(k) => c.with_keys(k),
None => c,
}
};
match cli.command {
// ---- Messages ------------------------------------------------------
Cmd::SendMessage {
channel,
content,
kind,
reply_to,
broadcast,
mentions,
} => {
commands::messages::cmd_send_message(
&client,
&channel,
&content,
kind,
reply_to.as_deref(),
broadcast,
&mentions,
)
.await
}
Cmd::SendDiffMessage {
channel,
diff,
repo,
commit,
file,
parent_commit,
source_branch,
target_branch,
pr,
lang,
description,
reply_to,
} => {
commands::messages::cmd_send_diff_message(
&client,
commands::messages::SendDiffParams {
channel_id: channel,
diff,
repo_url: repo,
commit_sha: commit,
file_path: file,
parent_commit_sha: parent_commit,
source_branch,
target_branch,
pr_number: pr,
language: lang,
description,
reply_to,
},
)
.await
}
Cmd::DeleteMessage { event } => {
commands::messages::cmd_delete_message(&client, &event).await
}
Cmd::GetMessages {
channel,
limit,
before,
since,
kinds,
} => {
commands::messages::cmd_get_messages(
&client,
&channel,
limit,
before,
since,
kinds.as_deref(),
)
.await
}
Cmd::GetThread {
channel,
event,
depth_limit,
limit,
cursor,
} => {
commands::messages::cmd_get_thread(
&client,
&channel,
&event,
depth_limit,
limit,
cursor.as_deref(),
)
.await
}
Cmd::Search { query, limit } => {
commands::messages::cmd_search(&client, &query, limit).await
}
Cmd::EditMessage { event, content } => {
commands::messages::cmd_edit_message(&client, &event, &content).await
}
Cmd::VoteOnPost { event, direction } => {
commands::messages::cmd_vote_on_post(&client, &event, &direction).await
}
// ---- Channels ------------------------------------------------------
Cmd::ListChannels { visibility, member } => {
commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member))
.await
}
Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await,
Cmd::CreateChannel {
name,
channel_type,
visibility,
description,
} => {
commands::channels::cmd_create_channel(
&client,
&name,
&channel_type,
&visibility,
description.as_deref(),
)
.await
}
Cmd::UpdateChannel {
channel,
name,
description,
} => {
commands::channels::cmd_update_channel(
&client,
&channel,
name.as_deref(),
description.as_deref(),
)
.await
}
Cmd::SetChannelTopic { channel, topic } => {
commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await
}
Cmd::SetChannelPurpose { channel, purpose } => {
commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await
}
Cmd::JoinChannel { channel } => {
commands::channels::cmd_join_channel(&client, &channel).await
}
Cmd::LeaveChannel { channel } => {
commands::channels::cmd_leave_channel(&client, &channel).await
}
Cmd::ArchiveChannel { channel } => {
commands::channels::cmd_archive_channel(&client, &channel).await
}
Cmd::UnarchiveChannel { channel } => {
commands::channels::cmd_unarchive_channel(&client, &channel).await
}
Cmd::DeleteChannel { channel } => {
commands::channels::cmd_delete_channel(&client, &channel).await
}
Cmd::ListChannelMembers { channel } => {
commands::channels::cmd_list_channel_members(&client, &channel).await
}
Cmd::AddChannelMember {
channel,
pubkey,
role,
} => {
commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref())
.await
}
Cmd::RemoveChannelMember { channel, pubkey } => {
commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await
}
Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await,
Cmd::SetCanvas { channel, content } => {
commands::channels::cmd_set_canvas(&client, &channel, &content).await
}
// ---- Reactions -----------------------------------------------------
Cmd::AddReaction { event, emoji } => {
commands::reactions::cmd_add_reaction(&client, &event, &emoji).await
}
Cmd::RemoveReaction { event, emoji } => {
commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await
}
Cmd::GetReactions { event } => {
commands::reactions::cmd_get_reactions(&client, &event).await
}
// ---- DMs -----------------------------------------------------------
Cmd::ListDms { cursor, limit } => {
commands::dms::cmd_list_dms(&client, cursor.as_deref(), limit).await
}
Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await,
Cmd::AddDmMember { channel, pubkey } => {
commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await
}
// ---- Users ---------------------------------------------------------
Cmd::GetUsers { pubkeys } => commands::users::cmd_get_users(&client, &pubkeys).await,
Cmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
commands::users::cmd_set_profile(
&client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await,
Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await,
Cmd::SetChannelAddPolicy { policy } => {
commands::users::cmd_set_channel_add_policy(&client, &policy).await
}
// ---- Workflows -----------------------------------------------------
Cmd::ListWorkflows { channel } => {
commands::workflows::cmd_list_workflows(&client, &channel).await
}
Cmd::CreateWorkflow { channel, yaml } => {
commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await
}
Cmd::UpdateWorkflow { workflow, yaml } => {
commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await
}
Cmd::DeleteWorkflow { workflow } => {
commands::workflows::cmd_delete_workflow(&client, &workflow).await
}
Cmd::TriggerWorkflow { workflow } => {
commands::workflows::cmd_trigger_workflow(&client, &workflow).await
}
Cmd::GetWorkflowRuns { workflow, limit } => {
commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await
}
Cmd::GetWorkflow { workflow } => {
commands::workflows::cmd_get_workflow(&client, &workflow).await
}
Cmd::ApproveStep {
token,
approved,
note,
} => {
let approved = parse_bool_flag("--approved", &approved)?;
commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await
}
// ---- Feed ----------------------------------------------------------
Cmd::GetFeed {
since,
limit,
types,
} => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
// ---- Social --------------------------------------------------------
Cmd::PublishNote { content, reply_to } => {
commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await
}
Cmd::SetContactList { contacts } => {
commands::social::cmd_set_contact_list(&client, &contacts).await
}
Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await,
Cmd::GetUserNotes {
pubkey,
limit,
before,
before_id,
} => {
commands::social::cmd_get_user_notes(
&client,
&pubkey,
limit,
before,
before_id.as_deref(),
)
.await
}
Cmd::GetContactList { pubkey } => {
commands::social::cmd_get_contact_list(&client, &pubkey).await
}
// ---- Pack (local) --------------------------------------------------
Cmd::Pack(_) => unreachable!("handled above"),
// ---- Auth & Tokens -------------------------------------------------
Cmd::Auth => unreachable!("handled above"),
Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await,
Cmd::DeleteToken { id } => commands::auth::cmd_delete_token(&client, &id).await,
Cmd::DeleteAllTokens => commands::auth::cmd_delete_all_tokens(&client).await,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// Smoke test: CLI definition is valid and parseable.
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
/// Regression: parse_bool_flag rejects values other than "true"/"false".
#[test]
fn parse_bool_flag_accepts_true() {
assert!(super::parse_bool_flag("--approved", "true").unwrap());
}
#[test]
fn parse_bool_flag_accepts_false() {
assert!(!super::parse_bool_flag("--approved", "false").unwrap());
}
#[test]
fn parse_bool_flag_rejects_invalid() {
let err = super::parse_bool_flag("--approved", "maybe").unwrap_err();
match err {
super::CliError::Usage(msg) => {
assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}");
assert!(msg.contains("maybe"), "got: {msg}");
}
other => panic!("expected Usage error, got: {other:?}"),
}
}
#[test]
fn parse_bool_flag_rejects_empty() {
assert!(super::parse_bool_flag("--approved", "").is_err());
}
/// Parity: the CLI exposes exactly the expected 54 commands.
/// If a command is added or removed, this test forces a conscious update.
#[test]
fn command_inventory_is_54() {
let expected: Vec<&str> = vec![
"add-channel-member",
"add-dm-member",
"add-reaction",
"approve-step",
"archive-channel",
"auth",
"create-channel",
"create-workflow",
"delete-all-tokens",
"delete-channel",
"delete-message",
"delete-token",
"delete-workflow",
"edit-message",
"get-canvas",
"get-channel",
"get-contact-list",
"get-event",
"get-feed",
"get-messages",
"get-presence",
"get-reactions",
"get-thread",
"get-user-notes",
"get-users",
"get-workflow",
"get-workflow-runs",
"join-channel",
"leave-channel",
"list-channel-members",
"list-channels",
"list-dms",
"list-tokens",
"list-workflows",
"open-dm",
"pack",
"publish-note",
"remove-channel-member",
"remove-reaction",
"search",
"send-diff-message",
"send-message",
"set-canvas",
"set-channel-add-policy",
"set-channel-purpose",
"set-channel-topic",
"set-contact-list",
"set-presence",
"set-profile",
"trigger-workflow",
"unarchive-channel",
"update-channel",
"update-workflow",
"vote-on-post",
];
let cmd = Cli::command();
let mut actual: Vec<String> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.filter(|n| n != "help") // clap auto-adds "help"
.collect();
actual.sort();
assert_eq!(
actual.len(),
54,
"Expected 54 commands, got {}. Actual: {:?}",
actual.len(),
actual
);
assert_eq!(actual, expected, "Command inventory drift detected");
}
std::process::exit(sprout_cli::run_from_args(std::env::args()).await);
}

View File

@@ -36,6 +36,7 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> {
/// Percent-encode for URL path segments and query parameter values.
/// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
#[cfg(test)]
pub fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {

View File

@@ -96,6 +96,10 @@ impl FromStr for ChannelType {
// ── Member role ──────────────────────────────────────────────────────────────
/// A member's role within a channel.
///
/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
/// Bot is a **separate designation** — it is not part of the linear hierarchy.
/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberRole {
/// Full control — can manage members and delete the channel.
@@ -106,7 +110,7 @@ pub enum MemberRole {
Member,
/// Read-only external participant.
Guest,
/// Automated agent or integration.
/// Automated agent or integration (not in the role hierarchy).
Bot,
}
@@ -126,6 +130,27 @@ impl MemberRole {
pub fn is_elevated(&self) -> bool {
matches!(self, Self::Owner | Self::Admin)
}
/// Numeric permission level for authorization comparisons.
///
/// Higher = more privileged. Bot returns 0 (must use explicit grants).
/// Use `role.permission_level() >= required.permission_level()` for checks.
pub fn permission_level(self) -> u8 {
match self {
Self::Owner => 4,
Self::Admin => 3,
Self::Member => 2,
Self::Guest => 1,
Self::Bot => 0,
}
}
/// Returns true if this role meets or exceeds the required role's permission level.
///
/// Bot never meets any requirement (returns false for all non-Bot requirements).
pub fn has_at_least(self, required: MemberRole) -> bool {
self.permission_level() >= required.permission_level()
}
}
impl fmt::Display for MemberRole {

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,25 @@ pub const KIND_FILE_METADATA: u32 = 1063;
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); author-owned, not channel-scoped.
pub const KIND_LONG_FORM: u32 = 30023;
/// NIP-38: User status (general, music, or custom d-tag).
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
pub const KIND_USER_STATUS: u32 = 30315;
/// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync.
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
/// Content is NIP-44 encrypted to the user's own keypair.
pub const KIND_READ_STATE: u32 = 30078;
/// NIP-42 auth event — never stored (carries bearer tokens).
pub const KIND_AUTH: u32 = 22242;
/// BUD-01: Blossom upload auth (used in upload.rs, not stored).
pub const KIND_BLOSSOM_AUTH: u32 = 24242;
/// NIP-98: HTTP auth event (used in nip98.rs, not stored).
pub const KIND_HTTP_AUTH: u32 = 27235;
// NEW: Sprout command kinds (Pure Nostr plan)
/// Agent metadata + owner reference (replaceable, agent-authored).
pub const KIND_AGENT_PROFILE: u32 = 10100;
// NIP-29 group admin events
/// NIP-29: Add a user to a group.
@@ -48,7 +65,25 @@ pub const KIND_NIP29_JOIN_REQUEST: u32 = 9021;
/// NIP-29: Request to leave a group.
pub const KIND_NIP29_LEAVE_REQUEST: u32 = 9022;
// System / admin (9031–9999)
// NIP-43 relay membership admin commands
/// NIP-43: Add a pubkey to the relay member list.
pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030;
/// NIP-43: Remove a pubkey from the relay member list.
pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031;
/// NIP-43: Change the role of an existing relay member.
pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032;
// NIP-43 relay membership announcement events (relay-signed)
/// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention).
pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534;
/// NIP-43: Member added announcement (relay-signed).
pub const KIND_NIP43_MEMBER_ADDED: u32 = 8000;
/// NIP-43: Member removed announcement (relay-signed).
pub const KIND_NIP43_MEMBER_REMOVED: u32 = 8001;
/// NIP-43: User leave request (user-signed, ephemeral).
pub const KIND_NIP43_LEAVE_REQUEST: u32 = 28936;
// System / admin (9100–9999)
/// V1 used kind:9001 — moved here due to NIP-29 conflict.
pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100;
/// V1 used kind:9010 — moved here for NIP-29 range safety.
@@ -66,6 +101,9 @@ pub const KIND_NIP29_GROUP_MEMBERS: u32 = 39002;
/// NIP-29: Addressable group roles definition.
pub const KIND_NIP29_GROUP_ROLES: u32 = 39003;
/// Workflow definition (parameterized replaceable, d=workflow_uuid).
pub const KIND_WORKFLOW_DEF: u32 = 30620;
/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
/// Upper bound of the NIP-33 parameterized replaceable range (30000–39999).
@@ -83,6 +121,8 @@ pub const KIND_PRESENCE_UPDATE: u32 = 20001;
pub const KIND_PAIRING: u32 = 24134;
/// Ephemeral: typing indicator for a channel.
pub const KIND_TYPING_INDICATOR: u32 = 20002;
/// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame.
pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200;
// Stream messaging
/// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001.
@@ -110,7 +150,21 @@ pub const KIND_CANVAS: u32 = 40100;
/// System message for channel state changes (join, leave, rename, etc.).
pub const KIND_SYSTEM_MESSAGE: u32 = 40099;
// Relay-only enrichment kinds (never client-submitted)
/// Thread summaries, reaction rollups (relay-signed sidecar).
pub const KIND_ENRICHMENT: u32 = 40900;
/// Channel metadata with computed fields (relay-signed sidecar).
pub const KIND_CHANNEL_SUMMARY: u32 = 40901;
/// Bulk presence state (relay-signed sidecar).
pub const KIND_PRESENCE_SNAPSHOT: u32 = 40902;
// Direct messages (41000–41999)
/// Open/create DM (p-tags = participants).
pub const KIND_DM_OPEN: u32 = 41010;
/// Add member to group DM.
pub const KIND_DM_ADD_MEMBER: u32 = 41011;
/// Hide DM from sidebar.
pub const KIND_DM_HIDE: u32 = 41012;
/// A new direct-message conversation was created.
pub const KIND_DM_CREATED: u32 = 41001;
/// A member was added to a DM conversation.
@@ -169,6 +223,12 @@ pub const KIND_FORUM_VOTE: u32 = 45002;
pub const KIND_FORUM_COMMENT: u32 = 45003;
// Workflow engine (46000–46999)
/// Trigger workflow execution.
pub const KIND_WORKFLOW_TRIGGER: u32 = 46020;
/// Grant pending approval.
pub const KIND_APPROVAL_GRANT: u32 = 46030;
/// Deny pending approval.
pub const KIND_APPROVAL_DENY: u32 = 46031;
/// A workflow was triggered by a matching event.
pub const KIND_WORKFLOW_TRIGGERED: u32 = 46001;
/// A workflow step began execution.
@@ -228,6 +288,28 @@ pub const KIND_HUDDLE_GUIDELINES: u32 = 48106;
/// Internal kind for media upload audit entries. Not a relay event kind.
pub const KIND_MEDIA_UPLOAD: u32 = 49001;
// ── NIP-34: Git repository events ────────────────────────────────────────────
/// NIP-34: Repository announcement (parameterized replaceable, d-tag = repo-id).
pub const KIND_GIT_REPO_ANNOUNCEMENT: u32 = 30617;
/// NIP-34: Repository state — current branch/tag refs (parameterized replaceable, d-tag = repo-id).
pub const KIND_GIT_REPO_STATE: u32 = 30618;
/// NIP-34: Patch (git format-patch output).
pub const KIND_GIT_PATCH: u32 = 1617;
/// NIP-34: Pull request.
pub const KIND_GIT_PULL_REQUEST: u32 = 1618;
/// NIP-34: Pull request update (tip commit change).
pub const KIND_GIT_PR_UPDATE: u32 = 1619;
/// NIP-34: Issue.
pub const KIND_GIT_ISSUE: u32 = 1621;
/// NIP-34: Status — Open.
pub const KIND_GIT_STATUS_OPEN: u32 = 1630;
/// NIP-34: Status — Applied / Merged.
pub const KIND_GIT_STATUS_MERGED: u32 = 1631;
/// NIP-34: Status — Closed.
pub const KIND_GIT_STATUS_CLOSED: u32 = 1632;
/// NIP-34: Status — Draft.
pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;
/// All registered kind constants — used for duplicate detection and iteration.
pub const ALL_KINDS: &[u32] = &[
KIND_PROFILE,
@@ -238,6 +320,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_REACTION,
KIND_GIFT_WRAP,
KIND_FILE_METADATA,
KIND_AGENT_PROFILE,
KIND_NIP29_PUT_USER,
KIND_NIP29_REMOVE_USER,
KIND_NIP29_EDIT_METADATA,
@@ -247,6 +330,13 @@ pub const ALL_KINDS: &[u32] = &[
KIND_NIP29_CREATE_INVITE,
KIND_NIP29_JOIN_REQUEST,
KIND_NIP29_LEAVE_REQUEST,
RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_CHANGE_ROLE,
KIND_NIP43_MEMBERSHIP_LIST,
KIND_NIP43_MEMBER_ADDED,
KIND_NIP43_MEMBER_REMOVED,
KIND_NIP43_LEAVE_REQUEST,
KIND_SYSTEM_TIMER_FIRED,
KIND_SYSTEM_SLASH_COMMAND,
KIND_SYSTEM_FLAG,
@@ -256,7 +346,10 @@ pub const ALL_KINDS: &[u32] = &[
KIND_NIP29_GROUP_ROLES,
KIND_PRESENCE_UPDATE,
KIND_TYPING_INDICATOR,
KIND_BLOSSOM_AUTH,
KIND_PAIRING,
KIND_AGENT_OBSERVER_FRAME,
KIND_HTTP_AUTH,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_STREAM_MESSAGE_EDIT,
@@ -267,6 +360,12 @@ pub const ALL_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE_DIFF,
KIND_CANVAS,
KIND_SYSTEM_MESSAGE,
KIND_ENRICHMENT,
KIND_CHANNEL_SUMMARY,
KIND_PRESENCE_SNAPSHOT,
KIND_DM_OPEN,
KIND_DM_ADD_MEMBER,
KIND_DM_HIDE,
KIND_DM_CREATED,
KIND_DM_MEMBER_ADDED,
KIND_DM_MEMBER_REMOVED,
@@ -285,10 +384,16 @@ pub const ALL_KINDS: &[u32] = &[
KIND_SUBSCRIPTION_RESUMED,
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_WORKFLOW_DEF,
KIND_LONG_FORM,
KIND_USER_STATUS,
KIND_READ_STATE,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
KIND_FORUM_COMMENT,
KIND_WORKFLOW_TRIGGER,
KIND_APPROVAL_GRANT,
KIND_APPROVAL_DENY,
KIND_WORKFLOW_TRIGGERED,
KIND_WORKFLOW_STEP_STARTED,
KIND_WORKFLOW_STEP_COMPLETED,
@@ -313,7 +418,18 @@ pub const ALL_KINDS: &[u32] = &[
KIND_HUDDLE_ENDED,
KIND_HUDDLE_TRACK_PUBLISHED,
KIND_HUDDLE_RECORDING_AVAILABLE,
KIND_HUDDLE_GUIDELINES,
KIND_MEDIA_UPLOAD,
KIND_GIT_REPO_ANNOUNCEMENT,
KIND_GIT_REPO_STATE,
KIND_GIT_PATCH,
KIND_GIT_PULL_REQUEST,
KIND_GIT_PR_UPDATE,
KIND_GIT_ISSUE,
KIND_GIT_STATUS_OPEN,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
];
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
@@ -341,6 +457,37 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool {
kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED
}
/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032).
pub const fn is_relay_admin_kind(kind: u32) -> bool {
matches!(
kind,
RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE
)
}
/// Returns `true` if `kind` is a Sprout command kind that requires transactional execution.
pub const fn is_command_kind(kind: u32) -> bool {
matches!(
kind,
KIND_WORKFLOW_DEF
| KIND_DM_OPEN
| KIND_DM_ADD_MEMBER
| KIND_DM_HIDE
| KIND_WORKFLOW_TRIGGER
| KIND_APPROVAL_GRANT
| KIND_APPROVAL_DENY
)
}
/// Returns `true` if `kind` is a relay-only enrichment kind (40900–40902).
/// Client submission of these kinds must be rejected.
pub const fn is_relay_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_ENRICHMENT | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT
)
}
/// Extract the kind from a nostr Event as u32.
/// NIP-01 specifies kind as an unsigned integer; u32 covers the full range.
pub fn event_kind_u32(event: &nostr::Event) -> u32 {
@@ -353,6 +500,20 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
event.kind.as_u16() as i32
}
// Compile-time: new kinds are in the expected ranges.
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
const _: () = assert!(
KIND_GIT_REPO_ANNOUNCEMENT >= PARAM_REPLACEABLE_KIND_MIN
&& KIND_GIT_REPO_ANNOUNCEMENT <= PARAM_REPLACEABLE_KIND_MAX
);
const _: () = assert!(
KIND_GIT_REPO_STATE >= PARAM_REPLACEABLE_KIND_MIN
&& KIND_GIT_REPO_STATE <= PARAM_REPLACEABLE_KIND_MAX
);
// Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind.
const _: () = assert!(KIND_AUTH <= u16::MAX as u32);
const _: () = assert!(KIND_CANVAS <= u16::MAX as u32);

View File

@@ -13,10 +13,14 @@ pub mod error;
pub mod event;
/// NIP-01 subscription filter matching.
pub mod filter;
/// Git permission types — ref patterns, protection rules, policy evaluation.
pub mod git_perms;
/// Sprout kind number registry — custom event type constants.
pub mod kind;
/// Network utilities — SSRF-safe IP classification.
pub mod network;
/// Agent observer frame helpers.
pub mod observer;
/// NIP-AB device pairing — crypto primitives, message types, and errors.
pub mod pairing;
/// Presence status types shared across crates.

View File

@@ -0,0 +1,156 @@
//! Agent observer frame helpers.
//!
//! Observer frames are transient, owner-scoped agent telemetry/control messages.
//! They use a Sprout ephemeral event kind and carry NIP-44 encrypted JSON in the
//! event content so relays can route frames without reading ACP internals.
use nostr::{nips::nip44, Event, Keys, PublicKey};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use zeroize::Zeroize;
/// Tag name that identifies the agent pubkey the observer frame belongs to.
pub const OBSERVER_AGENT_TAG: &str = "agent";
/// Tag name that identifies the cleartext frame direction.
pub const OBSERVER_FRAME_TAG: &str = "frame";
/// Frame value for agent-to-owner observer telemetry.
pub const OBSERVER_FRAME_TELEMETRY: &str = "telemetry";
/// Frame value for owner-to-agent observer control commands.
pub const OBSERVER_FRAME_CONTROL: &str = "control";
/// Minimum plausible NIP-44 v2 ciphertext length.
pub const NIP44_MIN_CONTENT_LEN: usize = 132;
/// Maximum NIP-44 v2 ciphertext length.
pub const NIP44_MAX_CONTENT_LEN: usize = 87_472;
/// Maximum observer plaintext JSON size accepted by helpers.
pub const OBSERVER_MAX_PLAINTEXT_LEN: usize = 65_535;
/// Errors returned by observer payload encryption/decryption helpers.
#[derive(Debug, Error)]
pub enum ObserverPayloadError {
/// NIP-44 encryption or decryption failed.
#[error("NIP-44 error: {0}")]
Nip44(#[from] nip44::Error),
/// JSON serialization or deserialization failed.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Ciphertext did not fit the expected NIP-44 v2 length envelope.
#[error("invalid NIP-44 ciphertext length: {0}")]
InvalidCiphertextLength(usize),
/// Decrypted JSON exceeded the observer plaintext size limit.
#[error("observer plaintext exceeds {max} bytes (got {got})")]
PlaintextTooLarge {
/// Maximum accepted plaintext bytes.
max: usize,
/// Actual plaintext byte count.
got: usize,
},
}
/// Returns true when `content` fits the NIP-44 v2 ciphertext length envelope.
pub fn content_looks_like_nip44(content: &str) -> bool {
(NIP44_MIN_CONTENT_LEN..=NIP44_MAX_CONTENT_LEN).contains(&content.len())
}
/// Serialize and NIP-44 encrypt an observer payload for `recipient`.
pub fn encrypt_observer_payload<T: Serialize>(
sender_keys: &Keys,
recipient: &PublicKey,
payload: &T,
) -> Result<String, ObserverPayloadError> {
let mut plaintext = serde_json::to_string(payload)?;
if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN {
let got = plaintext.len();
plaintext.zeroize();
return Err(ObserverPayloadError::PlaintextTooLarge {
max: OBSERVER_MAX_PLAINTEXT_LEN,
got,
});
}
let encrypted = nip44::encrypt(
sender_keys.secret_key(),
recipient,
&plaintext,
nip44::Version::V2,
)?;
plaintext.zeroize();
Ok(encrypted)
}
/// NIP-44 decrypt and deserialize an observer payload from `event`.
pub fn decrypt_observer_payload<T: DeserializeOwned>(
recipient_keys: &Keys,
event: &Event,
) -> Result<T, ObserverPayloadError> {
if !content_looks_like_nip44(&event.content) {
return Err(ObserverPayloadError::InvalidCiphertextLength(
event.content.len(),
));
}
let mut plaintext = nip44::decrypt(
recipient_keys.secret_key(),
&event.pubkey,
event.content.as_str(),
)?;
if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN {
let got = plaintext.len();
plaintext.zeroize();
return Err(ObserverPayloadError::PlaintextTooLarge {
max: OBSERVER_MAX_PLAINTEXT_LEN,
got,
});
}
let result = serde_json::from_str(&plaintext);
plaintext.zeroize();
Ok(result?)
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Kind, Tag};
#[test]
fn observer_payload_round_trips_with_nip44() {
let sender = Keys::generate();
let recipient = Keys::generate();
let payload = serde_json::json!({
"type": "turn_started",
"turnId": "turn-1"
});
let encrypted = encrypt_observer_payload(&sender, &recipient.public_key(), &payload)
.expect("encrypt payload");
assert!(content_looks_like_nip44(&encrypted));
let event = EventBuilder::new(
Kind::Custom(crate::kind::KIND_AGENT_OBSERVER_FRAME as u16),
encrypted,
[Tag::public_key(recipient.public_key())],
)
.sign_with_keys(&sender)
.expect("sign event");
let decrypted: serde_json::Value =
decrypt_observer_payload(&recipient, &event).expect("decrypt payload");
assert_eq!(decrypted, payload);
}
#[test]
fn observer_payload_rejects_short_ciphertext() {
let sender = Keys::generate();
let recipient = Keys::generate();
let event = EventBuilder::new(
Kind::Custom(crate::kind::KIND_AGENT_OBSERVER_FRAME as u16),
"not encrypted",
[Tag::public_key(recipient.public_key())],
)
.sign_with_keys(&sender)
.expect("sign event");
assert!(matches!(
decrypt_observer_payload::<serde_json::Value>(&recipient, &event),
Err(ObserverPayloadError::InvalidCiphertextLength(_))
));
}
}

View File

@@ -37,6 +37,9 @@ pub struct EventQuery {
/// Restrict to events with this exact `d_tag` value (NIP-33).
/// Pushed into SQL via the `idx_events_parameterized` index.
pub d_tag: Option<String>,
/// Restrict to events with any of these `d_tag` values (multi-value NIP-33 pushdown).
/// Used when a filter has multiple `#d` values and targets only NIP-33 kinds.
pub d_tags: Option<Vec<String>>,
/// Composite keyset cursor: exclude events at or "after" this (created_at, id) pair.
/// Used with `until` for stable pagination: events where
/// `created_at < until OR (created_at = until AND id > before_id)`.
@@ -48,6 +51,20 @@ pub struct EventQuery {
/// invariant (`is_global_only_kind`) ever changes.
/// Mutually exclusive with `channel_id`.
pub global_only: bool,
/// Restrict results to events from any of these pubkeys (multi-author `IN` pushdown).
pub authors: Option<Vec<Vec<u8>>>,
/// Restrict results to events with any of these IDs (multi-id `IN` pushdown).
pub ids: Option<Vec<Vec<u8>>>,
/// Restrict results to events with an `e` tag referencing any of these event IDs (hex).
/// Uses JSONB containment (`tags @> ...`) against the `tags` column.
pub e_tags: Option<Vec<String>>,
/// Restrict results to events in any of these channels (multi-channel `IN` pushdown).
/// Used by NIP-45 COUNT to enforce channel access without fetching all rows.
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// Override the default limit clamp (1000). Used by COUNT fallback path
/// which needs to fetch all matching events for post-filter counting.
/// When None, the default clamp of 1000 applies.
pub max_limit: Option<i64>,
}
/// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers;
@@ -155,12 +172,22 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
));
}
// kinds:[] means "match no kinds" — return empty immediately.
// Empty list means "match nothing" — return empty immediately.
if q.kinds.as_deref().is_some_and(|k| k.is_empty()) {
return Ok(vec![]);
}
if q.authors.as_deref().is_some_and(|a| a.is_empty()) {
return Ok(vec![]);
}
if q.ids.as_deref().is_some_and(|i| i.is_empty()) {
return Ok(vec![]);
}
if q.e_tags.as_deref().is_some_and(|e| e.is_empty()) {
return Ok(vec![]);
}
let limit_val = q.limit.unwrap_or(100).min(1000);
let clamp = q.max_limit.unwrap_or(1000);
let limit_val = q.limit.unwrap_or(100).min(clamp);
let offset_val = q.offset.unwrap_or(0);
let mut qb: QueryBuilder<sqlx::Postgres> = if let Some(ref p_hex) = q.p_tag_hex {
@@ -191,6 +218,28 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
}
// Multi-channel IN pushdown: restrict to events in any of these channels
// OR global events (channel_id IS NULL). Used by NIP-45 COUNT to enforce
// channel access at the SQL level without fetching all rows.
//
// SECURITY: Some(empty vec) means "user has access to NO channels" —
// only global events (channel_id IS NULL) should be returned.
if let Some(ref ch_ids) = q.channel_ids {
if ch_ids.is_empty() {
// No channel access — only global (non-channel) events visible.
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
} else {
qb.push(format!(
" AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
));
let mut sep = qb.separated(", ");
for ch in ch_ids {
sep.push_bind(*ch);
}
qb.push("))");
}
}
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
qb.push(format!(" AND {col_prefix}kind IN ("));
let mut sep = qb.separated(", ");
@@ -204,6 +253,50 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
qb.push(format!(" AND {col_prefix}pubkey = "))
.push_bind(pk.clone());
}
// Multi-author IN pushdown (mutually exclusive with single pubkey in practice).
if let Some(ref authors) = q.authors {
if !authors.is_empty() {
qb.push(format!(" AND {col_prefix}pubkey IN ("));
let mut sep = qb.separated(", ");
for a in authors {
sep.push_bind(a.clone());
}
qb.push(")");
}
}
// Multi-id IN pushdown.
if let Some(ref ids) = q.ids {
if !ids.is_empty() {
qb.push(format!(" AND {col_prefix}id IN ("));
let mut sep = qb.separated(", ");
for id in ids {
sep.push_bind(id.clone());
}
qb.push(")");
}
}
// e-tag pushdown via JSONB containment: tags @> '[["e","<hex>"]]'.
// Multiple e-tags use OR (any match). No GIN index yet — acceptable at
// current scale; add `CREATE INDEX ... USING gin(tags)` if this becomes hot.
if let Some(ref e_tags) = q.e_tags {
if !e_tags.is_empty() {
qb.push(" AND (");
for (i, hex_id) in e_tags.iter().enumerate() {
if i > 0 {
qb.push(" OR ");
}
// Build the JSONB literal: [["e","<hex>"]]
let containment = serde_json::json!([["e", hex_id]]);
qb.push(format!("{col_prefix}tags @> "));
qb.push_bind(containment);
}
qb.push(")");
}
}
if let Some(s) = q.since {
qb.push(format!(" AND {col_prefix}created_at >= "))
.push_bind(s);
@@ -229,6 +322,15 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
if let Some(ref d) = q.d_tag {
qb.push(format!(" AND {col_prefix}d_tag = "))
.push_bind(d.clone());
} else if let Some(ref ds) = q.d_tags {
if !ds.is_empty() {
qb.push(format!(" AND {col_prefix}d_tag IN ("));
let mut sep = qb.separated(", ");
for d in ds {
sep.push_bind(d.clone());
}
qb.push(")");
}
}
// Composite ordering for deterministic pagination across ALL callers of
@@ -297,6 +399,142 @@ pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result<Option<S
)))
}
/// Count events matching the given query parameters (NIP-45 COUNT support).
///
/// Uses the same filter logic as `query_events` but returns only the count.
pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result<i64> {
// Empty list means "match nothing" — return 0 immediately.
if q.kinds.as_deref().is_some_and(|k| k.is_empty()) {
return Ok(0);
}
if q.authors.as_deref().is_some_and(|a| a.is_empty()) {
return Ok(0);
}
if q.ids.as_deref().is_some_and(|i| i.is_empty()) {
return Ok(0);
}
if q.e_tags.as_deref().is_some_and(|e| e.is_empty()) {
return Ok(0);
}
let mut qb: QueryBuilder<sqlx::Postgres> = if let Some(ref p_hex) = q.p_tag_hex {
let mut b = QueryBuilder::new(
"SELECT COUNT(*) as cnt FROM events e \
INNER JOIN event_mentions m ON e.id = m.event_id \
WHERE e.deleted_at IS NULL AND m.pubkey_hex = ",
);
b.push_bind(p_hex.to_ascii_lowercase());
b
} else {
QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE deleted_at IS NULL")
};
let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" };
if let Some(ch) = q.channel_id {
qb.push(format!(" AND {col_prefix}channel_id = "))
.push_bind(ch);
} else if q.global_only {
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
}
// Multi-channel IN pushdown for COUNT: restrict to accessible channels + global.
// SECURITY: Some(empty vec) = no channel access → global events only.
if let Some(ref ch_ids) = q.channel_ids {
if ch_ids.is_empty() {
qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
} else {
qb.push(format!(
" AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
));
let mut sep = qb.separated(", ");
for ch in ch_ids {
sep.push_bind(*ch);
}
qb.push("))");
}
}
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
qb.push(format!(" AND {col_prefix}kind IN ("));
let mut sep = qb.separated(", ");
for k in ks {
sep.push_bind(*k);
}
qb.push(")");
}
if let Some(ref pk) = q.pubkey {
qb.push(format!(" AND {col_prefix}pubkey = "))
.push_bind(pk.clone());
}
if let Some(ref authors) = q.authors {
if !authors.is_empty() {
qb.push(format!(" AND {col_prefix}pubkey IN ("));
let mut sep = qb.separated(", ");
for a in authors {
sep.push_bind(a.clone());
}
qb.push(")");
}
}
if let Some(ref ids) = q.ids {
if !ids.is_empty() {
qb.push(format!(" AND {col_prefix}id IN ("));
let mut sep = qb.separated(", ");
for id in ids {
sep.push_bind(id.clone());
}
qb.push(")");
}
}
if let Some(ref e_tags) = q.e_tags {
if !e_tags.is_empty() {
qb.push(" AND (");
for (i, hex_id) in e_tags.iter().enumerate() {
if i > 0 {
qb.push(" OR ");
}
let containment = serde_json::json!([["e", hex_id]]);
qb.push(format!("{col_prefix}tags @> "));
qb.push_bind(containment);
}
qb.push(")");
}
}
if let Some(s) = q.since {
qb.push(format!(" AND {col_prefix}created_at >= "))
.push_bind(s);
}
if let Some(u) = q.until {
qb.push(format!(" AND {col_prefix}created_at <= "))
.push_bind(u);
}
if let Some(ref d) = q.d_tag {
qb.push(format!(" AND {col_prefix}d_tag = "))
.push_bind(d.clone());
} else if let Some(ref ds) = q.d_tags {
if !ds.is_empty() {
qb.push(format!(" AND {col_prefix}d_tag IN ("));
let mut sep = qb.separated(", ");
for d in ds {
sep.push_bind(d.clone());
}
qb.push(")");
}
}
let row = qb.build().fetch_one(pool).await?;
let cnt: i64 = row.try_get("cnt")?;
Ok(cnt)
}
/// Soft-delete an event by setting `deleted_at = NOW()`.
///
/// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted

View File

@@ -28,6 +28,7 @@
pub const FEED_MAX_LIMIT: i64 = 100;
use chrono::{DateTime, Utc};
use sqlx::postgres::PgRow;
use sqlx::{PgPool, QueryBuilder};
use uuid::Uuid;
@@ -41,6 +42,39 @@ use sprout_core::StoredEvent;
use crate::error::Result;
use crate::event::row_to_stored_event;
/// Column list shared by every feed subquery that aliases the `events` table as `e`.
const EVENT_COLS: &str =
"e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.received_at, e.channel_id";
/// Column list for queries that select directly from `events` (no table alias).
const EVENT_COLS_UNALIASED: &str =
"id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id";
/// Append `AND <col> IN ($1, $2, …)` for the given channel IDs.
///
/// No-ops when the slice is empty so callers don't need a guard.
fn push_channel_id_filter(qb: &mut QueryBuilder<sqlx::Postgres>, col: &str, ids: &[Uuid]) {
if !ids.is_empty() {
qb.push(format!(" AND {col} IN ("));
let mut sep = qb.separated(", ");
for id in ids {
sep.push_bind(*id);
}
qb.push(")");
}
}
/// Convert fetched rows into `Vec<StoredEvent>`, skipping any that fail conversion.
fn collect_stored_events(rows: Vec<PgRow>) -> Result<Vec<StoredEvent>> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if let Some(ev) = row_to_stored_event(row)? {
out.push(ev);
}
}
Ok(out)
}
/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags).
///
/// Joins against the `event_mentions` table -- Phase 2 implementation.
@@ -58,44 +92,26 @@ pub async fn query_mentions(
let limit = limit.min(FEED_MAX_LIMIT);
let pubkey_hex = hex::encode(pubkey_bytes);
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \
e.received_at, e.channel_id \
FROM events e \
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
"SELECT {EVENT_COLS} FROM events e \
INNER JOIN event_mentions m ON e.id = m.event_id \
WHERE m.pubkey_hex = ",
);
WHERE m.pubkey_hex = "
));
qb.push_bind(&pubkey_hex);
qb.push(" AND e.deleted_at IS NULL");
qb.push(format!(
" AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})"
" AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \
{KIND_FORUM_POST}, {KIND_FORUM_COMMENT})"
));
if !accessible_channel_ids.is_empty() {
qb.push(" AND e.channel_id IN (");
let mut sep = qb.separated(", ");
for id in accessible_channel_ids {
sep.push_bind(*id);
}
qb.push(")");
}
push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids);
if let Some(s) = since {
qb.push(" AND m.event_created_at >= ").push_bind(s);
}
qb.push(" ORDER BY m.event_created_at DESC LIMIT ")
.push_bind(limit);
let rows = qb.build().fetch_all(pool).await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if let Some(ev) = row_to_stored_event(row)? {
out.push(ev);
}
}
Ok(out)
collect_stored_events(rows)
}
/// Find events that require action from the given pubkey:
@@ -117,44 +133,25 @@ pub async fn query_needs_action(
let limit = limit.min(FEED_MAX_LIMIT);
let pubkey_hex = hex::encode(pubkey_bytes);
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \
e.received_at, e.channel_id \
FROM events e \
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
"SELECT {EVENT_COLS} FROM events e \
INNER JOIN event_mentions m ON e.id = m.event_id \
WHERE m.pubkey_hex = ",
);
WHERE m.pubkey_hex = "
));
qb.push_bind(&pubkey_hex);
qb.push(" AND e.deleted_at IS NULL");
qb.push(format!(
" AND e.kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})"
));
if !accessible_channel_ids.is_empty() {
qb.push(" AND e.channel_id IN (");
let mut sep = qb.separated(", ");
for id in accessible_channel_ids {
sep.push_bind(*id);
}
qb.push(")");
}
push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids);
if let Some(s) = since {
qb.push(" AND m.event_created_at >= ").push_bind(s);
}
qb.push(" ORDER BY m.event_created_at DESC LIMIT ")
.push_bind(limit);
let rows = qb.build().fetch_all(pool).await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if let Some(ev) = row_to_stored_event(row)? {
out.push(ev);
}
}
Ok(out)
collect_stored_events(rows)
}
/// Find recent activity across accessible channels (for watched topics / agent activity).
@@ -170,40 +167,21 @@ pub async fn query_activity(
limit: i64,
) -> Result<Vec<StoredEvent>> {
let limit = limit.min(FEED_MAX_LIMIT);
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE 1=1",
);
qb.push(" AND deleted_at IS NULL");
qb.push(format!(
" AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})"
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
"SELECT {EVENT_COLS_UNALIASED} FROM events WHERE deleted_at IS NULL"
));
if !accessible_channel_ids.is_empty() {
qb.push(" AND channel_id IN (");
let mut sep = qb.separated(", ");
for id in accessible_channel_ids {
sep.push_bind(*id);
}
qb.push(")");
}
qb.push(format!(
" AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, \
{KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})"
));
push_channel_id_filter(&mut qb, "channel_id", accessible_channel_ids);
if let Some(s) = since {
qb.push(" AND created_at >= ").push_bind(s);
}
qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit);
let rows = qb.build().fetch_all(pool).await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if let Some(ev) = row_to_stored_event(row)? {
out.push(ev);
}
}
Ok(out)
collect_stored_events(rows)
}
// -- Tests --------------------------------------------------------------------

View File

@@ -25,6 +25,8 @@ pub mod feed;
pub mod partition;
/// Reaction persistence.
pub mod reaction;
/// Relay-level membership persistence (NIP-43).
pub mod relay_members;
/// Thread metadata persistence.
pub mod thread;
/// User profile persistence.
@@ -197,6 +199,14 @@ impl Db {
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()
}
/// Begin a database transaction for atomic multi-statement operations.
///
/// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally.
/// The transaction holds an owned pool handle, not a borrow.
pub async fn begin_transaction(&self) -> Result<sqlx::Transaction<'static, sqlx::Postgres>> {
self.pool.begin().await.map_err(Into::into)
}
// ── Events ───────────────────────────────────────────────────────────────
/// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate.
@@ -219,6 +229,11 @@ impl Db {
event::query_events(&self.pool, q).await
}
/// Count events matching the given query (NIP-45 COUNT support).
pub async fn count_events(&self, q: &EventQuery) -> Result<i64> {
event::count_events(&self.pool, q).await
}
/// Fetch the latest replaceable event for a (kind, pubkey) pair.
///
/// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`.
@@ -1123,6 +1138,16 @@ impl Db {
workflow::delete_workflow(&self.pool, id).await
}
/// Find a workflow by owner pubkey and name. Used for NIP-09 a-tag deletion
/// where the d-tag is the workflow name (not UUID).
pub async fn find_workflow_by_owner_and_name(
&self,
owner_pubkey: &[u8],
name: &str,
) -> Result<Option<workflow::WorkflowRecord>> {
workflow::find_by_owner_and_name(&self.pool, owner_pubkey, name).await
}
/// Create a new workflow run.
pub async fn create_workflow_run(
&self,
@@ -1311,6 +1336,75 @@ impl Db {
Ok(out)
}
// ── Relay Members (NIP-43) ───────────────────────────────────────────────
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
pub async fn is_relay_member(&self, pubkey: &str) -> Result<bool> {
relay_members::is_relay_member(&self.pool, pubkey).await
}
/// Returns the relay member record for `pubkey`, or `None` if not found.
pub async fn get_relay_member(
&self,
pubkey: &str,
) -> Result<Option<relay_members::RelayMember>> {
relay_members::get_relay_member(&self.pool, pubkey).await
}
/// Returns all relay members ordered by `created_at` ascending.
pub async fn list_relay_members(&self) -> Result<Vec<relay_members::RelayMember>> {
relay_members::list_relay_members(&self.pool).await
}
/// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent).
/// Adds a new relay member.
///
/// Returns `true` if the row was actually inserted, `false` if the pubkey
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
pub async fn add_relay_member(
&self,
pubkey: &str,
role: &str,
added_by: Option<&str>,
) -> Result<bool> {
relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await
}
/// Removes a relay member atomically, refusing to delete the owner.
pub async fn remove_relay_member(&self, pubkey: &str) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member(&self.pool, pubkey).await
}
/// Removes a relay member only if their current role matches `expected_role`.
///
/// Atomic conditional delete — eliminates the TOCTOU race between a
/// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`].
pub async fn remove_relay_member_if_role(
&self,
pubkey: &str,
expected_role: &str,
) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await
}
/// Updates the role of an existing relay member. Returns `true` if updated.
pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result<bool> {
relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await
}
/// Ensures the owner pubkey exists with role `"owner"`. Called at startup.
pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> {
relay_members::bootstrap_owner(&self.pool, owner_pubkey).await
}
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
///
/// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows
/// inserted, or 0 if the `pubkey_allowlist` table doesn't exist.
pub async fn backfill_from_allowlist(&self) -> Result<u64> {
relay_members::backfill_from_allowlist(&self.pool).await
}
// ── Discovery events ─────────────────────────────────────────────────────
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.

View File

@@ -0,0 +1,283 @@
//! Relay-level membership persistence (NIP-43).
//!
//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata.
//! All pubkey values are 64-char lowercase hex strings.
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row as _};
use crate::error::Result;
/// A single relay member record.
#[derive(Debug, Clone)]
pub struct RelayMember {
/// 64-char lowercase hex pubkey.
pub pubkey: String,
/// Role: `"owner"`, `"admin"`, or `"member"`.
pub role: String,
/// Hex pubkey of who added this member, or `None` for bootstrap entries.
pub added_by: Option<String>,
/// When the member was added.
pub created_at: DateTime<Utc>,
/// When the record was last updated.
pub updated_at: DateTime<Utc>,
}
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
.bind(pubkey)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Returns the relay member record for `pubkey`, or `None` if not found.
pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<RelayMember>> {
let row = sqlx::query(
"SELECT pubkey, role, added_by, created_at, updated_at \
FROM relay_members WHERE pubkey = $1",
)
.bind(pubkey)
.fetch_optional(pool)
.await?;
row.map(|r| -> std::result::Result<RelayMember, sqlx::Error> {
Ok(RelayMember {
pubkey: r.try_get("pubkey")?,
role: r.try_get("role")?,
added_by: r.try_get("added_by")?,
created_at: r.try_get("created_at")?,
updated_at: r.try_get("updated_at")?,
})
})
.transpose()
.map_err(crate::error::DbError::from)
}
/// Returns all relay members ordered by `created_at` ascending.
pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
let rows = sqlx::query(
"SELECT pubkey, role, added_by, created_at, updated_at \
FROM relay_members ORDER BY created_at ASC",
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(|r| -> std::result::Result<RelayMember, sqlx::Error> {
Ok(RelayMember {
pubkey: r.try_get("pubkey")?,
role: r.try_get("role")?,
added_by: r.try_get("added_by")?,
created_at: r.try_get("created_at")?,
updated_at: r.try_get("updated_at")?,
})
})
.collect::<std::result::Result<Vec<_>, sqlx::Error>>()
.map_err(crate::error::DbError::from)
}
/// Adds a new relay member.
///
/// Returns `true` if the row was actually inserted, `false` if the pubkey
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
pub async fn add_relay_member(
pool: &PgPool,
pubkey: &str,
role: &str,
added_by: Option<&str>,
) -> Result<bool> {
let result = sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by) \
VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING",
)
.bind(pubkey)
.bind(role)
.bind(added_by)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// The result of a relay member removal attempt.
#[derive(Debug, PartialEq)]
pub enum RemoveResult {
/// Member was successfully removed.
Removed,
/// The pubkey belongs to the relay owner — removal is forbidden.
IsOwner,
/// No member with the given pubkey exists.
NotFound,
/// The member exists but their role doesn't match the expected role.
RoleMismatch,
}
/// Removes a relay member atomically, refusing to delete the owner.
///
/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the
/// owner-protection check and the deletion are one atomic operation —
/// no TOCTOU race between a separate read and delete.
pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveResult> {
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'")
.bind(pubkey)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
return Ok(RemoveResult::Removed);
}
// rows_affected == 0: either not found or is owner. One cheap read to
// distinguish the two cases so callers can return the right error message.
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
.bind(pubkey)
.fetch_optional(pool)
.await?;
if exists.is_some() {
Ok(RemoveResult::IsOwner)
} else {
Ok(RemoveResult::NotFound)
}
}
/// Removes a relay member only if their current role matches `expected_role`.
///
/// The delete and the role check are collapsed into a single
/// `DELETE … WHERE pubkey = $1 AND role = $2`, making the operation atomic —
/// no TOCTOU race between a prior read and this delete.
///
/// Returns:
/// - `Removed` — row was deleted.
/// - `NotFound` — no member with that pubkey exists.
/// - `IsOwner` — member exists with role `"owner"` (cannot be removed).
/// - `RoleMismatch` — member exists but their role no longer matches
/// `expected_role` (e.g., they were promoted between the caller's read and
/// this delete).
pub async fn remove_relay_member_if_role(
pool: &PgPool,
pubkey: &str,
expected_role: &str,
) -> Result<RemoveResult> {
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2")
.bind(pubkey)
.bind(expected_role)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
return Ok(RemoveResult::Removed);
}
// rows_affected == 0: either not found or role changed. One cheap read to
// distinguish the cases so callers can return the right error message.
let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1")
.bind(pubkey)
.fetch_optional(pool)
.await?;
match row {
None => Ok(RemoveResult::NotFound),
Some(r) => {
let role: String = r.try_get("role")?;
if role == "owner" {
Ok(RemoveResult::IsOwner)
} else {
// Role changed between the caller's check and this delete
// (e.g., target was promoted to admin). Signal that the
// caller no longer has authority to remove this target.
Ok(RemoveResult::RoleMismatch)
}
}
}
}
/// Updates the role of an existing relay member. Returns `true` if updated.
pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result<bool> {
let result = sqlx::query(
"UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'",
)
.bind(new_role)
.bind(pubkey)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes
/// any other owners to `"admin"`. This handles owner rotation: if
/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted.
///
/// Runs in a single transaction. Safe to call at every startup — idempotent.
pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
let pubkey = owner_pubkey.to_ascii_lowercase();
let mut tx = pool.begin().await?;
// 1. Upsert the configured owner.
sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by) \
VALUES ($1, 'owner', NULL) \
ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
)
.bind(&pubkey)
.execute(&mut *tx)
.await?;
// 2. Demote any other owners to admin.
sqlx::query(
"UPDATE relay_members SET role = 'admin', updated_at = now() \
WHERE role = 'owner' AND pubkey <> $1",
)
.bind(&pubkey)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
///
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members.
/// Returns the number of rows inserted, or 0 if:
/// - the `pubkey_allowlist` table doesn't exist, or
/// - `relay_members` already has rows (migration ran in a prior startup).
///
/// The empty-table guard prevents re-adding members that were intentionally
/// removed by an admin after the initial backfill.
pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
// Check if pubkey_allowlist table exists.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')",
)
.fetch_one(pool)
.await?;
if !exists {
return Ok(0);
}
// Only backfill if relay_members is empty — once the table has rows
// (from a previous backfill or manual admin commands), we must not
// re-add members that were intentionally removed.
let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)")
.fetch_one(pool)
.await?;
if has_members {
return Ok(0);
}
let result = sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by, created_at) \
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \
FROM pubkey_allowlist \
ON CONFLICT (pubkey) DO NOTHING",
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}

View File

@@ -823,6 +823,32 @@ fn row_to_approval_record(row: sqlx::postgres::PgRow) -> Result<ApprovalRecord>
})
}
/// Find a workflow by owner pubkey and name. Returns the first match (active or not).
pub async fn find_by_owner_and_name(
pool: &PgPool,
owner_pubkey: &[u8],
name: &str,
) -> Result<Option<WorkflowRecord>> {
let row = sqlx::query(
r#"
SELECT id, name, owner_pubkey, channel_id, definition, definition_hash,
status::text AS status, enabled, created_at, updated_at
FROM workflows
WHERE owner_pubkey = $1 AND name = $2
LIMIT 1
"#,
)
.bind(owner_pubkey)
.bind(name)
.fetch_optional(pool)
.await?;
match row {
Some(r) => Ok(Some(row_to_workflow_record(r)?)),
None => Ok(None),
}
}
// -- Tests --------------------------------------------------------------------
#[cfg(test)]

View File

@@ -0,0 +1,27 @@
[package]
name = "sprout-dev-mcp"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
name = "sprout-dev-mcp"
path = "src/main.rs"
[dependencies]
sprout-cli = { path = "../sprout-cli" }
tokio = { workspace = true }
tokio-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rmcp = { workspace = true }
schemars = { workspace = true }
similar = "2"
tempfile = "3"
ignore = "0.4.25"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal", "process"] }

View File

@@ -0,0 +1,146 @@
#![forbid(unsafe_code)]
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
ErrorData, ServerHandler, ServiceExt,
};
use std::path::Path;
use std::sync::Arc;
mod rg;
mod shell;
mod shim;
mod str_replace;
mod todo;
mod tree;
#[derive(Clone)]
struct DevMcp {
state: Arc<shell::SharedState>,
todos: Arc<todo::TodoState>,
tool_router: ToolRouter<DevMcp>,
}
#[tool_router]
impl DevMcp {
fn new(state: Arc<shell::SharedState>) -> Self {
Self {
state,
todos: Arc::new(todo::TodoState::new()),
tool_router: Self::tool_router(),
}
}
#[tool(
name = "shell",
description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and sprout (Sprout relay CLI — run sprout --help for commands)."
)]
async fn shell(
&self,
Parameters(p): Parameters<shell::ShellParams>,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, ErrorData> {
shell::run(&self.state, p, context.ct).await
}
#[tool(
name = "str_replace",
description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
)]
async fn str_replace(
&self,
Parameters(p): Parameters<str_replace::StrReplaceParams>,
) -> Result<String, ErrorData> {
str_replace::run(&self.state, p)
}
#[tool(
name = "todo",
description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open."
)]
async fn todo(
&self,
Parameters(p): Parameters<todo::TodoParams>,
) -> Result<CallToolResult, ErrorData> {
match self.todos.handle_todo(p) {
Ok(text) => todo::text_result(text),
Err(e) => todo::error_result(format!("Error: {e}")),
}
}
/// Hook: called by the agent before honoring end_turn. Returns
/// non-empty objection text iff items remain open.
#[tool(
name = "_Stop",
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
)]
async fn stop_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.stop_objection())
}
/// Hook: called by the agent after context compaction/handoff so the
/// todo list survives history truncation.
#[tool(
name = "_PostCompact",
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
)]
async fn post_compact_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.post_compact())
}
}
#[tool_handler]
impl ServerHandler for DevMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"sprout-dev-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(self.state.bootstrap_instructions.clone())
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = Path::new(&argv0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if cmd == "rg" {
let args: Vec<String> = std::env::args().skip(1).collect();
std::process::exit(rg::run(args));
}
if cmd == "tree" {
let args: Vec<String> = std::env::args().skip(1).collect();
std::process::exit(tree::run(args));
}
if cmd == "sprout" {
std::process::exit(sprout_cli::run_from_args(std::env::args()).await);
}
let cwd = std::env::current_dir()?;
let shim = shim::Shim::install()?;
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
let service = DevMcp::new(state).serve(stdio()).await?;
service.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,492 @@
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
const MAX_LINE_BYTES: usize = 1024 * 1024; // 1MB per line — skip files with longer lines (likely binary)
const MAX_OUTPUT_BYTES: usize = 50 * 1024;
const MAX_OUTPUT_LINES: usize = 2000;
const MAX_CONTEXT: usize = 100;
const MAX_WALK_DEPTH: usize = 50;
pub fn run(args: Vec<String>) -> i32 {
if let Some(code) = try_system_rg(&args) {
return code;
}
fallback(args)
}
fn try_system_rg(args: &[String]) -> Option<i32> {
let self_exe = std::env::current_exe().ok()?;
let self_canon = std::fs::canonicalize(&self_exe).ok()?;
let cleaned_path = clean_path(&self_canon);
let candidate = which_rg(&cleaned_path)?;
let status = Command::new(&candidate)
.args(args)
.env("PATH", &cleaned_path)
.status()
.ok()?;
Some(status.code().unwrap_or(2))
}
fn clean_path(self_canon: &Path) -> String {
let original = std::env::var("PATH").unwrap_or_default();
original
.split(':')
.filter(|dir| {
if dir.is_empty() {
return false;
}
let candidate = Path::new(dir).join("rg");
match std::fs::canonicalize(&candidate) {
Ok(c) => c != *self_canon,
Err(_) => true,
}
})
.collect::<Vec<_>>()
.join(":")
}
fn which_rg(path: &str) -> Option<PathBuf> {
for dir in path.split(':') {
if dir.is_empty() {
continue;
}
let candidate = Path::new(dir).join("rg");
if candidate.is_file() && is_executable(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
true
}
#[cfg_attr(test, derive(Debug))]
struct RgArgs {
pattern: Option<String>,
paths: Vec<PathBuf>,
line_numbers: bool,
ignore_case: bool,
files_only: bool,
list_files_with_matches: bool,
context: usize,
glob: Option<String>,
}
fn parse(args: Vec<String>) -> Result<RgArgs, String> {
let mut out = RgArgs {
pattern: None,
paths: Vec::new(),
line_numbers: false,
ignore_case: false,
files_only: false,
list_files_with_matches: false,
context: 0,
glob: None,
};
let mut iter = args.into_iter();
let mut positional: Vec<String> = Vec::new();
while let Some(a) = iter.next() {
match a.as_str() {
"--files" => out.files_only = true,
"-n" | "--line-number" => out.line_numbers = true,
"-i" | "--ignore-case" => out.ignore_case = true,
"-l" | "--files-with-matches" => out.list_files_with_matches = true,
"-C" | "--context" => {
let n = iter.next().ok_or("missing value for -C")?;
let parsed: usize = n.parse().map_err(|_| format!("bad -C value: {n}"))?;
out.context = parsed.min(MAX_CONTEXT);
}
"-g" | "--glob" => {
out.glob = Some(iter.next().ok_or("missing value for -g")?);
}
"--" => positional.extend(iter.by_ref()),
s if s.starts_with('-') && s.len() > 1 => {
return Err(format!("unsupported flag (fallback rg): {s}"));
}
_ => positional.push(a),
}
}
if out.files_only {
out.paths = positional.into_iter().map(PathBuf::from).collect();
if out.paths.is_empty() {
out.paths.push(PathBuf::from("."));
}
} else {
let mut it = positional.into_iter();
out.pattern = it.next();
out.paths = it.map(PathBuf::from).collect();
if out.paths.is_empty() {
out.paths.push(PathBuf::from("."));
}
}
Ok(out)
}
struct CappedSink {
bytes: usize,
lines: usize,
capped: bool,
}
impl CappedSink {
fn new() -> Self {
Self {
bytes: 0,
lines: 0,
capped: false,
}
}
fn writeln(&mut self, s: &str) {
if self.capped {
return;
}
let next = self.bytes.saturating_add(s.len()).saturating_add(1);
if next > MAX_OUTPUT_BYTES || self.lines >= MAX_OUTPUT_LINES {
self.capped = true;
tracing::warn!("rg (fallback): output capped at {MAX_OUTPUT_BYTES} bytes / {MAX_OUTPUT_LINES} lines");
return;
}
println!("{s}");
self.bytes = next;
self.lines += 1;
}
}
fn fallback(args: Vec<String>) -> i32 {
let opts = match parse(args) {
Ok(o) => o,
Err(e) => {
tracing::error!("rg (fallback): {e}");
return 2;
}
};
let mut sink = CappedSink::new();
let mut found = false;
let mut printed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
if opts.files_only {
for root in &opts.paths {
walk(root, &opts, &mut |p| {
sink.writeln(&p.display().to_string());
found = true;
!sink.capped
});
if sink.capped {
break;
}
}
return if found { 0 } else { 1 };
}
let pattern = match &opts.pattern {
Some(p) => p.clone(),
None => {
tracing::error!("rg (fallback): missing PATTERN");
return 2;
}
};
let needle = if opts.ignore_case {
pattern.to_lowercase()
} else {
pattern
};
for root in &opts.paths {
walk(root, &opts, &mut |path| {
if sink.capped {
return false;
}
if scan_file(path, &needle, &opts, &mut sink, &mut printed) {
found = true;
}
!sink.capped
});
if sink.capped {
break;
}
}
if found {
0
} else {
1
}
}
fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> Option<Result<String, ()>> {
let mut buf = Vec::new();
loop {
let available = match reader.fill_buf() {
Ok([]) => {
if buf.is_empty() {
return None;
}
return match String::from_utf8(buf) {
Ok(s) => Some(Ok(s)),
Err(_) => Some(Err(())),
};
}
Ok(b) => b,
Err(_) => return None,
};
let take = available
.iter()
.position(|b| *b == b'\n')
.map_or(available.len(), |i| i + 1);
if buf.len() + take > max {
return Some(Err(()));
}
buf.extend_from_slice(&available[..take]);
reader.consume(take);
if buf.ends_with(b"\n") {
buf.pop();
return match String::from_utf8(buf) {
Ok(s) => Some(Ok(s)),
Err(_) => Some(Err(())),
};
}
}
}
fn scan_file(
path: &Path,
needle: &str,
opts: &RgArgs,
sink: &mut CappedSink,
printed: &mut std::collections::HashSet<PathBuf>,
) -> bool {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let cap = opts.context;
let mut ring: std::collections::VecDeque<String> =
std::collections::VecDeque::with_capacity(cap);
let mut tail = 0usize;
let mut last: Option<usize> = None;
let mut found = false;
let mut reader = BufReader::new(file);
let mut idx = 0usize;
loop {
let line = match read_bounded_line(&mut reader, MAX_LINE_BYTES) {
None => break,
Some(Err(())) => return found,
Some(Ok(l)) => l,
};
if sink.capped {
return found;
}
let is_match = if opts.ignore_case {
line.to_lowercase().contains(needle)
} else {
line.contains(needle)
};
if is_match {
found = true;
if opts.list_files_with_matches {
if printed.insert(path.to_path_buf()) {
sink.writeln(&path.display().to_string());
}
return found;
}
let ring_start = idx.saturating_sub(ring.len());
for (offset, prev) in ring.iter().enumerate() {
let li = ring_start + offset;
if last.is_none_or(|l| li > l) {
emit_line(path, li, prev, opts, sink);
last = Some(li);
}
}
if last.is_none_or(|l| idx > l) {
emit_line(path, idx, &line, opts, sink);
last = Some(idx);
}
tail = cap;
} else if tail > 0 {
emit_line(path, idx, &line, opts, sink);
last = Some(idx);
tail -= 1;
}
if cap > 0 {
if ring.len() == cap {
ring.pop_front();
}
ring.push_back(line);
}
idx += 1;
}
found
}
fn emit_line(path: &Path, line_idx: usize, line: &str, opts: &RgArgs, sink: &mut CappedSink) {
let prefix = if opts.line_numbers {
format!("{}:{}:", path.display(), line_idx + 1)
} else {
format!("{}:", path.display())
};
sink.writeln(&format!("{prefix}{line}"));
}
fn walk(root: &Path, opts: &RgArgs, on_file: &mut dyn FnMut(&Path) -> bool) {
if root.is_file() {
if accept(root, opts) {
on_file(root);
}
return;
}
let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name.starts_with('.') {
continue;
}
let ft = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
if matches!(name, "target" | "node_modules" | "dist" | "build") {
continue;
}
if depth < MAX_WALK_DEPTH {
stack.push((path, depth + 1));
}
} else if ft.is_file() && accept(&path, opts) && !on_file(&path) {
return;
}
}
}
}
fn accept(path: &Path, opts: &RgArgs) -> bool {
match &opts.glob {
None => true,
Some(g) => glob_match(g, path),
}
}
fn glob_match(pattern: &str, path: &Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let full = path.to_string_lossy();
simple_glob(pattern, name) || simple_glob(pattern, &full)
}
fn simple_glob(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star_pi, mut star_ti) = (usize::MAX, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star_pi = pi;
star_ti = ti;
pi += 1;
} else if star_pi != usize::MAX {
pi = star_pi + 1;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_pattern_and_path() {
let opts = parse(vec!["-n".into(), "needle".into(), "src".into()]).expect("parse");
assert!(opts.line_numbers);
assert_eq!(opts.pattern.as_deref(), Some("needle"));
assert_eq!(opts.paths, vec![PathBuf::from("src")]);
}
#[test]
fn parse_files_only() {
let opts = parse(vec!["--files".into(), ".".into()]).expect("parse");
assert!(opts.files_only);
assert_eq!(opts.paths, vec![PathBuf::from(".")]);
}
#[test]
fn parse_rejects_unknown_flag() {
let err = parse(vec!["-Z".into(), "x".into()]).unwrap_err();
assert!(err.contains("unsupported flag"));
}
#[test]
fn capped_sink_stops_at_byte_limit() {
let mut s = CappedSink::new();
// Below cap: accepted.
s.bytes = MAX_OUTPUT_BYTES - 10;
s.writeln("12345");
assert!(!s.capped);
// This pushes us over.
s.writeln("x".repeat(20).as_str());
assert!(s.capped);
}
#[test]
fn glob_matches_simple_patterns() {
assert!(simple_glob("*.rs", "main.rs"));
assert!(simple_glob("src/*.rs", "src/main.rs"));
assert!(!simple_glob("*.rs", "main.txt"));
assert!(simple_glob("a?c", "abc"));
}
#[test]
fn fallback_finds_match_in_file() {
// End-to-end: create a file, run the fallback parser, scan it.
// We can't easily capture stdout here, so we verify scan_file
// returns true (match found).
let dir = tempfile::tempdir().expect("tempdir");
let f = dir.path().join("a.txt");
std::fs::write(&f, "alpha\nNEEDLE\nbeta\n").expect("write");
let opts = parse(vec!["NEEDLE".into(), f.display().to_string()]).expect("parse");
let mut sink = CappedSink::new();
let mut printed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let found = scan_file(&f, "NEEDLE", &opts, &mut sink, &mut printed);
assert!(found, "expected NEEDLE to be found");
}
}

View File

@@ -0,0 +1,559 @@
use crate::shim::Shim;
use rmcp::model::{CallToolResult, Content};
use rmcp::ErrorData;
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_COMMAND_BYTES: usize = 1_000_000;
const CAPTURE_CAP: usize = 10 * 1024 * 1024;
const MAX_BYTES: usize = 50 * 1024;
const MAX_LINES: usize = 2000;
const TAIL_BYTES: usize = 8 * 1024;
const ARTIFACT_RING_SIZE: usize = 8;
const READ_CHUNK: usize = 16 * 1024;
pub struct SharedState {
pub cwd: PathBuf,
pub shim: Shim,
pub session_dir: TempDir,
pub bootstrap_instructions: String,
pub artifacts: Mutex<VecDeque<PathBuf>>,
next_call_id: Mutex<u64>,
}
impl SharedState {
pub fn new(cwd: PathBuf, shim: Shim) -> std::io::Result<Self> {
let session_dir = tempfile::Builder::new()
.prefix("sprout-dev-mcp-session-")
.tempdir()?;
let bootstrap_instructions = build_bootstrap(&cwd);
Ok(Self {
cwd,
shim,
session_dir,
bootstrap_instructions,
artifacts: Mutex::new(VecDeque::with_capacity(ARTIFACT_RING_SIZE)),
next_call_id: Mutex::new(0),
})
}
fn next_id(&self) -> u64 {
let mut g = match self.next_call_id.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
*g += 1;
*g
}
}
fn build_bootstrap(cwd: &Path) -> String {
let stack = detect_stack(cwd);
let sprout_hint = if std::env::var("SPROUT_RELAY_URL").is_ok()
&& std::env::var("SPROUT_PRIVATE_KEY").is_ok()
{
"\nSprout relay configured. Run `sprout --help` to see available commands.\n"
} else {
""
};
format!(
"Working directory: {}\n\
Detected stack: {}\n\
Pass `workdir` per call rather than `cd`.\n\
{sprout_hint}",
cwd.display(),
stack,
)
}
fn detect_stack(cwd: &Path) -> String {
let markers = [
("Cargo.toml", "rust (cargo)"),
("package.json", "node"),
("go.mod", "go"),
("pyproject.toml", "python (pyproject)"),
("requirements.txt", "python"),
("Gemfile", "ruby"),
("pom.xml", "java (maven)"),
("build.gradle", "java (gradle)"),
("build.gradle.kts", "kotlin (gradle)"),
];
let mut found: Vec<&str> = markers
.iter()
.filter(|(f, _)| cwd.join(f).exists())
.map(|(_, name)| *name)
.collect();
if found.is_empty() {
"unknown".into()
} else {
found.sort();
found.join(", ")
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ShellParams {
pub command: String,
#[serde(default)]
pub workdir: Option<String>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
pub async fn run(
state: &SharedState,
p: ShellParams,
ct: CancellationToken,
) -> Result<CallToolResult, ErrorData> {
if p.command.len() > MAX_COMMAND_BYTES {
return Err(ErrorData::invalid_params(
format!("command exceeds {MAX_COMMAND_BYTES} byte limit"),
None,
));
}
let timeout_ms = p
.timeout_ms
.unwrap_or(DEFAULT_TIMEOUT_MS)
.min(MAX_TIMEOUT_MS);
let workdir: PathBuf = p
.workdir
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| state.cwd.clone());
if !workdir.is_dir() {
return Err(ErrorData::invalid_params(
format!(
"workdir does not exist or is not a directory: {}",
workdir.display()
),
None,
));
}
let mut cmd = Command::new("bash");
cmd.arg("-c").arg(&p.command);
cmd.current_dir(&workdir);
cmd.env("PATH", &state.shim.path_env);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
set_process_group(&mut cmd);
let started = Instant::now();
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return Ok(CallToolResult::error(vec![Content::text(format!(
"failed to spawn bash: {e}"
))]));
}
};
let pid = child.id();
struct PgidGuard(Option<u32>);
impl Drop for PgidGuard {
fn drop(&mut self) {
if let Some(pid) = self.0 {
kill_process_group_immediate(pid as i32);
}
}
}
let mut pgid_guard = PgidGuard(pid);
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let mut stdout_handle = tokio::spawn(async move {
match stdout_pipe {
Some(p) => read_capped(p).await,
None => CapturedStream::default(),
}
});
let mut stderr_handle = tokio::spawn(async move {
match stderr_pipe {
Some(p) => read_capped(p).await,
None => CapturedStream::default(),
}
});
let timeout_dur = Duration::from_millis(timeout_ms);
let mut notes: Vec<String> = Vec::new();
let (status, timed_out) = tokio::select! {
biased;
_ = ct.cancelled() => {
// Kill process group, reap child, abort reader tasks.
if let Some(pid) = pid {
kill_process_group_immediate(pid as i32);
}
// Bounded reap so we don't leak zombies. If reap times out,
// PgidGuard drop will SIGKILL again as a last resort.
match tokio::time::timeout(Duration::from_secs(1), child.wait()).await {
Ok(Ok(_)) => { pgid_guard.0 = None; } // reaped; disarm guard
Ok(Err(e)) => {
tracing::debug!("cancel: child wait error: {e}");
// Leave pgid_guard armed for drop-kill.
}
Err(_) => {
tracing::debug!("cancel: child reap timed out; guard will SIGKILL on drop");
}
}
stdout_handle.abort();
stderr_handle.abort();
return Ok(CallToolResult::error(vec![Content::text("cancelled")]));
}
r = tokio::time::timeout(timeout_dur, child.wait()) => match r {
Ok(Ok(s)) => (Some(s), false),
Ok(Err(err)) => {
notes.push(format!("child wait failed: {err}"));
(None, false)
}
Err(_) => {
// Kill process group — this closes the pipes, causing reads to EOF.
if let Some(pid) = pid {
kill_process_group_graceful(pid as i32).await;
}
// Reap the child so it doesn't become a zombie.
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if Instant::now() >= deadline => {
if let Err(e) = child.start_kill() {
notes.push(format!("force-kill failed: {e}"));
}
if let Err(e) = child.wait().await {
notes.push(format!("post-kill wait: {e}"));
}
break;
}
Ok(None) => {
tokio::time::sleep(Duration::from_millis(20)).await;
}
Err(err) => {
notes.push(format!("try_wait failed: {err}"));
break;
}
}
}
(None, true)
}
}
};
if !timed_out {
if let Some(pid) = pid {
kill_process_group_graceful(pid as i32).await;
}
}
let stdout_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stdout_handle).await {
Ok(Ok(cap)) => cap,
_ => {
stdout_handle.abort();
notes.push("stdout reader did not complete".into());
CapturedStream::default()
}
};
let stderr_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stderr_handle).await {
Ok(Ok(cap)) => cap,
_ => {
stderr_handle.abort();
notes.push("stderr reader did not complete".into());
CapturedStream::default()
}
};
let duration_ms = started.elapsed().as_millis() as u64;
let exit_code = status
.as_ref()
.and_then(|s| s.code())
.unwrap_or(if timed_out { 124 } else { -1 });
let id = state.next_id();
let (stdout_text, stdout_truncated, stdout_artifact) =
finalize_stream(state, id, "stdout", stdout_cap, &mut notes);
let (stderr_text, stderr_truncated, stderr_artifact) =
finalize_stream(state, id, "stderr", stderr_cap, &mut notes);
let body = serde_json::json!({
"exit_code": exit_code,
"stdout": stdout_text,
"stderr": stderr_text,
"timed_out": timed_out,
"duration_ms": duration_ms,
"stdout_truncated": stdout_truncated,
"stderr_truncated": stderr_truncated,
"stdout_artifact": stdout_artifact,
"stderr_artifact": stderr_artifact,
"notes": notes,
});
let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into());
pgid_guard.0 = None;
Ok(CallToolResult::success(vec![Content::text(text)]))
}
#[cfg(unix)]
fn set_process_group(cmd: &mut Command) {
cmd.process_group(0);
}
#[cfg(not(unix))]
fn set_process_group(_cmd: &mut Command) {}
/// Immediate SIGKILL of the process group. Sync; safe to call from Drop.
/// No grace period — used when the parent task is being torn down.
#[cfg(unix)]
fn kill_process_group_immediate(pid: i32) {
use nix::sys::signal::{killpg, Signal};
use nix::unistd::Pid;
let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
}
#[cfg(not(unix))]
fn kill_process_group_immediate(_pid: i32) {}
/// Graceful SIGTERM → 200ms async sleep → SIGKILL. Async; never blocks the runtime.
#[cfg(unix)]
async fn kill_process_group_graceful(pid: i32) {
use nix::sys::signal::{killpg, Signal};
use nix::unistd::Pid;
let pgid = Pid::from_raw(pid);
let _ = killpg(pgid, Signal::SIGTERM);
tokio::time::sleep(Duration::from_millis(200)).await;
let _ = killpg(pgid, Signal::SIGKILL);
}
#[cfg(not(unix))]
async fn kill_process_group_graceful(_pid: i32) {}
#[derive(Default)]
struct CapturedStream {
bytes: Vec<u8>,
/// Total bytes the process produced (may exceed bytes.len() if capped).
total_bytes: usize,
capped: bool,
}
async fn read_capped<R: AsyncRead + Unpin>(mut r: R) -> CapturedStream {
let mut out = CapturedStream::default();
let mut chunk = vec![0u8; READ_CHUNK];
loop {
match r.read(&mut chunk).await {
Ok(0) => break,
Ok(n) => {
out.total_bytes = out.total_bytes.saturating_add(n);
if !out.capped {
let remaining = CAPTURE_CAP.saturating_sub(out.bytes.len());
if remaining == 0 {
out.capped = true;
} else {
let take = n.min(remaining);
out.bytes.extend_from_slice(&chunk[..take]);
if out.bytes.len() >= CAPTURE_CAP {
out.capped = true;
}
}
}
}
Err(_) => break,
}
}
out
}
fn finalize_stream(
state: &SharedState,
call_id: u64,
label: &str,
cap: CapturedStream,
notes: &mut Vec<String>,
) -> (String, bool, Option<String>) {
let CapturedStream {
bytes: buf,
total_bytes,
capped,
} = cap;
let captured_len = buf.len();
let line_count = buf.iter().filter(|b| **b == b'\n').count();
let needs_truncate = capped || captured_len > MAX_BYTES || line_count > MAX_LINES;
if !needs_truncate {
return (lossy(buf), false, None);
}
let artifact_path = crate::shim::artifact_dir(state.session_dir.path())
.join(format!("{call_id:06}.{label}.txt"));
let artifact_str = match std::fs::write(&artifact_path, &buf) {
Ok(()) => {
rotate_artifacts(state, artifact_path.clone());
Some(artifact_path.to_string_lossy().into_owned())
}
Err(e) => {
notes.push(format!(
"{label}: artifact write failed ({}): {e}",
artifact_path.display()
));
None
}
};
let tail_start = captured_len.saturating_sub(TAIL_BYTES);
let tail_aligned = align_to_char_boundary(&buf, tail_start);
let tail = lossy(buf[tail_aligned..].to_vec());
let cap_note = if capped {
format!(
" (capture capped at {} bytes; further output discarded)",
CAPTURE_CAP
)
} else {
String::new()
};
let artifact_suffix = match &artifact_str {
Some(p) => format!("; captured output (first 10MB) at {p}"),
None => "; artifact unavailable".into(),
};
let notice = format!(
"[truncated: showing last {} bytes; {} bytes captured / {} lines / {} bytes total{cap_note}{artifact_suffix}]\n",
tail.len(),
captured_len,
line_count,
total_bytes,
);
let mut out = String::with_capacity(notice.len() + tail.len());
out.push_str(&notice);
out.push_str(&tail);
(out, true, artifact_str)
}
fn align_to_char_boundary(buf: &[u8], start: usize) -> usize {
let mut i = start.min(buf.len());
while i < buf.len() && (buf[i] & 0xC0) == 0x80 {
i += 1;
}
i
}
fn lossy(buf: Vec<u8>) -> String {
String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
fn rotate_artifacts(state: &SharedState, new_path: PathBuf) {
let mut ring = match state.artifacts.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
ring.push_back(new_path);
while ring.len() > ARTIFACT_RING_SIZE {
if let Some(old) = ring.pop_front() {
let _ = std::fs::remove_file(old);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shim::Shim;
use serde_json::Value;
use tempfile::tempdir;
fn make_state(cwd: &std::path::Path) -> SharedState {
let shim = Shim::install().expect("shim install");
SharedState::new(cwd.to_path_buf(), shim).expect("state new")
}
/// Pull the JSON body out of a CallToolResult so tests can assert on fields.
fn body(r: rmcp::model::CallToolResult) -> Value {
let text = match r.content.first().and_then(|c| c.as_text()) {
Some(t) => t.text.clone(),
None => panic!("no text content"),
};
serde_json::from_str(&text).expect("json")
}
#[tokio::test(flavor = "current_thread")]
async fn basic_echo() {
let dir = tempdir().expect("tempdir");
let state = make_state(dir.path());
let r = run(
&state,
ShellParams {
command: "echo hello".into(),
workdir: None,
timeout_ms: Some(5_000),
},
CancellationToken::new(),
)
.await
.expect("ok");
let v = body(r);
assert_eq!(v["exit_code"], 0);
assert_eq!(v["stdout"], "hello\n");
assert_eq!(v["timed_out"], false);
}
#[tokio::test(flavor = "current_thread")]
async fn timeout_fires() {
let dir = tempdir().expect("tempdir");
let state = make_state(dir.path());
let r = run(
&state,
ShellParams {
command: "sleep 999".into(),
workdir: None,
timeout_ms: Some(150),
},
CancellationToken::new(),
)
.await
.expect("ok");
let v = body(r);
assert_eq!(v["timed_out"], true);
assert_eq!(v["exit_code"], 124);
}
#[tokio::test(flavor = "current_thread")]
async fn workdir_is_honored() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).expect("mkdir sub");
let state = make_state(dir.path());
let r = run(
&state,
ShellParams {
command: "pwd".into(),
workdir: Some(sub.display().to_string()),
timeout_ms: Some(5_000),
},
CancellationToken::new(),
)
.await
.expect("ok");
let v = body(r);
let stdout = v["stdout"].as_str().unwrap_or("");
// Compare canonicalized paths (macOS /tmp -> /private/tmp, etc.).
let sub_canon = std::fs::canonicalize(&sub).expect("canon");
assert!(
stdout
.trim()
.ends_with(sub_canon.to_string_lossy().as_ref())
|| stdout.contains(sub.file_name().unwrap().to_str().unwrap()),
"stdout: {stdout}"
);
}
}

View File

@@ -0,0 +1,68 @@
use std::path::{Path, PathBuf};
use tempfile::TempDir;
pub struct Shim {
_dir: TempDir,
pub path_env: String,
}
impl Shim {
pub fn install() -> std::io::Result<Self> {
let dir = tempfile::Builder::new()
.prefix("sprout-dev-mcp-")
.tempdir()?;
set_owner_only(dir.path())?;
let self_exe = std::env::current_exe()?;
let rg_link = dir.path().join("rg");
symlink(&self_exe, &rg_link)?;
let tree_link = dir.path().join("tree");
symlink(&self_exe, &tree_link)?;
let sprout_link = dir.path().join("sprout");
symlink(&self_exe, &sprout_link)?;
let original = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(dir.path());
if !original.is_empty() {
new_path.push(":");
new_path.push(&original);
}
let path_env = new_path.to_string_lossy().into_owned();
Ok(Self {
_dir: dir,
path_env,
})
}
}
#[cfg(unix)]
fn set_owner_only(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn set_owner_only(_: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}
#[cfg(not(unix))]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::copy(src, dst).map(|_| ())
}
pub fn artifact_dir(session_root: &Path) -> PathBuf {
let p = session_root.join("artifacts");
let _ = std::fs::create_dir_all(&p);
p
}

View File

@@ -0,0 +1,386 @@
use crate::shell::SharedState;
use rmcp::ErrorData;
use schemars::JsonSchema;
use serde::Deserialize;
use similar::{DiffTag, TextDiff};
use std::io::Write;
use std::path::{Path, PathBuf};
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const MAX_INPUT_BYTES: usize = 1024 * 1024;
const HINT_SCAN_LINE_LIMIT: usize = 200;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct StrReplaceParams {
pub path: String,
pub old_str: String,
pub new_str: String,
#[serde(default)]
pub workdir: Option<String>,
}
pub fn run(state: &SharedState, p: StrReplaceParams) -> Result<String, ErrorData> {
if p.old_str.is_empty() {
return Err(ErrorData::invalid_params(
"old_str must not be empty".to_string(),
None,
));
}
if p.old_str.len() > MAX_INPUT_BYTES || p.new_str.len() > MAX_INPUT_BYTES {
return Err(ErrorData::invalid_params(
format!("old_str/new_str exceeds {} byte limit", MAX_INPUT_BYTES),
None,
));
}
let workspace_root = match p.workdir.as_deref() {
Some(w) => PathBuf::from(w),
None => state.cwd.clone(),
};
let target = match resolve_within(&workspace_root, &p.path) {
Ok(t) => t,
Err(e) => return Err(ErrorData::invalid_params(e, None)),
};
let meta = match std::fs::metadata(&target) {
Ok(m) => m,
Err(e) => {
return Err(ErrorData::internal_error(
format!("cannot stat {}: {e}", target.display()),
None,
));
}
};
if !meta.is_file() {
return Err(ErrorData::invalid_params(
format!("not a regular file: {}", target.display()),
None,
));
}
if meta.len() > MAX_FILE_BYTES {
return Err(ErrorData::invalid_params(
format!(
"file too large: {} is {} bytes (limit {} bytes)",
target.display(),
meta.len(),
MAX_FILE_BYTES
),
None,
));
}
let file = match std::fs::File::open(&target) {
Ok(f) => f,
Err(e) => {
return Err(ErrorData::internal_error(
format!("cannot open {}: {e}", target.display()),
None,
));
}
};
let mut buf = Vec::with_capacity(meta.len() as usize);
use std::io::Read;
match file.take(MAX_FILE_BYTES + 1).read_to_end(&mut buf) {
Ok(n) if n as u64 > MAX_FILE_BYTES => {
return Err(ErrorData::invalid_params(
format!("file grew past {} bytes during read", MAX_FILE_BYTES),
None,
));
}
Ok(_) => {}
Err(e) => {
return Err(ErrorData::internal_error(
format!("cannot read {}: {e}", target.display()),
None,
));
}
}
let content = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => {
return Err(ErrorData::internal_error(
format!("not valid UTF-8: {}: {e}", target.display()),
None,
));
}
};
let count = count_occurrences_capped(&content, &p.old_str);
match count {
0 => {
let hint = nearest_line_hint(&content, &p.old_str)
.map(|h| format!("\n{h}"))
.unwrap_or_default();
Err(ErrorData::invalid_params(
format!(
"old_str not found in {}.\nold_str (truncated): {:?}{hint}",
target.display(),
truncate(&p.old_str, 80)
),
None,
))
}
1 => {
let new_content = content.replacen(&p.old_str, &p.new_str, 1);
if new_content.len() as u64 > MAX_FILE_BYTES {
return Err(ErrorData::invalid_params(
format!(
"result would exceed {} byte limit ({} bytes)",
MAX_FILE_BYTES,
new_content.len()
),
None,
));
}
if let Err(e) = atomic_write(&target, &new_content) {
return Err(ErrorData::internal_error(
format!("failed to write {}: {e}", target.display()),
None,
));
}
let diff = unified_diff(&content, &new_content, &target);
Ok(format!(
"Replaced 1 occurrence in {}.\n\n{diff}",
target.display()
))
}
_ => Err(ErrorData::invalid_params(
format!(
"old_str matched multiple locations in {}; provide more surrounding context to make the match unique.",
target.display()
),
None,
)),
}
}
pub(crate) fn resolve_within(root: &Path, path: &str) -> Result<PathBuf, String> {
let raw = Path::new(path);
let candidate: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};
let root_canon = std::fs::canonicalize(root)
.map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?;
let resolved = std::fs::canonicalize(&candidate)
.map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?;
if !resolved.starts_with(&root_canon) {
return Err(format!(
"path escapes workspace: {} not within {}",
resolved.display(),
root_canon.display()
));
}
Ok(resolved)
}
pub(crate) fn count_occurrences_capped(text: &str, pattern: &str) -> usize {
if pattern.is_empty() {
return 0;
}
let mut count = 0;
let mut start = 0;
while let Some(pos) = text[start..].find(pattern) {
count += 1;
if count >= 2 {
return count;
}
start += pos + pattern.len();
}
count
}
fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> {
let parent = target.parent().unwrap_or_else(|| Path::new("."));
// Preserve original permissions so the atomic rename doesn't drop the file's mode.
let original_perms = std::fs::metadata(target).ok().map(|m| m.permissions());
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
tmp.persist(target).map_err(|e| e.error)?;
if let Some(perms) = original_perms {
let _ = std::fs::set_permissions(target, perms);
}
Ok(())
}
const MAX_DIFF_BYTES: usize = 64 * 1024;
fn unified_diff(old: &str, new: &str, path: &Path) -> String {
let diff = TextDiff::from_lines(old, new);
let display = path.display();
let mut out = format!("--- a/{display}\n+++ b/{display}\n");
for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
let h = hunk.to_string();
if out.len() + h.len() > MAX_DIFF_BYTES {
out.push_str("\n[diff truncated]\n");
break;
}
out.push_str(&h);
}
out
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let head: String = s.chars().take(max_chars).collect();
format!("{head}…")
}
}
fn truncate_str(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut cut = max;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
&s[..cut]
}
fn similarity(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
const MAX: usize = 512;
let a = truncate_str(a, MAX);
let b = truncate_str(b, MAX);
let matched: usize = TextDiff::from_chars(a, b)
.ops()
.iter()
.filter(|op| matches!(op.tag(), DiffTag::Equal))
.map(|op| op.new_range().len())
.sum();
matched as f64 / a.len().max(b.len()) as f64
}
fn nearest_line_hint(content: &str, pattern: &str) -> Option<String> {
let first = pattern.lines().next()?.trim();
if first.is_empty() {
return None;
}
let best = content
.lines()
.take(HINT_SCAN_LINE_LIMIT)
.enumerate()
.map(|(i, line)| (i, similarity(line.trim(), first), line))
.filter(|(_, s, _)| *s > 0.6)
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?;
Some(format!(
"Hint: nearest match around line {} (similarity {:.2}):\n found: {:?}\n expected: {:?}",
best.0 + 1,
best.1,
best.2.trim(),
first
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn count_occurrences_capped_returns_0_1_2() {
assert_eq!(count_occurrences_capped("hello world", "x"), 0);
assert_eq!(count_occurrences_capped("hello world", "hello"), 1);
assert_eq!(count_occurrences_capped("a a a a a", "a"), 2); // capped at 2
assert_eq!(count_occurrences_capped("abc", ""), 0);
}
#[test]
fn resolve_within_rejects_escape() {
let dir = tempdir().expect("tempdir");
let inside = dir.path().join("file.txt");
fs::write(&inside, b"x").expect("write");
// Symlink targeting outside the dir should be rejected.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join("sprout-mcp-escape-target");
let _ = fs::remove_file(&outside);
fs::write(&outside, b"y").expect("write outside");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&outside, &link).expect("symlink");
let err = resolve_within(dir.path(), "link.txt").unwrap_err();
assert!(err.contains("escapes workspace"), "got: {err}");
let _ = fs::remove_file(&outside);
}
// Resolves a normal path inside.
let p = resolve_within(dir.path(), "file.txt").expect("resolve");
assert!(p.ends_with("file.txt"));
}
fn make_state(cwd: &std::path::Path) -> SharedState {
let shim = crate::shim::Shim::install().expect("shim install");
SharedState::new(cwd.to_path_buf(), shim).expect("state new")
}
#[test]
fn run_basic_replace_emits_diff() {
let dir = tempdir().expect("tempdir");
let f = dir.path().join("a.txt");
fs::write(&f, "alpha\nbeta\ngamma\n").expect("write");
let state = make_state(dir.path());
let p = StrReplaceParams {
path: "a.txt".into(),
old_str: "beta".into(),
new_str: "BETA".into(),
workdir: Some(dir.path().display().to_string()),
};
let out = run(&state, p).expect("ok");
assert!(out.contains("Replaced 1 occurrence"), "out: {out}");
assert!(out.contains("-beta"), "out: {out}");
assert!(out.contains("+BETA"), "out: {out}");
let contents = fs::read_to_string(&f).expect("read");
assert_eq!(contents, "alpha\nBETA\ngamma\n");
}
#[test]
fn run_rejects_path_outside_workspace() {
let dir = tempdir().expect("tempdir");
let state = make_state(dir.path());
let p = StrReplaceParams {
path: "/etc/hosts".into(),
old_str: "x".into(),
new_str: "y".into(),
workdir: Some(dir.path().display().to_string()),
};
let err = run(&state, p).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("escapes workspace") || msg.contains("not accessible"),
"msg: {msg}"
);
}
#[test]
fn run_rejects_file_too_large() {
let dir = tempdir().expect("tempdir");
let f = dir.path().join("big.bin");
let big = vec![b'a'; (MAX_FILE_BYTES as usize) + 1024];
fs::write(&f, &big).expect("write");
let state = make_state(dir.path());
let p = StrReplaceParams {
path: "big.bin".into(),
old_str: "a".into(),
new_str: "b".into(),
workdir: Some(dir.path().display().to_string()),
};
let err = run(&state, p).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("too large"), "msg: {msg}");
}
}

View File

@@ -0,0 +1,558 @@
//! In-memory session task list with `_Stop` and `_PostCompact` hooks.
//!
//! - `todo`: read or full-list-replace the task list. Empty args reads.
//! - `_Stop`: hook called by the agent before honoring end_turn. Returns
//! objection text if any items are open, empty otherwise.
//! - `_PostCompact`: hook called after context compaction/handoff.
//! Returns the full list state so the agent can re-inject it.
//!
//! State is per-process (Vec<Item> behind a Mutex). Items are
//! `{text, done}` — no ids; the LLM provides a full replacement list.
//! On replacement, open items that disappear without being marked done
//! trigger a soft warning appended to the tool response.
use rmcp::model::{CallToolResult, Content};
use rmcp::ErrorData;
use schemars::JsonSchema;
use serde::Deserialize;
use std::sync::Mutex;
const MAX_ITEMS: usize = 50;
const MAX_TEXT_CHARS: usize = 200;
#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Item {
#[schemars(length(min = 1, max = 200))]
pub text: String,
#[serde(default)]
pub done: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TodoParams {
/// Full replacement list (max 50 items). Omit to read.
// Both omitted and explicit null mean "read current state".
#[serde(default)]
#[schemars(length(max = 50))]
pub todos: Option<Vec<Item>>,
}
/// Empty params struct for the hook tools. Hooks take no arguments but
/// rmcp requires Parameters<T> for the macro.
#[derive(Debug, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct HookParams {}
#[derive(Debug, Default)]
pub struct TodoState {
items: Mutex<Vec<Item>>,
}
impl TodoState {
pub fn new() -> Self {
Self::default()
}
fn with_items<R>(&self, f: impl FnOnce(&mut Vec<Item>) -> R) -> R {
let mut g = match self.items.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
f(&mut g)
}
/// Replace-or-read. Returns the rendered list, with a warning
/// appended if open items were silently removed. Mutation, warning
/// computation, and rendering all occur under a single lock hold so
/// that the rendered output reflects exactly the list that was just
/// written (no interleaving with concurrent calls).
pub fn handle_todo(&self, params: TodoParams) -> Result<String, String> {
if let Some(mut new_items) = params.todos {
validate(&new_items)?;
// Normalize: trim text on storage so duplicate detection
// and silent-removal diff operate on the same canonical
// form. Validation already rejected empty-after-trim text,
// duplicates after trim, and invalid characters.
for it in &mut new_items {
it.text = it.text.trim().to_owned();
}
return Ok(self.with_items(|items| {
let warning = silent_removal_warning(items, &new_items);
*items = new_items;
let mut out = render_items(items);
if !warning.is_empty() {
out.push('\n');
out.push_str(&warning);
}
out
}));
}
Ok(self.render())
}
pub fn render(&self) -> String {
self.with_items(|items| render_items(items))
}
/// Objection text if open items exist, empty string otherwise.
pub fn stop_objection(&self) -> String {
self.with_items(|items| {
if items.iter().any(|i| !i.done) {
format!(
"You have open todo items. Keep working.\n\n{}",
render_items(items)
)
} else {
String::new()
}
})
}
/// Re-injection block for after a handoff. Empty if no items.
pub fn post_compact(&self) -> String {
self.with_items(|items| {
if items.is_empty() {
String::new()
} else {
format!("# Todo List\n{}", render_items(items))
}
})
}
}
/// Reject characters that aren't safe for single-line display. ASCII
/// space is the only whitespace allowed; everything else (control
/// chars, exotic whitespace, bidi/format chars, zero-width joiners,
/// BOM) could be used to spoof the rendered todo output.
fn invalid_text_char(c: char) -> bool {
if c.is_control() {
return true;
}
if c.is_whitespace() && c != ' ' {
// Catches \t, \n, \r, NBSP (\u{00A0}), line/paragraph
// separators (\u{2028}, \u{2029}), and other exotic spaces.
return true;
}
matches!(c,
'\u{200B}'..='\u{200F}' // zero-width spaces, LRM/RLM
| '\u{202A}'..='\u{202E}' // bidi embedding/override
| '\u{2060}'..='\u{206F}' // word joiner, invisible operators
| '\u{FEFF}' // BOM / zero-width no-break space
)
}
fn validate(items: &[Item]) -> Result<(), String> {
if items.len() > MAX_ITEMS {
return Err(format!("too many items (max {MAX_ITEMS})"));
}
for (i, it) in items.iter().enumerate() {
if it.text.trim().is_empty() {
return Err(format!("item {}: text is empty", i + 1));
}
if it.text.trim().chars().count() > MAX_TEXT_CHARS {
return Err(format!(
"item {}: text exceeds {MAX_TEXT_CHARS} characters",
i + 1
));
}
// Reject control characters and Unicode trickery that could
// spoof the rendered output: line/paragraph separators, bidi
// overrides, zero-width joiners, BOM, etc. See
// `invalid_text_char` for the full list.
if it.text.chars().any(invalid_text_char) {
return Err(format!("item {}: text contains invalid characters", i + 1));
}
}
// Reject duplicates (after trim). Without ids, duplicates are
// ambiguous: silent-removal detection can't tell which copy was
// dropped, and they have no semantic meaning anyway.
for (i, a) in items.iter().enumerate() {
let a_trim = a.text.trim();
for (j, b) in items.iter().enumerate().skip(i + 1) {
if a_trim == b.text.trim() {
return Err(format!(
"item {}: duplicate text (matches item {})",
j + 1,
i + 1
));
}
}
}
Ok(())
}
/// Diff old vs new. Any open (`!done`) item in `old` whose `text` is
/// absent from `new` (any position, any done-state) is "silently
/// removed". Returns the warning string, or empty if none.
///
/// `validate()` rejects duplicate text in `new`, so a `HashSet` lookup
/// is unambiguous here.
fn silent_removal_warning(old: &[Item], new: &[Item]) -> String {
let new_texts: std::collections::HashSet<&str> = new.iter().map(|i| i.text.as_str()).collect();
let removed: Vec<&str> = old
.iter()
.filter(|i| !i.done && !new_texts.contains(i.text.as_str()))
.map(|i| i.text.as_str())
.collect();
if removed.is_empty() {
return String::new();
}
let mut out = format!(
"⚠️ {} open item{} {} removed from the list:\n",
removed.len(),
if removed.len() == 1 { "" } else { "s" },
if removed.len() == 1 { "was" } else { "were" },
);
for t in &removed {
out.push_str(" - \"");
out.push_str(t);
out.push_str("\"\n");
}
out.push_str(
"If accidental, re-add them. If they are complete, prefer marking items done before removing.",
);
out
}
fn render_items(items: &[Item]) -> String {
if items.is_empty() {
return "(todo list is empty)".into();
}
let next = items.iter().position(|i| !i.done);
let mut out = String::with_capacity(64 * items.len());
for (i, it) in items.iter().enumerate() {
let box_ = if it.done { "[x]" } else { "[ ]" };
out.push_str(box_);
out.push(' ');
out.push_str(&(i + 1).to_string());
out.push_str(". ");
out.push_str(&it.text);
if Some(i) == next {
out.push_str(" ← next");
}
out.push('\n');
}
out
}
/// Wrap a string result as an MCP CallToolResult with text content.
pub fn text_result(s: String) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![Content::text(s)]))
}
/// Wrap an error string as an MCP CallToolResult with isError=true.
pub fn error_result(s: String) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::error(vec![Content::text(s)]))
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(items: &[(&str, bool)]) -> Vec<Item> {
items
.iter()
.map(|(text, done)| Item {
text: (*text).to_owned(),
done: *done,
})
.collect()
}
fn write(s: &TodoState, items: &[(&str, bool)]) -> String {
s.handle_todo(TodoParams {
todos: Some(mk(items)),
})
.unwrap()
}
#[test]
fn empty_read_returns_placeholder() {
let s = TodoState::new();
let out = s.handle_todo(TodoParams { todos: None }).unwrap();
assert!(out.contains("empty"));
}
#[test]
fn rejects_empty_text() {
let s = TodoState::new();
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[(" ", false)])),
})
.unwrap_err();
assert!(err.contains("text is empty"));
}
#[test]
fn rejects_too_many_items() {
let s = TodoState::new();
let many: Vec<Item> = (0..=MAX_ITEMS)
.map(|i| Item {
text: format!("item {i}"),
done: false,
})
.collect();
let err = s.handle_todo(TodoParams { todos: Some(many) }).unwrap_err();
assert!(err.contains("too many items"));
}
#[test]
fn rejects_duplicate_text() {
let s = TodoState::new();
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[("same", false), ("same", true)])),
})
.unwrap_err();
assert!(err.contains("duplicate"), "got: {err}");
}
#[test]
fn rejects_duplicate_text_after_trim() {
let s = TodoState::new();
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[("foo", false), (" foo ", false)])),
})
.unwrap_err();
assert!(err.contains("duplicate"), "got: {err}");
}
#[test]
fn rejects_control_characters() {
let s = TodoState::new();
let err_n = s
.handle_todo(TodoParams {
todos: Some(mk(&[("line1\nline2", false)])),
})
.unwrap_err();
assert!(err_n.contains("invalid characters"), "got: {err_n}");
let err_t = s
.handle_todo(TodoParams {
todos: Some(mk(&[("col1\tcol2", false)])),
})
.unwrap_err();
assert!(err_t.contains("invalid characters"), "got: {err_t}");
}
#[test]
fn rejects_unicode_line_separator() {
let s = TodoState::new();
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[("line1\u{2028}line2", false)])),
})
.unwrap_err();
assert!(err.contains("invalid characters"), "got: {err}");
}
#[test]
fn rejects_bidi_override() {
let s = TodoState::new();
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[("text\u{202E}reversed", false)])),
})
.unwrap_err();
assert!(err.contains("invalid characters"), "got: {err}");
}
#[test]
fn rejects_zero_width() {
let s = TodoState::new();
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[("zero\u{200B}width", false)])),
})
.unwrap_err();
assert!(err.contains("invalid characters"), "got: {err}");
}
#[test]
fn leading_trailing_spaces_trimmed_on_store() {
let s = TodoState::new();
// Send with surrounding spaces.
let out = write(&s, &[(" task ", false)]);
// Render should have the trimmed form; no leading/trailing
// spaces in the displayed text.
assert!(out.contains("[ ] 1. task"));
assert!(!out.contains(" task "));
// A subsequent write of the trimmed form should not falsely
// warn that " task " was silently removed: stored text is
// already "task", so the diff sees no removal.
let out2 = write(&s, &[("task", false)]);
assert!(!out2.contains("⚠️"), "unexpected warning: {out2}");
}
#[test]
fn hook_params_rejects_unknown_fields() {
let res: Result<HookParams, _> = serde_json::from_str(r#"{"x":1}"#);
assert!(res.is_err(), "expected error, got: {res:?}");
// Sanity: empty object parses.
let ok: Result<HookParams, _> = serde_json::from_str("{}");
assert!(ok.is_ok(), "expected ok, got: {ok:?}");
}
#[test]
fn explicit_null_is_read() {
let s = TodoState::new();
write(&s, &[("alpha", false)]);
// {"todos": null} should behave the same as omitting the field
// — return the current list, don't clear it.
let params: TodoParams = serde_json::from_str(r#"{"todos":null}"#).unwrap();
let out = s.handle_todo(params).unwrap();
assert!(out.contains("[ ] 1. alpha"), "got: {out}");
// Verify state was not mutated.
let again = s.render();
assert!(again.contains("[ ] 1. alpha"));
}
#[test]
fn rejects_unknown_fields() {
// Unknown field on Item: `complete` instead of `done`.
let bad_item = r#"{"todos":[{"text":"x","complete":true}]}"#;
let res: Result<TodoParams, _> = serde_json::from_str(bad_item);
assert!(res.is_err(), "expected error, got: {res:?}");
// Unknown field on TodoParams.
let bad_root = r#"{"todos":[{"text":"x"}],"extra":1}"#;
let res: Result<TodoParams, _> = serde_json::from_str(bad_root);
assert!(res.is_err(), "expected error, got: {res:?}");
// Sanity: the correct shape parses.
let good = r#"{"todos":[{"text":"x","done":true}]}"#;
let res: Result<TodoParams, _> = serde_json::from_str(good);
assert!(res.is_ok(), "expected ok, got: {res:?}");
}
#[test]
fn text_at_boundary_200_chars() {
let s = TodoState::new();
let exactly_200 = "a".repeat(200);
let out = s
.handle_todo(TodoParams {
todos: Some(mk(&[(exactly_200.as_str(), false)])),
})
.unwrap();
assert!(out.contains(&exactly_200));
let too_long = "b".repeat(201);
let err = s
.handle_todo(TodoParams {
todos: Some(mk(&[(too_long.as_str(), false)])),
})
.unwrap_err();
assert!(err.contains("exceeds"), "got: {err}");
}
#[test]
fn write_and_render_atomic() {
// The string returned from a write must reflect the list that
// was just written, not stale state. With the atomic
// implementation the response IS computed under the same lock
// hold as the mutation, so this is guaranteed structurally; we
// assert the contract.
let s = TodoState::new();
let out = write(&s, &[("alpha", false), ("beta", true)]);
assert!(out.contains("[ ] 1. alpha"));
assert!(out.contains("[x] 2. beta"));
// And a subsequent render returns the same content.
let again = s.render();
assert!(again.contains("[ ] 1. alpha"));
assert!(again.contains("[x] 2. beta"));
}
#[test]
fn render_uses_position_numbers() {
let s = TodoState::new();
let out = write(&s, &[("first", true), ("second", false), ("third", false)]);
assert!(out.contains("[x] 1. first"));
assert!(out.contains("[ ] 2. second"));
assert!(out.contains("[ ] 3. third"));
// ← next on first open item
assert!(out.contains("[ ] 2. second ← next"));
assert!(!out.contains("[ ] 3. third ← next"));
}
#[test]
fn stop_returns_objection_when_open_items_exist() {
let s = TodoState::new();
write(&s, &[("a", false), ("b", false)]);
let obj = s.stop_objection();
assert!(!obj.is_empty(), "expected non-empty objection");
assert!(obj.contains("open todo items"));
assert!(obj.contains("a"));
}
#[test]
fn stop_returns_empty_when_all_done() {
let s = TodoState::new();
write(&s, &[("a", true), ("b", true)]);
assert_eq!(s.stop_objection(), "");
}
#[test]
fn stop_returns_empty_when_list_is_empty() {
assert_eq!(TodoState::new().stop_objection(), "");
}
#[test]
fn post_compact_renders_when_populated() {
let s = TodoState::new();
write(&s, &[("a", false)]);
let block = s.post_compact();
assert!(block.starts_with("# Todo List\n"));
}
#[test]
fn post_compact_empty_when_no_items() {
assert_eq!(TodoState::new().post_compact(), "");
}
#[test]
fn silent_removal_warns() {
let s = TodoState::new();
write(
&s,
&[
("fix the flaky test", false),
("write unit tests", false),
("ship it", false),
],
);
let out = write(&s, &[("ship it", false)]);
assert!(out.contains("⚠️"), "expected warning, got: {out}");
assert!(out.contains("2 open items"));
assert!(out.contains("\"fix the flaky test\""));
assert!(out.contains("\"write unit tests\""));
}
#[test]
fn removal_of_done_item_no_warning() {
let s = TodoState::new();
write(&s, &[("done thing", true), ("open thing", false)]);
let out = write(&s, &[("open thing", false)]);
assert!(!out.contains("⚠️"), "unexpected warning: {out}");
}
#[test]
fn rename_triggers_warning() {
let s = TodoState::new();
write(&s, &[("write tests", false)]);
let out = write(&s, &[("write unit tests", false)]);
assert!(out.contains("⚠️"), "expected warning, got: {out}");
assert!(out.contains("\"write tests\""));
}
#[test]
fn marking_done_then_removing_no_warning() {
let s = TodoState::new();
write(&s, &[("task", false)]);
// Mark done.
let out1 = write(&s, &[("task", true)]);
assert!(!out1.contains("⚠️"));
// Now remove (it's done in the prior list).
let out2 = write(&s, &[]);
assert!(!out2.contains("⚠️"), "unexpected warning: {out2}");
}
}

View File

@@ -0,0 +1,184 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
const MAX_OUTPUT_BYTES: usize = 50 * 1024;
const MAX_OUTPUT_LINES: usize = 2000;
const MAX_WALK_DEPTH: usize = 50;
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
struct Frame {
depth: usize,
out_idx: usize,
total: usize,
leaf: bool,
}
pub fn run(args: Vec<String>) -> i32 {
let (root, max_depth) = match parse(args) {
Ok(v) => v,
Err(e) => {
tracing::error!("tree: {e}");
return 2;
}
};
if !root.is_dir() {
tracing::error!("tree: not a directory: {}", root.display());
return 2;
}
let mut out: Vec<String> = Vec::new();
let mut stack: Vec<Frame> = vec![Frame {
depth: 0,
out_idx: 0,
total: 0,
leaf: false,
}];
let line_budget = MAX_OUTPUT_LINES.saturating_sub(1);
let mut truncated = false;
let mut builder = WalkBuilder::new(&root);
builder.git_ignore(true);
builder.git_exclude(true);
builder.git_global(true);
builder.require_git(false);
builder.ignore(true);
builder.hidden(true);
builder.max_depth(Some(max_depth));
builder.sort_by_file_name(|a, b| a.cmp(b));
for entry in builder.build().flatten() {
if entry.depth() == 0 {
continue;
}
let depth = entry.depth();
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let is_file = entry.file_type().is_some_and(|t| t.is_file());
while stack.last().is_some_and(|f| f.depth >= depth) {
let Some(frame) = stack.pop() else { break };
if let Some(parent) = stack.last_mut() {
parent.total = parent.total.saturating_add(frame.total);
}
if frame.depth > 0 && !frame.leaf {
let placeholder = &mut out[frame.out_idx];
*placeholder = format!("{} [{}]", placeholder, frame.total);
}
}
if out.len() >= line_budget {
truncated = true;
break;
}
let prefix = " ".repeat(depth - 1);
let name = entry.file_name().to_string_lossy();
if is_dir {
let idx = out.len();
out.push(format!("{prefix}{name}/"));
stack.push(Frame {
depth,
out_idx: idx,
total: 0,
leaf: depth == max_depth,
});
} else if is_file {
let lc = line_count(entry.path());
if let Some(parent) = stack.last_mut() {
parent.total = parent.total.saturating_add(lc);
}
out.push(format!("{prefix}{name} [{lc}]"));
}
}
let mut grand_total = 0usize;
while let Some(frame) = stack.pop() {
if let Some(parent) = stack.last_mut() {
parent.total = parent.total.saturating_add(frame.total);
}
if frame.depth == 0 {
grand_total = frame.total;
} else if !frame.leaf {
let placeholder = &mut out[frame.out_idx];
*placeholder = format!("{} [{}]", placeholder, frame.total);
}
}
let root_name = root
.file_name()
.unwrap_or(root.as_os_str())
.to_string_lossy();
let stdout = std::io::stdout();
let mut w = stdout.lock();
if writeln!(w, "{root_name}/ [{grand_total}]").is_err() {
return 0;
}
let mut bytes = 0usize;
for line in &out {
if bytes + line.len() + 1 > MAX_OUTPUT_BYTES {
let _ = writeln!(w, "[truncated]");
return 0;
}
if writeln!(w, "{line}").is_err() {
return 0;
}
bytes += line.len() + 1;
}
if truncated {
let _ = writeln!(w, "[truncated]");
}
0
}
fn parse(args: Vec<String>) -> Result<(PathBuf, usize), String> {
let mut depth = MAX_WALK_DEPTH;
let mut path = PathBuf::from(".");
let mut path_set = false;
let mut iter = args.into_iter();
while let Some(a) = iter.next() {
match a.as_str() {
"--" => {
if let Some(p) = iter.next() {
if path_set {
return Err("multiple paths not supported".to_string());
}
path = PathBuf::from(p);
}
break;
}
"-d" | "--depth" => {
let n = iter.next().ok_or("missing value for --depth")?;
depth = n.parse::<usize>().map_err(|_| format!("bad depth: {n}"))?;
}
s if s.starts_with('-') => return Err(format!("unknown flag: {s}")),
_ => {
if path_set {
return Err("multiple paths not supported".to_string());
}
path = PathBuf::from(a);
path_set = true;
}
}
}
Ok((path, depth.min(MAX_WALK_DEPTH)))
}
fn line_count(path: &Path) -> usize {
std::fs::metadata(path)
.ok()
.filter(|m| m.is_file() && m.len() <= MAX_FILE_BYTES)
.and_then(|_| std::fs::read(path).ok())
.map(|b| {
if b.is_empty() {
0
} else {
b.iter().filter(|&&c| c == b'\n').count()
+ if b.last() != Some(&b'\n') { 1 } else { 0 }
}
})
.unwrap_or(0)
}

View File

@@ -38,6 +38,12 @@ reqwest = { workspace = true }
# TLS crypto provider (required for wss:// connections)
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# Crypto
sha2 = { workspace = true }
hex = { workspace = true }
base64 = "0.22"
infer = "0.19"
# Utilities
uuid = { workspace = true }
tracing = { workspace = true }

View File

@@ -9,13 +9,13 @@
//!
//! `sprout-mcp` runs as a stdio MCP server. An agent host (e.g. Claude Desktop, Goose)
//! launches it as a subprocess and communicates over JSON-RPC on stdin/stdout. The server
//! maintains a persistent, authenticated WebSocket connection to a Sprout relay and a shared
//! HTTP client for REST API calls.
//! maintains a persistent, authenticated WebSocket connection to a Sprout relay. All reads
//! use Nostr REQ/EOSE queries; all writes publish signed Nostr events.
//!
//! ```text
//! ┌─────────────┐ JSON-RPC (stdio) ┌──────────────┐ NIP-42 WebSocket ┌───────────────┐
//! │ Agent Host │ ◄─────────────────► │ sprout-mcp │ ◄─────────────────► │ Sprout Relay │
//! └─────────────┘ └──────────────┘ REST (reqwest) └───────────────┘
//! └─────────────┘ └──────────────┘ HTTP (media only) └───────────────┘
//! ```
//!
//! ## Connecting to the Relay
@@ -26,7 +26,7 @@
//! |----------------------|--------------------------|--------------------------------------------------|
//! | `SPROUT_RELAY_URL` | `ws://localhost:3000` | WebSocket URL of the Sprout relay |
//! | `SPROUT_PRIVATE_KEY` | *(generated)* | `nsec…` Nostr private key for the agent identity |
//! | `SPROUT_API_TOKEN` | *(none)* | Bearer token for REST auth (production mode) |
//! | `SPROUT_API_TOKEN` | *(none)* | Auth token embedded in NIP-42 handshake |
//!
//! If `SPROUT_PRIVATE_KEY` is absent a fresh ephemeral keypair is generated and its public key
//! is printed to stderr. In production you should supply a stable key so the agent has a
@@ -81,10 +81,11 @@
//! - **`join_channel`** / **`leave_channel`** — Membership management.
//! - **`update_channel`** / **`set_channel_topic`** / **`set_channel_purpose`** — Metadata.
//! - **`open_dm`** — Open a direct-message channel.
//! - **`list_channel_members`** — List members of a channel.
//!
//! ### Channel Admin (`channel_admin` toolset)
//! - **`create_channel`** / **`archive_channel`** / **`unarchive_channel`**
//! - **`add_channel_member`** / **`remove_channel_member`** / **`list_channel_members`**
//! - **`add_channel_member`** / **`remove_channel_member`**
//!
//! ### Canvas (`canvas` toolset)
//! - **`get_canvas`** — Retrieve the shared canvas document for a channel.
@@ -125,3 +126,5 @@ pub mod relay_client;
pub mod server;
/// Toolset definitions and configuration for organizing MCP tools.
pub mod toolsets;
/// File upload to the Sprout relay (Blossom protocol).
pub mod upload;

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
use nostr::Keys;
use nostr::{Keys, Tag};
use rmcp::{transport::stdio, ServiceExt};
use tracing_subscriber::EnvFilter;
@@ -7,6 +7,42 @@ use sprout_mcp::relay_client::RelayClient;
use sprout_mcp::server::SproutMcpServer;
use sprout_mcp::toolsets::ToolsetConfig;
/// Parse and validate the NIP-OA auth tag from the environment.
///
/// Returns `Ok(Some(tag))` if a valid auth tag is configured,
/// `Ok(None)` if no auth tag is set (or empty),
/// `Err` if the tag is present but malformed, invalid, or non-Unicode.
fn resolve_auth_tag(
env_value: Result<String, std::env::VarError>,
agent_pubkey: &nostr::PublicKey,
) -> anyhow::Result<Option<Tag>> {
let tag_json = match env_value {
Ok(s) if !s.is_empty() => s,
Ok(_) => return Ok(None), // empty string — treat as not set
Err(std::env::VarError::NotPresent) => return Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
anyhow::bail!("SPROUT_AUTH_TAG contains non-Unicode data");
}
};
let tag = sprout_sdk::nip_oa::parse_auth_tag(&tag_json)
.map_err(|e| anyhow::anyhow!("SPROUT_AUTH_TAG is malformed: {e}"))?;
sprout_sdk::nip_oa::verify_auth_tag(&tag_json, agent_pubkey).map_err(|e| {
anyhow::anyhow!(
"SPROUT_AUTH_TAG signature verification failed for agent pubkey {}: {e}",
agent_pubkey.to_hex()
)
})?;
eprintln!(
"sprout-mcp: NIP-OA auth tag configured (owner: {})",
tag.as_slice().get(1).map(|s| &s[..8]).unwrap_or("?")
);
Ok(Some(tag))
}
#[tokio::main]
async fn main() -> Result<()> {
// Install the ring crypto provider for rustls (required for wss:// connections).
@@ -36,11 +72,17 @@ async fn main() -> Result<()> {
}
};
let auth_tag = resolve_auth_tag(std::env::var("SPROUT_AUTH_TAG"), &keys.public_key())?;
if auth_tag.is_some() {
eprintln!("sprout-mcp: NIP-OA auth tag verified ✓");
}
let toolset_config = ToolsetConfig::from_env();
eprintln!("sprout-mcp: toolsets: {:?}", toolset_config);
eprintln!("sprout-mcp: connecting to relay at {relay_url}...");
let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref()).await?;
let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref(), auth_tag).await?;
eprintln!("sprout-mcp: connected and authenticated.");
let tools_to_remove = toolset_config.tools_to_remove();
@@ -50,3 +92,70 @@ async fn main() -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String {
let owner_keys = nostr::Keys::generate();
sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "").unwrap()
}
#[test]
fn resolve_auth_tag_valid() {
let agent_keys = nostr::Keys::generate();
let json = make_valid_auth_tag(&agent_keys);
let result = resolve_auth_tag(Ok(json), &agent_keys.public_key());
assert!(result.is_ok());
assert!(result.unwrap().is_some());
}
#[test]
fn resolve_auth_tag_not_present() {
let keys = nostr::Keys::generate();
let result = resolve_auth_tag(Err(std::env::VarError::NotPresent), &keys.public_key());
assert!(result.unwrap().is_none());
}
#[test]
fn resolve_auth_tag_empty_string() {
let keys = nostr::Keys::generate();
let result = resolve_auth_tag(Ok(String::new()), &keys.public_key());
assert!(result.unwrap().is_none());
}
#[test]
fn resolve_auth_tag_malformed_json() {
let keys = nostr::Keys::generate();
let result = resolve_auth_tag(Ok("not json".into()), &keys.public_key());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("malformed"));
}
#[test]
fn resolve_auth_tag_bad_signature() {
let agent_keys = nostr::Keys::generate();
// Structurally valid (4 elements, correct hex lengths) but cryptographically wrong.
let fake_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
let result = resolve_auth_tag(Ok(fake_json), &agent_keys.public_key());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("verification failed"));
}
#[test]
fn resolve_auth_tag_non_unicode() {
let keys = nostr::Keys::generate();
let result = resolve_auth_tag(
Err(std::env::VarError::NotUnicode(std::ffi::OsString::from(
"bad",
))),
&keys.public_key(),
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("non-Unicode"));
}
}

View File

@@ -221,6 +221,7 @@ async fn do_connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> Result<WsStream, RelayClientError> {
let parsed = relay_url
.parse::<url::Url>()
@@ -236,7 +237,7 @@ async fn do_connect(
// Wait for AUTH challenge (5s timeout).
let challenge = wait_for_auth_challenge(&mut ws, Duration::from_secs(5)).await?;
let auth_event = build_auth_event(&challenge, relay_url, keys, api_token)?;
let auth_event = build_auth_event(&challenge, relay_url, keys, api_token, auth_tag)?;
let event_id = auth_event.id.to_hex();
debug!("sending AUTH event {event_id}");
let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
@@ -318,18 +319,22 @@ async fn wait_for_ok(
}
/// Build a NIP-42 AUTH event for the given challenge.
///
/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the
/// AUTH event so the relay can use it for membership delegation fallback.
#[allow(clippy::result_large_err)]
fn build_auth_event(
challenge: &str,
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> Result<Event, RelayClientError> {
let relay_nostr_url: Url = relay_url
.parse()
.map_err(|e: url::ParseError| RelayClientError::Url(e.to_string()))?;
if let Some(token) = api_token {
let tags = vec![
let mut tags = vec![
Tag::parse(&["relay", relay_url])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
Tag::parse(&["challenge", challenge])
@@ -337,6 +342,19 @@ fn build_auth_event(
Tag::parse(&["auth_token", token])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
];
if let Some(t) = auth_tag {
tags.push(t.clone());
}
Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?)
} else if let Some(t) = auth_tag {
// Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags.
let tags = vec![
Tag::parse(&["relay", relay_url])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
Tag::parse(&["challenge", challenge])
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
t.clone(),
];
Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?)
} else {
Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?)
@@ -353,9 +371,10 @@ async fn send_auth_response(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) {
let result: Result<(), RelayClientError> = async {
let auth_event = build_auth_event(challenge, relay_url, keys, api_token)?;
let auth_event = build_auth_event(challenge, relay_url, keys, api_token, auth_tag)?;
let msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
ws.send(Message::Text(msg.into())).await?;
debug!("sent AUTH response for mid-session challenge");
@@ -377,6 +396,7 @@ async fn handle_ws_message(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> bool {
match msg {
Message::Text(text) => {
@@ -429,7 +449,7 @@ async fn handle_ws_message(
}
RelayMessage::Auth { challenge } => {
debug!("received mid-session AUTH challenge — re-authenticating");
send_auth_response(ws, &challenge, relay_url, keys, api_token).await;
send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag).await;
}
}
true
@@ -463,13 +483,14 @@ async fn do_reconnect(
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> bool {
warn!("relay connection lost — reconnecting…");
state.cancel_pending();
let mut delay = Duration::from_secs(1);
loop {
match do_connect(relay_url, keys, api_token).await {
match do_connect(relay_url, keys, api_token, auth_tag).await {
Ok(new_ws) => {
tracing::info!("reconnected to relay at {relay_url}");
*ws = new_ws;
@@ -544,6 +565,7 @@ async fn run_background_task(
keys: Keys,
relay_url: String,
api_token: Option<String>,
auth_tag: Option<Tag>,
) {
let mut state = BgState::new();
// Ticker for expiring timed-out pending operations (~1s granularity).
@@ -557,14 +579,14 @@ async fn run_background_task(
let needs_reconnect = match raw {
Some(Ok(msg)) => {
!handle_ws_message(
msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(),
msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref(),
).await
}
Some(Err(e)) => { warn!("WebSocket error: {e}"); true }
None => { debug!("WebSocket stream ended"); true }
};
if needs_reconnect
&& !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await
&& !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await
{
return; // Shutdown received during reconnect
}
@@ -581,7 +603,7 @@ async fn run_background_task(
};
if let Err(e) = ws.send(Message::Text(msg.into())).await {
let _ = reply.send(Err(RelayClientError::WebSocket(e)));
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await {
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
return;
}
continue;
@@ -611,7 +633,7 @@ async fn run_background_task(
};
if let Err(e) = ws.send(Message::Text(text.into())).await {
let _ = reply.send(Err(RelayClientError::WebSocket(e)));
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await {
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
return;
}
continue;
@@ -636,7 +658,7 @@ async fn run_background_task(
};
if let Err(e) = ws.send(Message::Text(msg.into())).await {
let _ = reply.send(Err(RelayClientError::WebSocket(e)));
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await {
if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await {
return;
}
continue;
@@ -695,10 +717,10 @@ pub struct RelayClient {
keys: Keys,
/// WebSocket URL of the relay (e.g. "ws://localhost:3000").
relay_url: String,
/// Shared reqwest client for REST API calls.
/// Shared reqwest client for HTTP calls (media upload only).
http: reqwest::Client,
/// Optional API token for Bearer auth on REST endpoints.
api_token: Option<String>,
/// Optional NIP-OA auth tag injected into every signed event.
auth_tag: Option<nostr::Tag>,
}
impl RelayClient {
@@ -706,40 +728,76 @@ impl RelayClient {
///
/// Performs the initial NIP-42 handshake synchronously so startup failures
/// are surfaced immediately. After that, reconnection is automatic.
///
/// `auth_tag` is an optional NIP-OA tag that will be injected into every
/// event signed via [`sign_event`](Self::sign_event).
pub async fn connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<nostr::Tag>,
) -> Result<Self, RelayClientError> {
let ws = do_connect(relay_url, keys, api_token).await?;
let ws = do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?;
let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
let bg_keys = keys.clone();
let bg_relay_url = relay_url.to_string();
let bg_api_token = api_token.map(|t| t.to_string());
let bg_auth_tag = auth_tag.clone();
let handle = tokio::spawn(async move {
run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token).await;
run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token, bg_auth_tag).await;
});
Ok(Self {
bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }),
keys: keys.clone(),
relay_url: relay_url.to_string(),
// Default builder with only timeout config — infallible in practice.
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| RelayClientError::Url(format!("HTTP client build failed: {e}")))?,
api_token: api_token.map(|t| t.to_string()),
auth_tag,
})
}
/// Returns the Nostr keypair used for signing and authentication.
pub fn keys(&self) -> &Keys {
&self.keys
/// Sign an event builder, injecting the NIP-OA auth tag if configured.
///
/// This is the canonical signing path in the MCP server. All event creation
/// should go through this method to ensure consistent auth tag injection.
///
/// **Callers MUST NOT add `auth` tags to the builder before calling this
/// method.** The only `auth` tag that may appear in the signed event is the
/// one injected by this method. Any pre-existing `auth` tag — whether
/// `self.auth_tag` is configured or not — is rejected immediately.
pub fn sign_event(&self, builder: EventBuilder) -> Result<Event, RelayClientError> {
let builder = if let Some(ref tag) = self.auth_tag {
builder.add_tags([tag.clone()])
} else {
builder
};
let event = builder
.sign_with_keys(&self.keys)
.map_err(RelayClientError::from)?;
// Enforce: auth tags may only come from self.auth_tag injection.
// - If auth_tag is Some: exactly 1 auth tag must exist (the one we injected)
// - If auth_tag is None: zero auth tags must exist (no caller bypass)
let auth_count = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.count();
let expected = if self.auth_tag.is_some() { 1 } else { 0 };
if auth_count != expected {
return Err(RelayClientError::EventBuilder(format!(
"event has {auth_count} auth tags — expected {expected}; callers must not add auth tags manually"
)));
}
Ok(event)
}
/// Returns the WebSocket URL the client connected to.
@@ -753,104 +811,110 @@ impl RelayClient {
relay_ws_to_http(&self.relay_url)
}
fn pubkey_hex(&self) -> String {
pub(crate) fn pubkey_hex(&self) -> String {
self.keys.public_key().to_hex()
}
/// Returns the appropriate auth header for REST requests.
/// Returns a reference to the shared reqwest HTTP client.
pub fn http_client(&self) -> &reqwest::Client {
&self.http
}
/// Returns a reference to the Nostr signing keys.
pub fn keys(&self) -> &nostr::Keys {
&self.keys
}
/// Returns the NIP-OA auth tag JSON string for use in HTTP `x-auth-tag` headers.
///
/// - If an API token is present: `Authorization: Bearer <token>` (production mode).
/// - Otherwise: `X-Pubkey: <hex>` (dev mode, relay has `require_auth_token=false`).
fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref token) = self.api_token {
builder.header("Authorization", format!("Bearer {}", token))
} else {
builder.header("X-Pubkey", self.pubkey_hex())
/// Returns `None` if no auth tag is configured (direct-member agents).
pub fn auth_tag_json(&self) -> Option<String> {
self.auth_tag
.as_ref()
.and_then(|t| serde_json::to_string(t.as_slice()).ok())
}
/// Returns the relay's server authority (host or host:port) for BUD-11 server tags.
///
/// Uses the same logic as the desktop client's `extract_server_authority`:
/// default ports (80/443) are omitted, non-default ports are included.
/// Returns `None` for localhost (no server tag in dev mode).
pub fn server_domain(&self) -> Option<String> {
// Convert ws:// → http://, wss:// → https:// for url::Url parsing.
let http_url = self
.relay_url
.replace("wss://", "https://")
.replace("ws://", "http://");
let parsed = url::Url::parse(&http_url).ok()?;
let host = parsed.host_str()?;
if host.is_empty() || host == "localhost" {
return None;
}
match parsed.port() {
Some(port) => Some(format!("{host}:{port}")),
None => Some(host.to_string()),
}
}
/// Authenticated GET to the relay's REST API. Returns the response body.
pub async fn get(&self, path: &str) -> anyhow::Result<String> {
let url = format!("{}{}", self.relay_http_url(), path);
let resp = self.apply_auth(self.http.get(&url)).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
/// Authenticated POST (JSON body) to the relay's REST API.
pub async fn post(&self, path: &str, body: &serde_json::Value) -> anyhow::Result<String> {
let url = format!("{}{}", self.relay_http_url(), path);
let resp = self
.apply_auth(self.http.post(&url))
.json(body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
/// Authenticated PUT (JSON body) to the relay's REST API.
pub async fn put(&self, path: &str, body: &serde_json::Value) -> anyhow::Result<String> {
let url = format!("{}{}", self.relay_http_url(), path);
let resp = self
.apply_auth(self.http.put(&url))
.json(body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
/// Authenticated DELETE to the relay's REST API.
pub async fn delete(&self, path: &str) -> anyhow::Result<String> {
let url = format!("{}{}", self.relay_http_url(), path);
let resp = self.apply_auth(self.http.delete(&url)).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
}
/// Get the canvas content for a channel via REST.
pub async fn get_canvas(&self, channel_id: &str) -> anyhow::Result<String> {
self.get(&format!("/api/channels/{}/canvas", channel_id))
.await
}
/// Set the canvas content for a channel via REST.
pub async fn set_canvas(&self, channel_id: &str, content: &str) -> anyhow::Result<String> {
let body = serde_json::json!({ "content": content });
self.put(&format!("/api/channels/{}/canvas", channel_id), &body)
.await
}
/// Authenticated GET to a full URL (for feed tools that build the URL themselves).
pub async fn get_api(&self, url: &str) -> anyhow::Result<String> {
let resp = self.apply_auth(self.http.get(url)).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("{} {}: {}", status, url, body));
}
Ok(resp.text().await?)
/// One-shot query: send REQ with auto-generated sub_id, collect events until EOSE.
///
/// This is the primary read path for the MCP server. Equivalent to calling
/// `subscribe()` with a random sub_id.
pub async fn query(&self, filters: Vec<Filter>) -> Result<Vec<Event>, RelayClientError> {
let sub_id = format!("q-{}", uuid::Uuid::new_v4().simple());
self.subscribe(&sub_id, filters).await
}
/// Publish a signed Nostr event to the relay and wait for the `OK` acknowledgement.
///
/// Defense-in-depth: validates that the event carries the expected number of
/// `auth` tags before publishing. This catches any code path that bypasses
/// [`sign_event`](Self::sign_event).
pub async fn send_event(&self, event: Event) -> Result<OkResponse, RelayClientError> {
// Verify the event was authored by this client's keypair.
if event.pubkey != self.keys.public_key() {
return Err(RelayClientError::EventBuilder(format!(
"send_event rejected: event pubkey {} does not match client pubkey {}",
event.pubkey.to_hex(),
self.keys.public_key().to_hex()
)));
}
// Defense-in-depth: validate auth tags match configuration exactly.
let auth_tags: Vec<&nostr::Tag> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.collect();
match (&self.auth_tag, auth_tags.as_slice()) {
// Configured: exactly 1 auth tag that matches our configured tag byte-for-byte
(Some(expected), [actual]) => {
if actual.as_slice() != expected.as_slice() {
return Err(RelayClientError::EventBuilder(
"send_event rejected: auth tag does not match configured attestation"
.into(),
));
}
}
// Configured but wrong count
(Some(_), tags) => {
return Err(RelayClientError::EventBuilder(format!(
"send_event rejected: expected 1 auth tag, found {}",
tags.len()
)));
}
// Unconfigured: no auth tags allowed
(None, tags) if !tags.is_empty() => {
return Err(RelayClientError::EventBuilder(format!(
"send_event rejected: auth tags not allowed when unconfigured, found {}",
tags.len()
)));
}
// Unconfigured, no auth tags: OK
(None, _) => {}
}
let (reply_tx, reply_rx) = oneshot::channel();
self.bg
.cmd_tx
@@ -1213,6 +1277,167 @@ mod tests {
assert!(result.is_err());
}
// ── sign_event auth tag injection ────────────────────────────────────────
/// Build a minimal `RelayClient` without a live relay connection.
///
/// Only `keys` and `auth_tag` matter for `sign_event`; the other fields
/// are inert stubs (the background task immediately exits, the HTTP
/// client is never used).
fn make_client(keys: Keys, auth_tag: Option<nostr::Tag>) -> RelayClient {
let (cmd_tx, _cmd_rx) = mpsc::channel(1);
let handle = tokio::runtime::Handle::current().spawn(async {});
RelayClient {
bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }),
keys,
relay_url: "ws://127.0.0.1:1".to_string(),
http: reqwest::Client::new(),
auth_tag,
}
}
#[tokio::test]
async fn test_sign_event_injects_auth_tag() {
let keys = Keys::generate();
// Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "<conditions>", "<128-char-hex-sig>"]
let owner_pubkey = "a".repeat(64);
let conditions = "";
let signature = "b".repeat(128);
let auth_tag = nostr::Tag::parse(&["auth", &owner_pubkey, conditions, &signature]).unwrap();
// With auth_tag: the signed event must contain it.
let client = make_client(keys.clone(), Some(auth_tag.clone()));
let event = client
.sign_event(EventBuilder::new(Kind::TextNote, "hello", []))
.expect("sign_event should succeed");
let tag_values: Vec<Vec<String>> = event
.tags
.iter()
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
assert!(
tag_values
.iter()
.any(|t| t.first().map(|s| s.as_str()) == Some("auth")
&& t.get(1).map(|s| s.as_str()) == Some(owner_pubkey.as_str())
&& t.get(3).map(|s| s.as_str()) == Some(signature.as_str())),
"expected NIP-OA auth tag in event; got: {tag_values:?}"
);
// Without auth_tag: the signed event must NOT contain an auth tag.
let client_no_auth = make_client(keys, None);
let event_no_auth = client_no_auth
.sign_event(EventBuilder::new(Kind::TextNote, "hello", []))
.expect("sign_event should succeed");
let has_auth_tag = event_no_auth
.tags
.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()).unwrap_or("") == "auth");
assert!(!has_auth_tag, "expected no auth tag when auth_tag is None");
}
#[tokio::test]
async fn test_sign_event_rejects_duplicate_auth_tag() {
let keys = Keys::generate();
// Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "<conditions>", "<128-char-hex-sig>"]
let owner_pubkey = "c".repeat(64);
let conditions = "";
let signature = "d".repeat(128);
let auth_tag = nostr::Tag::parse(&["auth", &owner_pubkey, conditions, &signature]).unwrap();
// Case 1: client has auth_tag configured, caller also pre-adds one → duplicate → reject.
let client = make_client(keys.clone(), Some(auth_tag.clone()));
let builder = EventBuilder::new(Kind::TextNote, "oops", [auth_tag.clone()]);
let result = client.sign_event(builder);
assert!(
result.is_err(),
"sign_event should return an error when the event would have duplicate auth tags"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("auth tags"),
"error message should mention auth tags; got: {err_msg}"
);
// Case 2: client has NO auth_tag configured, but caller manually adds one → bypass → reject.
let client_no_auth = make_client(keys, None);
let builder_with_manual = EventBuilder::new(Kind::TextNote, "bypass", [auth_tag]);
let result2 = client_no_auth.sign_event(builder_with_manual);
assert!(
result2.is_err(),
"sign_event should reject a manually added auth tag even when auth_tag is None"
);
let err_msg2 = result2.unwrap_err().to_string();
assert!(
err_msg2.contains("auth tags"),
"error message should mention auth tags; got: {err_msg2}"
);
}
// ── send_event auth tag validation ───────────────────────────────────────
#[tokio::test]
async fn test_send_event_rejects_forged_auth_tag() {
let keys = Keys::generate();
let real_tag = nostr::Tag::parse(&["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap();
let forged_tag =
nostr::Tag::parse(&["auth", &"c".repeat(64), "", &"d".repeat(128)]).unwrap();
let client = make_client(keys.clone(), Some(real_tag));
// Build event with forged auth tag, bypassing sign_event
let event = EventBuilder::new(Kind::TextNote, "forged", [forged_tag])
.sign_with_keys(&keys)
.unwrap();
let result = client.send_event(event).await;
assert!(result.is_err(), "send_event should reject forged auth tag");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("does not match"),
"error should mention mismatch: {err_msg}"
);
}
#[tokio::test]
async fn test_send_event_rejects_auth_tag_when_unconfigured() {
let keys = Keys::generate();
let sneaky_tag =
nostr::Tag::parse(&["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap();
let client = make_client(keys.clone(), None);
let event = EventBuilder::new(Kind::TextNote, "sneaky", [sneaky_tag])
.sign_with_keys(&keys)
.unwrap();
let result = client.send_event(event).await;
assert!(
result.is_err(),
"send_event should reject auth tags when unconfigured"
);
}
#[tokio::test]
async fn test_send_event_rejects_wrong_pubkey() {
let client_keys = Keys::generate();
let other_keys = Keys::generate();
let client = make_client(client_keys, None);
// Event signed by a different keypair
let event = EventBuilder::new(Kind::TextNote, "wrong author", [])
.sign_with_keys(&other_keys)
.unwrap();
let result = client.send_event(event).await;
assert!(
result.is_err(),
"send_event should reject events from wrong pubkey"
);
}
// ── Integration tests: mini relay ─────────────────────────────────────────
//
// Each test spins up a lightweight in-process WebSocket server that performs
@@ -1296,7 +1521,7 @@ mod tests {
.await;
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
// Give the background task a moment to process the Ping.
tokio::time::sleep(Duration::from_millis(200)).await;
let _ = client.close().await;
@@ -1337,7 +1562,7 @@ mod tests {
.await;
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
let _ = client.close().await;
}
@@ -1366,7 +1591,7 @@ mod tests {
.await;
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
let event = EventBuilder::new(Kind::Custom(9), "test", [])
.sign_with_keys(&keys)
@@ -1418,7 +1643,7 @@ mod tests {
.await;
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
let events = client
.subscribe("sub-1", vec![Filter::new()])
@@ -1444,7 +1669,7 @@ mod tests {
.await;
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
let event = EventBuilder::new(Kind::Custom(9), "timeout-test", [])
.sign_with_keys(&keys)
@@ -1517,7 +1742,7 @@ mod tests {
.await;
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
// Subscribe (EOSE comes back immediately).
client
@@ -1634,7 +1859,7 @@ mod tests {
});
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
// Wait for the second relay to complete auth (background task reconnected).
tokio::time::timeout(Duration::from_secs(5), ok_rx)
@@ -1697,7 +1922,7 @@ mod tests {
});
let keys = Keys::generate();
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
// Give the background task time to notice the close and enter
// the reconnect backoff loop.

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More