feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
[package]
|
||||
name = "buzz-acp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "ACP harness that bridges Buzz events to AI agents"
|
||||
|
||||
[lib]
|
||||
name = "buzz_acp"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-acp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Internal
|
||||
buzz-core = { workspace = true }
|
||||
buzz-sdk = { workspace = true }
|
||||
buzz-persona = { path = "../buzz-persona" }
|
||||
|
||||
# Nostr
|
||||
nostr = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
# WebSocket
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
# Codec (bounded line reads)
|
||||
tokio-util = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
futures-util = { workspace = true }
|
||||
|
||||
# HTTP (channel discovery REST API)
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# IDs
|
||||
uuid = { workspace = true }
|
||||
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 }
|
||||
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# Config file
|
||||
toml = "1.0"
|
||||
|
||||
# Filter expressions
|
||||
evalexpr = { workspace = true }
|
||||
|
||||
# Process-group kill (safe wrapper around killpg) — Unix-only; kill_process_group
|
||||
# has a #[cfg(not(unix))] fallback in acp.rs.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", default-features = false, features = ["signal"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
httparse = "1"
|
||||
@@ -0,0 +1,340 @@
|
||||
# buzz-acp
|
||||
|
||||
ACP harness that connects AI agents to Buzz. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using the Buzz CLI.
|
||||
|
||||
```
|
||||
Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent
|
||||
│
|
||||
Buzz CLI
|
||||
(send_message, etc.)
|
||||
```
|
||||
|
||||
Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Buzz relay (`just relay` starts Docker services automatically, or use a hosted instance)
|
||||
- A Nostr keypair for the agent (see [Generating Keys](#generating-keys))
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cargo build --release -p buzz-acp
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
```
|
||||
|
||||
## Generating Keys
|
||||
|
||||
Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use `buzz-admin` to generate one:
|
||||
|
||||
```bash
|
||||
cargo run -p buzz-admin -- generate-key
|
||||
```
|
||||
|
||||
This prints a public and secret key pair as hex. **Save the secret key immediately — it is not stored and cannot be recovered.** Set `BUZZ_PRIVATE_KEY` to the secret key to act as this identity.
|
||||
|
||||
Then register the agent's public key as a relay member so it can read and publish:
|
||||
|
||||
```bash
|
||||
BUZZ_RELAY_PRIVATE_KEY=<relay signing key> \
|
||||
cargo run -p buzz-admin -- add-member --pubkey <agent public key>
|
||||
```
|
||||
|
||||
`add-member` publishes a kind:13534 membership event, so the relay needs a stable signing key: set `BUZZ_RELAY_PRIVATE_KEY` in the relay's environment (uncomment it in `.env`) and restart the relay before running this.
|
||||
|
||||
> **Running multiple agents?** Mint a separate keypair for each. Every agent needs its own identity.
|
||||
|
||||
## Channels
|
||||
|
||||
The harness discovers channels by querying the relay with the agent's authenticated identity.
|
||||
|
||||
By default, the harness discovers only channels the agent is a **member** of (`GET /api/channels?member=true`). When the agent is added to a new channel, the membership notification subscription auto-subscribes to it.
|
||||
|
||||
**Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Buzz CLI to create new channels (the creator is automatically a member).
|
||||
|
||||
## Quick Start (goose)
|
||||
|
||||
```bash
|
||||
export BUZZ_PRIVATE_KEY="nsec1..." # your agent's key (see "Generating Keys")
|
||||
export BUZZ_RELAY_URL="ws://localhost:3000"
|
||||
export GOOSE_MODE=auto
|
||||
|
||||
buzz-acp
|
||||
```
|
||||
|
||||
That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Buzz CLI that the harness configures automatically.
|
||||
|
||||
## Running with Codex
|
||||
|
||||
[codex-acp](https://github.com/agentclientprotocol/codex-acp) wraps OpenAI Codex in an ACP interface.
|
||||
|
||||
```bash
|
||||
# Install the adapter (npm package — no Rust build required)
|
||||
npm install -g @agentclientprotocol/codex-acp
|
||||
|
||||
# Run
|
||||
export OPENAI_API_KEY="sk-..." # required — use an OpenAI API key, not a ChatGPT subscription
|
||||
|
||||
buzz-acp
|
||||
```
|
||||
|
||||
> **API key note:** `codex-acp` always attempts a ChatGPT WebSocket login first, which logs a `426 Upgrade Required` error. This is expected and non-fatal — it falls back to `OPENAI_API_KEY` automatically. Set `OPENAI_API_KEY` to ensure it has a working fallback.
|
||||
|
||||
## Running with Claude Code
|
||||
|
||||
[claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface.
|
||||
|
||||
```bash
|
||||
# Install the current adapter package
|
||||
npm install -g @agentclientprotocol/claude-agent-acp
|
||||
|
||||
# Run
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
export BUZZ_ACP_AGENT_COMMAND="claude-agent-acp"
|
||||
|
||||
buzz-acp
|
||||
```
|
||||
|
||||
Older installs that still expose `claude-code-acp` are also supported. `buzz-acp`
|
||||
treats both Claude ACP command names as the same zero-arg runtime.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables (or CLI flags — every env var has a matching flag).
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `BUZZ_PRIVATE_KEY` | **yes** | — | Agent's Nostr private key (`nsec1...`). Used for relay auth and agent identity. |
|
||||
| `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. |
|
||||
| `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. |
|
||||
| `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). |
|
||||
| `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. |
|
||||
| `BUZZ_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. |
|
||||
| `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). |
|
||||
| `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). |
|
||||
|
||||
**Note:** `BUZZ_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`.
|
||||
|
||||
**Legacy env vars:** `BUZZ_ACP_PRIVATE_KEY`, `BUZZ_ACP_API_TOKEN`, and `BUZZ_ACP_TURN_TIMEOUT` (replaced by `BUZZ_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks.
|
||||
|
||||
### Parallel Agents & Heartbeat
|
||||
|
||||
| Flag | Env Var | Default | Description |
|
||||
|------|---------|---------|-------------|
|
||||
| `--agents` | `BUZZ_ACP_AGENTS` | `1` | Number of agent subprocesses (1–32). |
|
||||
| `--lazy-pool` | `BUZZ_ACP_LAZY_POOL` | `false` | Connect, subscribe, and queue accepted work before starting ACP/LLM subprocesses. The first accepted event wakes one pool initialization task; failures retry with bounded exponential backoff while work remains. |
|
||||
| `--heartbeat-interval` | `BUZZ_ACP_HEARTBEAT_INTERVAL` | `0` | Seconds between heartbeat prompts. `0` = disabled. Must be `0` or ≥10 when enabled. |
|
||||
| `--heartbeat-prompt` | `BUZZ_ACP_HEARTBEAT_PROMPT` | (built-in) | Custom heartbeat prompt text. Conflicts with `--heartbeat-prompt-file`. |
|
||||
| `--heartbeat-prompt-file` | `BUZZ_ACP_HEARTBEAT_PROMPT_FILE` | — | Read heartbeat prompt from a file. Conflicts with `--heartbeat-prompt`. |
|
||||
|
||||
### Inbound Author Gate
|
||||
|
||||
Controls which authors' events the harness forwards to the agent. Events from disallowed authors are silently dropped before reaching subscription rules.
|
||||
|
||||
| Flag | Env Var | Default | Description |
|
||||
|------|---------|---------|-------------|
|
||||
| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `allowlist`, `anyone`, `nobody`. |
|
||||
| `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated 64-char hex pubkeys (required when mode is `allowlist`). Owner is always implicitly included. |
|
||||
|
||||
**Modes:**
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `owner-only` | Forward only events from the agent's registered owner. If no owner is set, all events are dropped until the owner is resolved. |
|
||||
| `allowlist` | Forward events from the listed pubkeys plus the owner. |
|
||||
| `anyone` | Forward all events (no author filtering). |
|
||||
| `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. |
|
||||
|
||||
The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode:
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `!shutdown` | Gracefully exits the harness. |
|
||||
| `!cancel` | Cancels the current in-flight turn for that channel, if any. |
|
||||
| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. |
|
||||
|
||||
Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle.
|
||||
|
||||
Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent.
|
||||
|
||||
> **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Default: only respond to owner
|
||||
buzz-acp
|
||||
|
||||
# Respond to a team of three users (owner always included automatically)
|
||||
buzz-acp --respond-to allowlist \
|
||||
--respond-to-allowlist "abc123...64hex,def456...64hex,789abc...64hex"
|
||||
|
||||
# Respond to anyone (open agent)
|
||||
buzz-acp --respond-to anyone
|
||||
|
||||
# Broadcast-only: post on heartbeat, ignore all inbound events
|
||||
buzz-acp --respond-to nobody --heartbeat-interval 300
|
||||
```
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
**Single agent, no heartbeat (default):**
|
||||
```bash
|
||||
buzz-acp
|
||||
```
|
||||
|
||||
**Four agents, no heartbeat (high-throughput event processing):**
|
||||
```bash
|
||||
buzz-acp --agents 4
|
||||
```
|
||||
|
||||
**Two agents with 5-minute heartbeat:**
|
||||
```bash
|
||||
buzz-acp --agents 2 --heartbeat-interval 300
|
||||
```
|
||||
|
||||
**Custom heartbeat prompt:**
|
||||
```bash
|
||||
buzz-acp --agents 2 --heartbeat-interval 300 \
|
||||
--heartbeat-prompt "Check get_feed_actions() for pending approvals, then get_feed_mentions() for unanswered mentions. If nothing actionable, end your turn immediately."
|
||||
```
|
||||
|
||||
### Shared Identity
|
||||
|
||||
All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same channel is never processed by two agents simultaneously (the queue enforces this). Cross-channel message ordering is not guaranteed when N>1.
|
||||
|
||||
### Heartbeat Semantics
|
||||
|
||||
When `--heartbeat-interval` is set, the harness fires a prompt on an idle agent at the configured interval. Heartbeat rules:
|
||||
|
||||
- **Lower priority than queued events** — if events are pending, they are dispatched first.
|
||||
- **Skipped when all agents are busy** — no queuing; the tick is simply dropped.
|
||||
- **At most one heartbeat in flight globally** — the next tick is suppressed until the current one completes.
|
||||
- **Default prompt** (when `--heartbeat-prompt` is not set) calls `get_feed_actions()` and `get_feed_mentions()` to surface pending work.
|
||||
|
||||
Heartbeat is designed for idle periods. Under sustained event load it will rarely fire — that's expected.
|
||||
|
||||
### Choosing N
|
||||
|
||||
Start with **N=2** for most deployments. Increase if queue depth grows under load. Each agent spawns its own MCP server subprocess, so resource usage scales approximately as N × (agent memory + MCP server memory). Maximum is 32.
|
||||
|
||||
## Forum Channels
|
||||
|
||||
By default, the ACP harness subscribes to stream message kinds (9, 46010, 40007). To receive forum events, opt in with `--kinds` and disable the mention filter (forum posts don't @mention agents):
|
||||
|
||||
**CLI flags:**
|
||||
```bash
|
||||
buzz-acp --kinds 9,46010,40007,45001,45002,45003 --no-mention-filter
|
||||
```
|
||||
|
||||
**Or with `--subscribe all`:**
|
||||
```bash
|
||||
buzz-acp --subscribe all --kinds 9,46010,40007,45001,45002,45003
|
||||
```
|
||||
|
||||
**Per-channel config:**
|
||||
```toml
|
||||
[channel.CHANNEL_UUID]
|
||||
kinds = [9, 46010, 40007, 45001, 45002, 45003]
|
||||
require_mention = false
|
||||
```
|
||||
|
||||
Forum event kinds:
|
||||
- **45001** — Forum post (thread root)
|
||||
- **45002** — Vote on a post or comment
|
||||
- **45003** — Comment reply on a forum post
|
||||
|
||||
> **Note:** Without `--no-mention-filter` (or `require_mention = false`), the default `subscribe=mentions` mode filters events that don't @mention the agent — forum posts will be invisible.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth.
|
||||
2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
|
||||
3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel.
|
||||
4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
|
||||
5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz.
|
||||
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.
|
||||
|
||||
Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.
|
||||
|
||||
> **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel.
|
||||
|
||||
## Bring Your Own Harness (BYOH)
|
||||
|
||||
Buzz Desktop supports registering any ACP-speaking agent tool as a selectable runtime without a PR.
|
||||
|
||||
### How it works
|
||||
|
||||
**Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden.
|
||||
|
||||
**Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead.
|
||||
|
||||
> **Note — OpenClaw:** `openclaw acp` is a Gateway-backed bridge; PATH availability shows "Available" even when the OpenClaw Gateway daemon is not running. This is expected tier-2 semantics (same class as a preset with unconfigured auth). The Gateway URL is configured via `OPENCLAW_GATEWAY_URL` (or the equivalent env var from OpenClaw's docs) — set it in the agent's **env vars** in Edit Agent, not in the definition env (the preset definition carries no env entries). Note that `openclaw acp` executes tools inside the Gateway daemon, not the Desktop process, so Desktop-injected `BUZZ_*` env vars do NOT reach the execution locus unless you also set them on the Gateway's own environment.
|
||||
|
||||
**Tier-3 — user custom harnesses**: JSON files in `<app-data>/custom_harnesses/` that the user can create from the Settings UI or drop in directly. Each file describes one harness — no install scripts.
|
||||
|
||||
### Custom harness JSON schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-agent",
|
||||
"label": "My Agent",
|
||||
"command": "my-agent-bin",
|
||||
"args": ["acp"],
|
||||
"env": {
|
||||
"MY_AGENT_MODE": "acp"
|
||||
},
|
||||
"installInstructionsUrl": "https://example.com/docs",
|
||||
"installHint": "Download from example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `id` — `[a-z0-9_][a-z0-9_-]*` (used as the runtime picker value and file name)
|
||||
- `label` — human-readable name shown in the UI
|
||||
- `command` — the executable name or absolute path (must be non-empty)
|
||||
- `args` — optional default CLI arguments (array); instance-level args override this when non-empty
|
||||
- `env` — optional environment variables injected at spawn time (definition env is a floor; user/persona/global env overrides it; Buzz-reserved keys like `BUZZ_MANAGED_AGENT` are always stripped and cannot be overridden)
|
||||
- `installInstructionsUrl` / `installHint` — shown when the binary is not on PATH
|
||||
|
||||
Invalid files (bad JSON, unknown id, empty command) are skipped with a warning and do not break discovery for other entries.
|
||||
|
||||
### Security guarantees
|
||||
|
||||
- No install shell commands in preset or custom definitions — only the user's own PATH is consulted.
|
||||
- `can_auto_install` is always `false` for preset and custom entries.
|
||||
- No user-supplied icon URLs — icons are bundled assets keyed by id in `RuntimeIcon.tsx`.
|
||||
- `BUZZ_MANAGED_AGENT` and other Buzz identity keys cannot be overridden by `env` in a custom definition; they are stripped before merging.
|
||||
|
||||
### Adding a preset (contributor guide)
|
||||
|
||||
To add a new runtime to the tier-2 gallery:
|
||||
|
||||
1. **Verify the ACP entrypoint** from the vendor's own documentation — do not rely on a PR description alone. Test with the actual binary.
|
||||
2. **Add a `HarnessDefinition` entry** to the `PRESET_HARNESSES` slice in `desktop/src-tauri/src/managed_agents/discovery.rs`. Fill `id`, `label`, `command`, `args`, `install_instructions_url`, `install_hint`. Leave `env` empty unless the harness requires a specific env var to enable ACP mode.
|
||||
3. **Add the preset id to `BUILTIN_IDS`** in `desktop/src-tauri/src/managed_agents/custom_harnesses.rs` so custom JSON files cannot shadow it.
|
||||
4. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/<id>.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk.
|
||||
5. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles.
|
||||
|
||||
The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses.
|
||||
|
||||
## Using Any ACP Agent
|
||||
|
||||
The harness works with any agent that implements the [ACP spec](https://agentclientprotocol.com/) over stdio. The requirements are:
|
||||
|
||||
- Accept `initialize` and return a result
|
||||
- Accept `session/new` with `mcpServers` and return a `sessionId`
|
||||
- Accept `session/prompt` with a text message and stream `session/update` notifications
|
||||
- Return a `stopReason` (`end_turn`, `cancelled`, `max_tokens`, etc.)
|
||||
|
||||
Set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` to point at your agent binary.
|
||||
|
||||
## Testing
|
||||
|
||||
See the [root TESTING.md](../../TESTING.md) for the full integration testing guide — automated test suites, multi-agent E2E testing via the ACP harness, and troubleshooting.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session.
|
||||
|
||||
## Session Model
|
||||
|
||||
You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state.
|
||||
|
||||
When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled.
|
||||
|
||||
## Buzz CLI
|
||||
|
||||
The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON.
|
||||
|
||||
| Group | Key commands |
|
||||
|-------|-------------|
|
||||
| `buzz agents` | `draft-create`, `draft-update` |
|
||||
| `buzz messages` | `send`, `get`, `thread`, `search` |
|
||||
| `buzz channels` | `list`, `get`, `create`, `join`, `members` |
|
||||
| `buzz canvas` | `get`, `set` |
|
||||
| `buzz reactions` | `add`, `remove` |
|
||||
| `buzz dms` | `list`, `open` |
|
||||
| `buzz users` | `get`, `set-profile`, `presence` |
|
||||
| `buzz workflows` | `list`, `trigger`, `runs` |
|
||||
| `buzz feed` | `get` |
|
||||
| `buzz social` | `publish`, `notes` |
|
||||
| `buzz repos` | `create`, `get`, `list` |
|
||||
| `buzz issues` | `create`, `get`, `list`, `status` |
|
||||
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
|
||||
| `buzz upload` | `file` |
|
||||
|
||||
Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.
|
||||
|
||||
When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.
|
||||
|
||||
`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.
|
||||
|
||||
## Conversational Agent Creation
|
||||
|
||||
When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.
|
||||
|
||||
`buzz agents draft-create --channel <current-channel-uuid> --display-name <name> --system-prompt <instructions>`
|
||||
|
||||
Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model, credentials, environment variables, or access: Buzz Desktop resolves local runtime/provider/model defaults and new agents default to owner-only access. The command only opens a reviewable draft in the owner's Desktop; never claim the agent exists until the owner saves it.
|
||||
|
||||
For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save.
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
### Mentions
|
||||
|
||||
- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently.
|
||||
- Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery.
|
||||
- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention <hex-or-npub>`. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed.
|
||||
- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically.
|
||||
- Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm.
|
||||
|
||||
### Callback Mentions
|
||||
|
||||
- When you **finish delegated work**, you MUST `@mention` the delegator in the message that reports the result, deliverable, or blocker. This is the #1 cause of stalled collaboration.
|
||||
- This applies to **completed work only.** Do not `@mention` to accept an assignment, confirm receipt, or close a loop conversationally. If you have nothing to report yet, say nothing and report when you do.
|
||||
|
||||
### Threading
|
||||
|
||||
Use the reply destination supplied in the `[Context]` block for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root.
|
||||
|
||||
For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread.
|
||||
|
||||
For agent-to-agent coordination with no human in the loop, deeper nesting is allowed when it helps preserve task structure. Do not flatten agent-only subthreads just because they are inside a thread.
|
||||
|
||||
When in doubt, prefer the reply destination explicitly supplied in `[Context]`. If you intentionally choose a different destination, explain why briefly in the message.
|
||||
|
||||
All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it.
|
||||
|
||||
### General
|
||||
|
||||
- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
|
||||
- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure.
|
||||
- **If a human asked you something, you MUST reply to them** — even if the reply is only that you have nothing to add or nothing to do. Never leave a person waiting on you.
|
||||
- **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure.
|
||||
- **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed.
|
||||
- **Never publish a bare acknowledgement.** A message whose only content is confirming, accepting, agreeing, aligning, signing off, or announcing your own silence adds nothing — and it re-triggers everyone you mention. Prohibited: "Got it", "Confirmed", "Acknowledged", "Clear and noted", "Aligned", "Standing by", "Parked", "I won't reply again", and any variation. If your draft contains nothing beyond acknowledgement, send nothing. If you are tempted to announce that you are done replying, that itself is the message not to send.
|
||||
- For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message.
|
||||
- Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting.
|
||||
- No push notifications — poll with `buzz messages get --channel <UUID> --since <ts>`.
|
||||
- Address people by the name in their own message header.
|
||||
- Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done.
|
||||
- Praise in public; correct in the work, not the person.
|
||||
|
||||
## Startup Recovery
|
||||
|
||||
1. `buzz feed get` — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`.
|
||||
2. `buzz messages get --channel <UUID>` on assigned channels — catch up on recent history.
|
||||
3. Check `AGENTS.md` in your working directory for team context.
|
||||
4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `buzz messages search --query "..."` for cross-channel keyword lookups.
|
||||
|
||||
## Workspace Layout
|
||||
|
||||
Your persistent workspace is in your working directory:
|
||||
|
||||
| Dir | Purpose |
|
||||
|-----|---------|
|
||||
| `RESEARCH/` | Findings and reference material |
|
||||
| `PLANS/` | Project and task plans |
|
||||
| `GUIDES/` | How-to documentation |
|
||||
| `WORK_LOGS/` | Timestamped activity logs |
|
||||
| `OUTBOX/` | Drafts pending review or send |
|
||||
| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does |
|
||||
| `.scratch/` | Ephemeral working files |
|
||||
|
||||
Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions.
|
||||
|
||||
These paths are relative to your working directory — keep exploration there. Never run `find` or recursive searches over `$HOME` or `/` hunting for workspace files: they live under your working directory, not elsewhere on disk.
|
||||
|
||||
## Agent Memory
|
||||
|
||||
Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions.
|
||||
|
||||
- **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline).
|
||||
- **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/<topic>` slug you read on demand — not appended to `core`.
|
||||
- **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later.
|
||||
- **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it.
|
||||
- Cite sources with paths, links, or command outputs. No unsupported claims.
|
||||
|
||||
## Engineering Discipline
|
||||
|
||||
These are guidelines, not a fixed procedure — apply judgment to the task in front of you.
|
||||
|
||||
- **Work in the open.** Your tool calls and reasoning are invisible to humans — narrate as you go in brief messages, and never go dark between "picked up" and "done." If you didn't post it, it didn't happen.
|
||||
- **Be candid.** Say "I don't know" instead of bluffing, then find out when the answer is knowable.
|
||||
- **Understand before changing.** Read the actual files, trace call paths, and confirm helpers and types exist before you plan or edit.
|
||||
- **Plan briefly, then build.** Be opinionated about the safest concrete approach. Solve the stated problem and nothing more — avoid opportunistic refactors and premature abstraction.
|
||||
- **Match what's there.** Follow the surrounding code's conventions and module boundaries. Read neighboring code first.
|
||||
- **Attribute results to the exact state that produced them.** Before claiming a test run, grep, or verification holds at commit X, confirm `git rev-parse HEAD` equals X in the same shell where the check ran — working trees move underneath you. Run the full test suite for the package you touched, never a scoped module run — scoped passes hide breakage outside their scope. Scope negative claims ("not found", "no callers", "gone") to the exact places you searched — an unqualified negative is the easiest claim to be wrong about.
|
||||
- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. If the same failure hits twice, change angle rather than retrying.
|
||||
- **Get a second opinion on risky changes.** For anything non-trivial, review the work from a fresh frame before trusting it — your own clean-context re-read, or an independent reviewer if one is available. Don't tell the reviewer what you expect them to find.
|
||||
- **Self-review before calling it done.** Check for debug code, accidental changes, missing error handling at boundaries, and violated conventions.
|
||||
- **Scale effort to risk.** A typo or config tweak just gets done. A multi-file change touching persistence, auth, or anything user-visible earns the full discipline above.
|
||||
|
||||
## Working in the Repo
|
||||
|
||||
- Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another.
|
||||
- Before committing, read the repo-local git `user.name` / `user.email`; if email is empty, stop and ask. Include the trailers the repo requires.
|
||||
|
||||
## Autonomy
|
||||
|
||||
Resolve questions yourself before asking: read more context, re-examine from a fresh frame, hand a tangent to a separate agent when one's available, then pick the safest option and note the decision so it can be overridden. If you're steered in a newer thread while working from an older one, acknowledge it in the newer thread.
|
||||
|
||||
Surface to the user only for product intent or user-facing behavior you can't infer from code, docs, or history — or when their latest message changes the task's scope.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
//! Fetch the agent's NIP-AE `core` engram at session creation and render it
|
||||
//! into a prompt section.
|
||||
//!
|
||||
//! Scope per Tyler's spec:
|
||||
//! - Fire one synchronous query for the core head when a *new* session is born.
|
||||
//! - If a body is found, emit `[Agent Memory — core]\n<profile>`.
|
||||
//! - If no body is found, emit an onboarding nudge so the agent learns how
|
||||
//! to set its own core.
|
||||
//! - On any *error* (transport, parse), log and emit nothing. We must not
|
||||
//! mistake a relay outage for "no core" — that would invite the agent to
|
||||
//! overwrite real, just-unreachable memory with a fresh profile.
|
||||
//! - Either way, session creation is never blocked.
|
||||
|
||||
use buzz_core::engram::{conversation_key, d_tag, select_head, validate_and_decrypt, Body};
|
||||
use buzz_core::kind::KIND_AGENT_ENGRAM;
|
||||
use nostr::{Event, Keys, PublicKey};
|
||||
|
||||
use crate::relay::RestClient;
|
||||
|
||||
/// Section header rendered into the prompt.
|
||||
const SECTION_LABEL: &str = "Agent Memory — core";
|
||||
|
||||
/// Onboarding nudge for new agents with no core yet.
|
||||
///
|
||||
/// Wording is from Tyler's brief: "No core memory found. Use `buzz mem`
|
||||
/// to create a core memory. Ask your user about yourself."
|
||||
pub const ONBOARDING_NUDGE: &str = "No core memory found. \
|
||||
Use `buzz mem set core \"…\"` to create one (it will hold your identity, \
|
||||
rules, and goals across sessions). Ask your user about yourself.";
|
||||
|
||||
/// Build the rendered prompt section for the agent's core.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Some(profile_section)` when a valid core exists,
|
||||
/// - `Some(nudge_section)` when the relay confirmed absence,
|
||||
/// - `None` when the fetch failed (transport, parse, decrypt) — the caller
|
||||
/// should inject no section in that case so the agent doesn't conclude
|
||||
/// memory is empty.
|
||||
pub async fn build_core_section(
|
||||
rest: &RestClient,
|
||||
agent_keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
) -> Option<String> {
|
||||
match fetch_core_body(rest, agent_keys, owner).await {
|
||||
Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")),
|
||||
Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")),
|
||||
Err(reason) => {
|
||||
tracing::warn!(
|
||||
target: "engram::core",
|
||||
"core fetch failed: {reason} — emitting no section to avoid \
|
||||
confusing a relay outage with an absent core"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query the relay for the core head and decode it. Returns:
|
||||
/// - `Ok(Some(profile))` if a valid core body was found,
|
||||
/// - `Ok(None)` only if the relay confirmed absence (empty result set),
|
||||
/// - `Err(reason)` if the relay returned candidates we could not parse,
|
||||
/// verify, or decrypt — those are NOT treated as absence (would let an
|
||||
/// unreadable but real core be silently overwritten by the onboarding nudge),
|
||||
/// - `Err` for transport / parse errors.
|
||||
async fn fetch_core_body(
|
||||
rest: &RestClient,
|
||||
agent_keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
) -> Result<Option<String>, String> {
|
||||
let k_c = conversation_key(agent_keys.secret_key(), owner);
|
||||
let d = d_tag(&k_c, buzz_core::engram::CORE_SLUG);
|
||||
|
||||
let filter = nostr::Filter::new()
|
||||
.kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16))
|
||||
.author(agent_keys.public_key())
|
||||
.custom_tags(nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), [d])
|
||||
.custom_tags(
|
||||
nostr::SingleLetterTag::lowercase(nostr::Alphabet::P),
|
||||
[owner.to_hex()],
|
||||
)
|
||||
.limit(16);
|
||||
|
||||
let value = rest
|
||||
.query(&[filter])
|
||||
.await
|
||||
.map_err(|e| format!("relay query failed: {e}"))?;
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| "relay query returned non-array".to_string())?;
|
||||
decode_core_body(arr, agent_keys, owner)
|
||||
}
|
||||
|
||||
/// Pure decoder: given the relay's JSON array, decide whether we have a
|
||||
/// readable core, confirmed absence, or an ambiguous unreadable-state.
|
||||
///
|
||||
/// - Empty array → `Ok(None)` (confirmed absence; caller renders the nudge).
|
||||
/// - At least one event decrypts → use the winning head's body.
|
||||
/// * Body::Core → `Ok(Some(profile))`
|
||||
/// * Body::Tombstone or unexpected shape → `Ok(None)` (treat as absent).
|
||||
/// - Non-empty array but nothing decrypts → `Err` (fail closed; caller
|
||||
/// emits no section, so the agent does not assume memory is empty and
|
||||
/// try to overwrite a real-but-unreadable core).
|
||||
fn decode_core_body(
|
||||
arr: &[serde_json::Value],
|
||||
agent_keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
) -> Result<Option<String>, String> {
|
||||
if arr.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut valid_with_body: Vec<(Event, Body)> = Vec::with_capacity(arr.len());
|
||||
let mut candidates_seen = 0usize;
|
||||
let mut last_decrypt_err: Option<String> = None;
|
||||
for ev_json in arr {
|
||||
let event: Event = match serde_json::from_value(ev_json.clone()) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if event.verify().is_err() {
|
||||
continue;
|
||||
}
|
||||
candidates_seen += 1;
|
||||
match validate_and_decrypt(
|
||||
&event,
|
||||
&agent_keys.public_key(),
|
||||
owner,
|
||||
agent_keys.secret_key(),
|
||||
owner,
|
||||
) {
|
||||
Ok(body) => valid_with_body.push((event, body)),
|
||||
Err(e) => {
|
||||
last_decrypt_err = Some(e.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if valid_with_body.is_empty() {
|
||||
if candidates_seen > 0 {
|
||||
return Err(format!(
|
||||
"{candidates_seen} core candidate(s) returned but none decryptable (last error: {})",
|
||||
last_decrypt_err.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
return Err(
|
||||
"relay returned core candidate(s) that could not be parsed or verified".to_string(),
|
||||
);
|
||||
}
|
||||
let events: Vec<Event> = valid_with_body.iter().map(|(e, _)| e.clone()).collect();
|
||||
// `select_head` returns `None` only on an empty iterator, which we
|
||||
// ruled out above.
|
||||
let Some(head) = select_head(events) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let head_id = head.id;
|
||||
let body = valid_with_body
|
||||
.into_iter()
|
||||
.find(|(e, _)| e.id == head_id)
|
||||
.map(|(_, b)| b);
|
||||
match body {
|
||||
Some(Body::Core { profile }) => Ok(Some(profile)),
|
||||
// A tombstone or unexpectedly-shaped head means "no usable core."
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::engram::{build_event, Body};
|
||||
use serde_json::json;
|
||||
|
||||
/// Empty array → confirmed absence → Ok(None), so the caller emits the
|
||||
/// onboarding nudge. This is the only path that maps to "no core."
|
||||
#[test]
|
||||
fn decode_empty_array_is_confirmed_absence() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let out = decode_core_body(&[], &agent, &owner.public_key()).unwrap();
|
||||
assert_eq!(out, None);
|
||||
}
|
||||
|
||||
/// Happy path: a real, decryptable core event yields the profile.
|
||||
#[test]
|
||||
fn decode_valid_core_returns_profile() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let body = Body::Core {
|
||||
profile: "I am Sami.".to_string(),
|
||||
};
|
||||
let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap();
|
||||
let arr = vec![serde_json::to_value(&ev).unwrap()];
|
||||
let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("I am Sami."));
|
||||
}
|
||||
|
||||
/// Regression: when the relay returns a kind:30174 event addressed to
|
||||
/// this agent that we cannot decrypt (here: encrypted to a *different*
|
||||
/// owner's key, so the MAC fails for this agent↔owner pair), we MUST
|
||||
/// return Err and NOT Ok(None). Returning Ok(None) would cause the
|
||||
/// harness to emit the onboarding nudge, inviting the agent to overwrite
|
||||
/// a real-but-unreadable core.
|
||||
#[test]
|
||||
fn decode_undecryptable_candidate_is_err_not_absent() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let wrong_owner = Keys::generate();
|
||||
// Build an engram encrypted to wrong_owner (not owner). It will pass
|
||||
// sig verification but fail MAC/decrypt for the agent↔owner pair.
|
||||
let body = Body::Core {
|
||||
profile: "secret".to_string(),
|
||||
};
|
||||
let ev = build_event(&agent, &wrong_owner.public_key(), &body, 1_700_000_000).unwrap();
|
||||
let arr = vec![serde_json::to_value(&ev).unwrap()];
|
||||
let result = decode_core_body(&arr, &agent, &owner.public_key());
|
||||
assert!(result.is_err(), "expected Err, got: {result:?}");
|
||||
let msg = result.unwrap_err();
|
||||
assert!(msg.contains("decryptable"), "got: {msg}");
|
||||
}
|
||||
|
||||
/// An unexpectedly-shaped head (here: a Memory body in what was supposed
|
||||
/// to be the core slot) is a legitimate, decryptable "no usable core" —
|
||||
/// Ok(None). Real `rm core` is refused at the CLI, so this is a defensive
|
||||
/// branch for malformed data on the wire.
|
||||
#[test]
|
||||
fn decode_non_core_body_is_absent() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let body = Body::Memory {
|
||||
slug: "mem/x".to_string(),
|
||||
value: None,
|
||||
};
|
||||
let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap();
|
||||
let arr = vec![serde_json::to_value(&ev).unwrap()];
|
||||
let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap();
|
||||
assert_eq!(out, None);
|
||||
}
|
||||
|
||||
/// Non-empty array with only garbage entries (not even parseable as
|
||||
/// events) is also treated as a fetch error, not absence.
|
||||
#[test]
|
||||
fn decode_unparseable_candidates_is_err() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let arr = vec![json!({"not": "an event"}), json!("garbage")];
|
||||
let result = decode_core_body(&arr, &agent, &owner.public_key());
|
||||
assert!(result.is_err(), "expected Err, got: {result:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
//! Content filtering and subscription rule matching.
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! - Building an evalexpr context from a Nostr event
|
||||
//! - Evaluating boolean filter expressions with a hard timeout
|
||||
//! - Matching events against ordered subscription rules (first match wins)
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
/// Errors that can occur during filter expression evaluation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FilterError {
|
||||
#[error("expression too long ({len} bytes, max {max})")]
|
||||
ExpressionTooLong { len: usize, max: usize },
|
||||
#[error("evaluation timed out")]
|
||||
Timeout,
|
||||
#[error("evaluation error: {0}")]
|
||||
EvalError(String),
|
||||
}
|
||||
|
||||
/// Variables extracted from a Nostr event for use in filter expressions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilterContext {
|
||||
/// Event content (message body).
|
||||
pub content: String,
|
||||
/// Event author pubkey as hex string.
|
||||
pub author: String,
|
||||
/// Nostr event kind number.
|
||||
pub kind: u32,
|
||||
/// Channel UUID as string.
|
||||
pub channel_id: String,
|
||||
/// Event `created_at` unix timestamp.
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
impl FilterContext {
|
||||
/// Build a `FilterContext` from a Nostr event and its channel UUID.
|
||||
pub fn from_event(event: &nostr::Event, channel_id: uuid::Uuid) -> Self {
|
||||
Self {
|
||||
content: event.content.clone(),
|
||||
author: event.pubkey.to_hex(),
|
||||
kind: event.kind.as_u16() as u32,
|
||||
channel_id: channel_id.to_string(),
|
||||
timestamp: event.created_at.as_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scope of channels a subscription rule applies to.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ChannelScope {
|
||||
/// The literal string `"all"` — matches every channel.
|
||||
All(String),
|
||||
/// An explicit list of channel UUID strings.
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl ChannelScope {
|
||||
/// Returns `true` if this scope covers the given channel UUID.
|
||||
///
|
||||
/// `ChannelScope::All` only matches when the inner string is exactly `"all"`.
|
||||
pub fn matches(&self, channel_id: &uuid::Uuid) -> bool {
|
||||
match self {
|
||||
ChannelScope::All(s) => s == "all",
|
||||
ChannelScope::List(ids) => ids.iter().any(|id| id == &channel_id.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single subscription rule from the agent config.
|
||||
///
|
||||
/// # Thread safety
|
||||
///
|
||||
/// `consecutive_timeouts` is an `AtomicU32` so `match_event` can update it
|
||||
/// without requiring `&mut self` — rules are shared via `Arc<[SubscriptionRule]>`
|
||||
/// across the event-dispatch loop.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SubscriptionRule {
|
||||
/// Human-readable rule name; used as fallback `prompt_tag`.
|
||||
pub name: String,
|
||||
/// Which channels this rule applies to.
|
||||
pub channels: ChannelScope,
|
||||
/// Nostr event kinds to match. Empty = wildcard (all kinds).
|
||||
#[serde(default)]
|
||||
pub kinds: Vec<u32>,
|
||||
/// If `true`, the event must contain a `p` tag referencing the agent pubkey.
|
||||
#[serde(default)]
|
||||
pub require_mention: bool,
|
||||
/// Optional evalexpr boolean expression for fine-grained filtering.
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
/// Tag passed to the prompt template. Falls back to `name` if absent.
|
||||
#[serde(default)]
|
||||
pub prompt_tag: Option<String>,
|
||||
/// Pre-compiled evalexpr AST for the `filter` expression.
|
||||
///
|
||||
/// Populated by `load_rules()` at startup so `match_event` never re-parses
|
||||
/// the expression string on the hot path. `None` when `filter` is `None`
|
||||
/// or the rule was constructed without calling `load_rules()` (e.g. tests).
|
||||
#[serde(skip)]
|
||||
pub compiled_filter: Option<Arc<evalexpr::Node>>,
|
||||
/// Consecutive filter-evaluation timeout counter.
|
||||
///
|
||||
/// Incremented on each timeout; reset on any successful evaluation.
|
||||
/// When this reaches `MAX_CONSECUTIVE_TIMEOUTS`, the rule is treated as
|
||||
/// disabled and `match_event` returns `None` (fail-closed).
|
||||
#[serde(skip)]
|
||||
pub consecutive_timeouts: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl Default for SubscriptionRule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
channels: ChannelScope::All("all".into()),
|
||||
kinds: Vec::new(),
|
||||
require_mention: false,
|
||||
filter: None,
|
||||
prompt_tag: None,
|
||||
compiled_filter: None,
|
||||
consecutive_timeouts: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for SubscriptionRule {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
name: self.name.clone(),
|
||||
channels: self.channels.clone(),
|
||||
kinds: self.kinds.clone(),
|
||||
require_mention: self.require_mention,
|
||||
filter: self.filter.clone(),
|
||||
prompt_tag: self.prompt_tag.clone(),
|
||||
compiled_filter: self.compiled_filter.clone(),
|
||||
// Share the same counter across clones so all copies of a rule
|
||||
// agree on the timeout state.
|
||||
consecutive_timeouts: self.consecutive_timeouts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a successful rule match.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchedRule {
|
||||
/// Zero-based index of the matching rule in the rules slice.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub rule_index: usize,
|
||||
/// Prompt tag to use (rule's `prompt_tag` or its `name`).
|
||||
pub prompt_tag: String,
|
||||
}
|
||||
|
||||
/// Maximum expression length accepted by `evaluate_filter`.
|
||||
///
|
||||
/// Bounds worst-case O(2^n) evaluation paths. The spawn_blocking thread cannot
|
||||
/// be cancelled after a timeout fires, so we cap length before dispatching.
|
||||
const MAX_EXPR_LEN: usize = 4096;
|
||||
|
||||
/// Maximum wall-clock time allowed for a single evalexpr evaluation.
|
||||
const EVAL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Maximum concurrent blocking filter evaluations.
|
||||
///
|
||||
/// The semaphore permit is moved into each `spawn_blocking` closure so it is
|
||||
/// held until the blocking thread finishes — not just until the caller's timeout
|
||||
/// fires. This truly bounds the number of live blocking evals even under repeated
|
||||
/// slow expressions.
|
||||
const MAX_CONCURRENT_FILTER_EVALS: usize = 4;
|
||||
|
||||
/// Semaphore that bounds concurrent `spawn_blocking` filter evaluations.
|
||||
///
|
||||
/// Wrapped in `Arc` so `acquire_owned()` can be used, which returns an
|
||||
/// `OwnedSemaphorePermit` that can be moved into the `spawn_blocking` closure.
|
||||
/// This ensures the permit is held until the blocking task actually finishes —
|
||||
/// not just until the caller's timeout fires — so the semaphore truly bounds
|
||||
/// the number of live blocking threads.
|
||||
static FILTER_EVAL_SEMAPHORE: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILTER_EVALS)));
|
||||
|
||||
/// Evaluate a boolean filter expression against a `FilterContext`.
|
||||
///
|
||||
/// - Caps expression length at [`MAX_EXPR_LEN`] bytes.
|
||||
/// - Acquires an owned permit from [`FILTER_EVAL_SEMAPHORE`] and moves it into
|
||||
/// the blocking closure so it is held until the task finishes, not just until
|
||||
/// the caller's timeout fires.
|
||||
/// - Runs evaluation on a blocking thread with a [`EVAL_TIMEOUT`] hard timeout.
|
||||
/// - When a pre-compiled `node` is provided (via `Arc`), uses
|
||||
/// `node.eval_boolean_with_context()` instead of re-parsing the expression
|
||||
/// string on every call.
|
||||
/// - Registers custom string helpers: `str_contains`, `str_starts_with`,
|
||||
/// `str_ends_with`, `str_len` (duplicated intentionally from buzz-workflow).
|
||||
pub async fn evaluate_filter(
|
||||
expr: &str,
|
||||
ctx: &FilterContext,
|
||||
node: Option<Arc<evalexpr::Node>>,
|
||||
) -> Result<bool, FilterError> {
|
||||
if expr.len() > MAX_EXPR_LEN {
|
||||
return Err(FilterError::ExpressionTooLong {
|
||||
len: expr.len(),
|
||||
max: MAX_EXPR_LEN,
|
||||
});
|
||||
}
|
||||
|
||||
let eval_ctx = build_eval_context(ctx).map_err(FilterError::EvalError)?;
|
||||
let expr_owned = expr.to_owned();
|
||||
|
||||
// Acquire an *owned* permit so it can be moved into the spawn_blocking closure.
|
||||
// The permit is held until the blocking task actually completes — not just until
|
||||
// the caller's timeout fires — so the semaphore truly bounds the number of live
|
||||
// blocking threads even when callers time out.
|
||||
//
|
||||
// The acquire itself is bounded by EVAL_TIMEOUT: if all permits are held by
|
||||
// wedged blocking tasks, we time out instead of blocking the main event loop.
|
||||
let permit = tokio::time::timeout(
|
||||
EVAL_TIMEOUT,
|
||||
Arc::clone(&*FILTER_EVAL_SEMAPHORE).acquire_owned(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| FilterError::Timeout)?
|
||||
.map_err(|e| FilterError::EvalError(format!("semaphore closed: {e}")))?;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
EVAL_TIMEOUT,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Hold the permit for the lifetime of this closure: released only
|
||||
// when the blocking thread returns, not when the caller times out.
|
||||
let _permit = permit;
|
||||
// Use the pre-compiled AST when available; fall back to string parsing.
|
||||
if let Some(node) = node {
|
||||
node.eval_boolean_with_context(&eval_ctx)
|
||||
} else {
|
||||
evalexpr::eval_boolean_with_context(&expr_owned, &eval_ctx)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| FilterError::Timeout)?
|
||||
.map_err(|e| FilterError::EvalError(format!("eval task panicked: {e}")))?
|
||||
.map_err(|e| FilterError::EvalError(e.to_string()))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Build an `evalexpr::HashMapContext` from a `FilterContext`.
|
||||
///
|
||||
/// Variables exposed to expressions:
|
||||
///
|
||||
/// | Name | Type | Source |
|
||||
/// |--------------|--------|---------------------------|
|
||||
/// | `content` | string | `event.content` |
|
||||
/// | `author` | string | `event.pubkey` (hex) |
|
||||
/// | `kind` | int | `event.kind` |
|
||||
/// | `channel_id` | string | channel UUID |
|
||||
/// | `timestamp` | int | `event.created_at` |
|
||||
///
|
||||
/// Also registers `str_contains`, `str_starts_with`, `str_ends_with`,
|
||||
/// `str_len` — duplicated from buzz-workflow intentionally so this crate
|
||||
/// has no runtime dependency on buzz-workflow.
|
||||
fn build_eval_context(ctx: &FilterContext) -> Result<evalexpr::HashMapContext, String> {
|
||||
use evalexpr::*;
|
||||
|
||||
let mut eval_ctx = HashMapContext::new();
|
||||
|
||||
// evalexpr v11 does not ship these helpers; register them manually.
|
||||
|
||||
eval_ctx
|
||||
.set_function(
|
||||
"str_contains".into(),
|
||||
Function::new(|args| {
|
||||
let args = args.as_fixed_len_tuple(2)?;
|
||||
let haystack = args[0].as_string()?;
|
||||
let needle = args[1].as_string()?;
|
||||
Ok(Value::Boolean(haystack.contains(needle.as_str())))
|
||||
}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
eval_ctx
|
||||
.set_function(
|
||||
"str_starts_with".into(),
|
||||
Function::new(|args| {
|
||||
let args = args.as_fixed_len_tuple(2)?;
|
||||
let s = args[0].as_string()?;
|
||||
let prefix = args[1].as_string()?;
|
||||
Ok(Value::Boolean(s.starts_with(prefix.as_str())))
|
||||
}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
eval_ctx
|
||||
.set_function(
|
||||
"str_ends_with".into(),
|
||||
Function::new(|args| {
|
||||
let args = args.as_fixed_len_tuple(2)?;
|
||||
let s = args[0].as_string()?;
|
||||
let suffix = args[1].as_string()?;
|
||||
Ok(Value::Boolean(s.ends_with(suffix.as_str())))
|
||||
}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
eval_ctx
|
||||
.set_function(
|
||||
"str_len".into(),
|
||||
Function::new(|arg| {
|
||||
let s = arg.as_string()?;
|
||||
Ok(Value::Int(s.len() as i64))
|
||||
}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
eval_ctx
|
||||
.set_value("content".into(), Value::String(ctx.content.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
eval_ctx
|
||||
.set_value("author".into(), Value::String(ctx.author.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
eval_ctx
|
||||
.set_value("kind".into(), Value::Int(ctx.kind as i64))
|
||||
.map_err(|e| e.to_string())?;
|
||||
eval_ctx
|
||||
.set_value("channel_id".into(), Value::String(ctx.channel_id.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
eval_ctx
|
||||
.set_value("timestamp".into(), Value::Int(ctx.timestamp as i64))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(eval_ctx)
|
||||
}
|
||||
|
||||
/// Consecutive timeout threshold before a rule is treated as disabled.
|
||||
///
|
||||
/// After this many back-to-back timeouts on a single rule, the rule is logged
|
||||
/// at ERROR level and `match_event` returns `None` (fail-closed). This prevents
|
||||
/// a pathological expression from silently widening the subscription.
|
||||
const MAX_CONSECUTIVE_TIMEOUTS: u32 = 5;
|
||||
|
||||
/// Match a Nostr event against an ordered list of subscription rules.
|
||||
///
|
||||
/// Rules are evaluated in order; the first rule whose conditions all pass
|
||||
/// wins. Returns `None` if no rule matches.
|
||||
///
|
||||
/// # Matching logic (per rule)
|
||||
///
|
||||
/// 1. **channels** — if not `"all"`, the event's channel UUID must be in the list.
|
||||
/// 2. **kinds** — if non-empty, the event kind must be in the list.
|
||||
/// 3. **require_mention** — if `true`, a `p` tag matching `agent_pubkey_hex` must
|
||||
/// exist. Tag kind is checked via `tag.as_slice()` for stable, library-independent
|
||||
/// access.
|
||||
/// 4. **filter** — if `Some`, the evalexpr expression must evaluate to `true`.
|
||||
///
|
||||
/// # Fail-closed filter error handling
|
||||
///
|
||||
/// Any filter evaluation error — including timeout — causes the **entire
|
||||
/// `match_event` call** to return `None` (no match for any rule). We never
|
||||
/// fall through to the next rule on error because that would silently widen
|
||||
/// the subscription: a broken/slow rule would let events through that were
|
||||
/// meant to be gated.
|
||||
///
|
||||
/// After [`MAX_CONSECUTIVE_TIMEOUTS`] consecutive timeouts on a single rule,
|
||||
/// that rule is logged at ERROR and the call returns `None` immediately to
|
||||
/// avoid blocking the event loop indefinitely.
|
||||
pub async fn match_event(
|
||||
event: &nostr::Event,
|
||||
channel_id: uuid::Uuid,
|
||||
rules: &[SubscriptionRule],
|
||||
agent_pubkey_hex: &str,
|
||||
) -> Option<MatchedRule> {
|
||||
let filter_ctx = FilterContext::from_event(event, channel_id);
|
||||
|
||||
for (index, rule) in rules.iter().enumerate() {
|
||||
// 1. Channel scope check.
|
||||
if !rule.channels.matches(&channel_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Kind filter (empty = wildcard).
|
||||
if !rule.kinds.is_empty() && !rule.kinds.contains(&(event.kind.as_u16() as u32)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Mention check — look for a `p` tag whose first element equals
|
||||
// agent_pubkey_hex. Uses tag.as_slice() for stable, library-independent
|
||||
// access — avoids relying on the Display impl of tag kind.
|
||||
if rule.require_mention {
|
||||
let mentioned = event.tags.iter().any(|tag| {
|
||||
let s = tag.as_slice();
|
||||
s.first().map(|k| k.as_str()) == Some("p")
|
||||
&& s.get(1).map(|v| v.as_str()) == Some(agent_pubkey_hex)
|
||||
});
|
||||
if !mentioned {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Optional evalexpr filter expression.
|
||||
if let Some(expr) = &rule.filter {
|
||||
// Skip rules that have timed out too many times — treat as disabled.
|
||||
let prior_timeouts = rule.consecutive_timeouts.load(Ordering::Relaxed);
|
||||
if prior_timeouts >= MAX_CONSECUTIVE_TIMEOUTS {
|
||||
error!(
|
||||
rule = %rule.name,
|
||||
rule_index = index,
|
||||
timeouts = prior_timeouts,
|
||||
"filter rule disabled after too many consecutive timeouts; \
|
||||
failing closed (no match for any rule)"
|
||||
);
|
||||
// Fail-closed: disabled rule → no match for this event.
|
||||
return None;
|
||||
}
|
||||
|
||||
match evaluate_filter(expr, &filter_ctx, rule.compiled_filter.clone()).await {
|
||||
Ok(true) => {
|
||||
// Successful match — reset timeout counter.
|
||||
rule.consecutive_timeouts.store(0, Ordering::Relaxed);
|
||||
}
|
||||
Ok(false) => {
|
||||
rule.consecutive_timeouts.store(0, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
Err(FilterError::Timeout) => {
|
||||
let n = rule.consecutive_timeouts.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
warn!(
|
||||
rule = %rule.name,
|
||||
rule_index = index,
|
||||
consecutive_timeouts = n,
|
||||
"filter expression timed out; failing closed (no match for any rule)"
|
||||
);
|
||||
// Fail-closed: timeout → no match, not next rule.
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
rule = %rule.name,
|
||||
rule_index = index,
|
||||
error = %e,
|
||||
"filter expression error; failing closed (no match for any rule)"
|
||||
);
|
||||
// Fail-closed: any error → no match, not next rule.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed — this rule wins.
|
||||
let prompt_tag = rule.prompt_tag.clone().unwrap_or_else(|| rule.name.clone());
|
||||
|
||||
return Some(MatchedRule {
|
||||
rule_index: index,
|
||||
prompt_tag,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Build a minimal test event with the given kind and content.
|
||||
fn make_event(kind: u32, content: &str) -> nostr::Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::Custom(kind as u16), content)
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build a test event with an explicit `p` tag.
|
||||
fn make_event_with_p_tag(kind: u32, content: &str, p_hex: &str) -> nostr::Event {
|
||||
let keys = Keys::generate();
|
||||
let p_tag = Tag::parse(["p", p_hex]).expect("tag parse");
|
||||
EventBuilder::new(Kind::Custom(kind as u16), content)
|
||||
.tags([p_tag])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn any_channel() -> Uuid {
|
||||
Uuid::new_v4()
|
||||
}
|
||||
|
||||
fn make_rule(
|
||||
name: &str,
|
||||
channels: ChannelScope,
|
||||
kinds: Vec<u32>,
|
||||
mention: bool,
|
||||
filter: Option<&str>,
|
||||
prompt_tag: Option<&str>,
|
||||
) -> SubscriptionRule {
|
||||
SubscriptionRule {
|
||||
name: name.into(),
|
||||
channels,
|
||||
kinds,
|
||||
require_mention: mention,
|
||||
filter: filter.map(|s| s.into()),
|
||||
prompt_tag: prompt_tag.map(|s| s.into()),
|
||||
compiled_filter: None,
|
||||
consecutive_timeouts: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_context_from_event() {
|
||||
let event = make_event(9, "hello world");
|
||||
let channel_id = any_channel();
|
||||
let ctx = FilterContext::from_event(&event, channel_id);
|
||||
|
||||
assert_eq!(ctx.content, "hello world");
|
||||
assert_eq!(ctx.author, event.pubkey.to_hex());
|
||||
assert_eq!(ctx.kind, 9);
|
||||
assert_eq!(ctx.channel_id, channel_id.to_string());
|
||||
assert_eq!(ctx.timestamp, event.created_at.as_secs());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_filter_str_contains() {
|
||||
let event = make_event(9, "P1 incident in production");
|
||||
let ctx = FilterContext::from_event(&event, any_channel());
|
||||
|
||||
let result = evaluate_filter(r#"str_contains(content, "P1")"#, &ctx, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let result = evaluate_filter(r#"str_contains(content, "P2")"#, &ctx, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_filter_kind_check() {
|
||||
let event = make_event(9, "some content");
|
||||
let ctx = FilterContext::from_event(&event, any_channel());
|
||||
|
||||
let result = evaluate_filter("kind == 9", &ctx, None).await.unwrap();
|
||||
assert!(result);
|
||||
|
||||
let result = evaluate_filter("kind == 1", &ctx, None).await.unwrap();
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_filter_too_long() {
|
||||
let event = make_event(9, "content");
|
||||
let ctx = FilterContext::from_event(&event, any_channel());
|
||||
|
||||
let long_expr = "a".repeat(MAX_EXPR_LEN + 1);
|
||||
let err = evaluate_filter(&long_expr, &ctx, None).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
FilterError::ExpressionTooLong { len, max }
|
||||
if len == MAX_EXPR_LEN + 1 && max == MAX_EXPR_LEN
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_filter_precompiled_node() {
|
||||
let event = make_event(9, "hello world");
|
||||
let ctx = FilterContext::from_event(&event, any_channel());
|
||||
|
||||
let node =
|
||||
Arc::new(evalexpr::build_operator_tree(r#"str_contains(content, "hello")"#).unwrap());
|
||||
let result = evaluate_filter(r#"str_contains(content, "hello")"#, &ctx, Some(node))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_event_first_match_wins() {
|
||||
let event = make_event(9, "hello");
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rules = vec![
|
||||
make_rule(
|
||||
"first",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
Some("tag-first"),
|
||||
),
|
||||
make_rule(
|
||||
"second",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
Some("tag-second"),
|
||||
),
|
||||
];
|
||||
|
||||
let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
|
||||
assert_eq!(matched.rule_index, 0);
|
||||
assert_eq!(matched.prompt_tag, "tag-first");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_event_kind_filter() {
|
||||
let event = make_event(9, "hello");
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rules = vec![
|
||||
make_rule(
|
||||
"wrong-kind",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![1],
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
make_rule(
|
||||
"right-kind",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![9],
|
||||
false,
|
||||
None,
|
||||
Some("matched"),
|
||||
),
|
||||
];
|
||||
|
||||
let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
|
||||
assert_eq!(matched.rule_index, 1);
|
||||
assert_eq!(matched.prompt_tag, "matched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_event_require_mention() {
|
||||
let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
||||
|
||||
let event_no_mention = make_event(9, "hello");
|
||||
let event_with_mention = make_event_with_p_tag(9, "hello", agent_pubkey);
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rules = vec![make_rule(
|
||||
"mention-only",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
true,
|
||||
None,
|
||||
Some("mentioned"),
|
||||
)];
|
||||
|
||||
// Without mention — no match.
|
||||
let result = match_event(&event_no_mention, channel_id, &rules, agent_pubkey).await;
|
||||
assert!(result.is_none());
|
||||
|
||||
// With mention — matches.
|
||||
let matched = match_event(&event_with_mention, channel_id, &rules, agent_pubkey)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(matched.prompt_tag, "mentioned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_event_no_match() {
|
||||
let event = make_event(1, "hello");
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rules = vec![make_rule(
|
||||
"kind-9-only",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![9],
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)];
|
||||
|
||||
let result = match_event(&event, channel_id, &rules, "").await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_scope_all() {
|
||||
let scope = ChannelScope::All("all".into());
|
||||
assert!(scope.matches(&Uuid::new_v4()));
|
||||
assert!(scope.matches(&Uuid::new_v4()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_scope_all_invalid_string() {
|
||||
// Only the literal "all" should match; other strings must not.
|
||||
let scope = ChannelScope::All("ALL".into());
|
||||
assert!(!scope.matches(&Uuid::new_v4()));
|
||||
|
||||
let scope = ChannelScope::All("".into());
|
||||
assert!(!scope.matches(&Uuid::new_v4()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_scope_list() {
|
||||
let id_a = Uuid::new_v4();
|
||||
let id_b = Uuid::new_v4();
|
||||
let id_c = Uuid::new_v4();
|
||||
|
||||
let scope = ChannelScope::List(vec![id_a.to_string(), id_b.to_string()]);
|
||||
|
||||
assert!(scope.matches(&id_a));
|
||||
assert!(scope.matches(&id_b));
|
||||
assert!(!scope.matches(&id_c));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prompt_tag_falls_back_to_name() {
|
||||
let event = make_event(9, "hello");
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rules = vec![make_rule(
|
||||
"my-rule",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
None, // no explicit tag
|
||||
)];
|
||||
|
||||
let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
|
||||
assert_eq!(matched.prompt_tag, "my-rule");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_error_fails_closed_no_fallthrough() {
|
||||
// A broken filter on rule[0] must NOT fall through to rule[1].
|
||||
let event = make_event(9, "hello");
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rules = vec![
|
||||
make_rule(
|
||||
"broken-filter",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
false,
|
||||
Some("this is not valid evalexpr syntax !!!"),
|
||||
Some("should-not-match"),
|
||||
),
|
||||
make_rule(
|
||||
"catch-all",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
Some("catch-all"),
|
||||
),
|
||||
];
|
||||
|
||||
// Must return None — not "catch-all".
|
||||
let result = match_event(&event, channel_id, &rules, "").await;
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"filter error must fail closed, not fall through to next rule"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_consecutive_timeouts_disables_rule() {
|
||||
// After MAX_CONSECUTIVE_TIMEOUTS, the rule is skipped and None returned.
|
||||
let event = make_event(9, "hello");
|
||||
let channel_id = any_channel();
|
||||
|
||||
let rule = make_rule(
|
||||
"timed-out-rule",
|
||||
ChannelScope::All("all".into()),
|
||||
vec![],
|
||||
false,
|
||||
Some("kind == 9"),
|
||||
Some("should-not-match"),
|
||||
);
|
||||
// Pre-seed the counter at the threshold.
|
||||
rule.consecutive_timeouts
|
||||
.store(MAX_CONSECUTIVE_TIMEOUTS, Ordering::Relaxed);
|
||||
|
||||
let rules = vec![rule];
|
||||
let result = match_event(&event, channel_id, &rules, "").await;
|
||||
assert!(result.is_none(), "disabled rule must return None");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
fn main() -> anyhow::Result<()> {
|
||||
buzz_acp::run()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 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 {
|
||||
/// Buzz 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>,
|
||||
/// RFC3339 timestamp at which the current turn began, when known.
|
||||
pub started_at: 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>,
|
||||
/// Buzz 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>,
|
||||
/// RFC3339 timestamp at which the current turn began, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: 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(),
|
||||
started_at: context.started_at.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,
|
||||
started_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the authoritative start timestamp to every observer frame for a turn.
|
||||
pub fn context_for_turn(
|
||||
channel_id: Option<uuid::Uuid>,
|
||||
session_id: Option<String>,
|
||||
turn_id: String,
|
||||
started_at: String,
|
||||
) -> ObserverContext {
|
||||
ObserverContext {
|
||||
channel_id: channel_id.map(|id| id.to_string()),
|
||||
session_id,
|
||||
turn_id: Some(turn_id),
|
||||
started_at: Some(started_at),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
//! Lazy agent-pool lifecycle state.
|
||||
//!
|
||||
//! Relay connection, subscription, and event buffering live outside this
|
||||
//! module. This state machine owns only whether a deferred pool has not started,
|
||||
//! is waking, is ready, or is waiting to retry after a failed wake.
|
||||
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(5);
|
||||
const MAX_RETRY_DELAY: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PoolLifecycle<P> {
|
||||
Listening,
|
||||
Waking {
|
||||
attempt: u32,
|
||||
},
|
||||
Ready(P),
|
||||
Failed {
|
||||
attempt: u32,
|
||||
retry_at: Instant,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl<P> PoolLifecycle<P> {
|
||||
pub(crate) fn listening() -> Self {
|
||||
Self::Listening
|
||||
}
|
||||
|
||||
/// Start the first wake, or a due retry, when buffered work exists.
|
||||
///
|
||||
/// Returns the attempt token exactly once per transition into `Waking`;
|
||||
/// callers attach it to the single pool-initialization task and return it
|
||||
/// with the result.
|
||||
pub(crate) fn start_wake_if_due(
|
||||
&mut self,
|
||||
has_pending_work: bool,
|
||||
now: Instant,
|
||||
) -> Option<u32> {
|
||||
if !has_pending_work {
|
||||
return None;
|
||||
}
|
||||
|
||||
let next_attempt = match self {
|
||||
Self::Listening => Some(1),
|
||||
Self::Failed {
|
||||
attempt, retry_at, ..
|
||||
} if now >= *retry_at => Some(attempt.saturating_add(1)),
|
||||
Self::Waking { .. } | Self::Ready(_) | Self::Failed { .. } => None,
|
||||
};
|
||||
|
||||
if let Some(attempt) = next_attempt {
|
||||
*self = Self::Waking { attempt };
|
||||
}
|
||||
next_attempt
|
||||
}
|
||||
|
||||
pub(crate) fn take_ready(&mut self) -> Option<P> {
|
||||
match std::mem::replace(self, Self::Listening) {
|
||||
Self::Ready(pool) => Some(pool),
|
||||
other => {
|
||||
*self = other;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn waking_attempt(&self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Waking { attempt } => Some(*attempt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retry_at(&self) -> Option<Instant> {
|
||||
match self {
|
||||
Self::Failed { retry_at, .. } => Some(*retry_at),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn failed_error(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Failed { error, .. } => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_wake(&mut self, attempt: u32, error: String, now: Instant) -> bool {
|
||||
self.complete_wake(attempt, Err(error), now).is_ok()
|
||||
}
|
||||
|
||||
/// Complete the matching in-flight wake attempt.
|
||||
///
|
||||
/// A failure remains retryable. A result returned outside `Waking`, or from
|
||||
/// an older attempt, is rejected: accepting it could replace a newer pool.
|
||||
pub(crate) fn complete_wake(
|
||||
&mut self,
|
||||
completed_attempt: u32,
|
||||
result: Result<P, String>,
|
||||
now: Instant,
|
||||
) -> Result<(), &'static str> {
|
||||
let attempt = match self {
|
||||
Self::Waking { attempt } if *attempt == completed_attempt => *attempt,
|
||||
Self::Waking { .. } => return Err("wake result attempt did not match Waking attempt"),
|
||||
_ => return Err("wake completed while lifecycle was not Waking"),
|
||||
};
|
||||
|
||||
*self = match result {
|
||||
Ok(pool) => Self::Ready(pool),
|
||||
Err(error) => Self::Failed {
|
||||
attempt,
|
||||
retry_at: now + retry_delay(attempt),
|
||||
error,
|
||||
},
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_delay(attempt: u32) -> Duration {
|
||||
let exponent = attempt.saturating_sub(1).min(63);
|
||||
let multiplier = 1_u64.checked_shl(exponent).unwrap_or(u64::MAX);
|
||||
Duration::from_secs(
|
||||
INITIAL_RETRY_DELAY
|
||||
.as_secs()
|
||||
.saturating_mul(multiplier)
|
||||
.min(MAX_RETRY_DELAY.as_secs()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn first_pending_event_starts_exactly_one_wake() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<()>::listening();
|
||||
|
||||
assert_eq!(lifecycle.start_wake_if_due(false, now), None);
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), None);
|
||||
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failure_retries_only_when_work_exists_and_deadline_is_due() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<()>::listening();
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
lifecycle
|
||||
.complete_wake(1, Err("provider unavailable".into()), now)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
lifecycle.start_wake_if_due(true, now + Duration::from_secs(4)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
lifecycle.start_wake_if_due(false, now + Duration::from_secs(5)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)),
|
||||
Some(2)
|
||||
);
|
||||
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 }));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retry_backoff_doubles_and_caps_at_five_minutes() {
|
||||
let mut now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<()>::listening();
|
||||
|
||||
for attempt in 1..=9 {
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(attempt));
|
||||
assert!(matches!(
|
||||
lifecycle,
|
||||
PoolLifecycle::Waking { attempt: actual } if actual == attempt
|
||||
));
|
||||
lifecycle
|
||||
.complete_wake(attempt, Err("no brain".into()), now)
|
||||
.unwrap();
|
||||
|
||||
let expected = retry_delay(attempt);
|
||||
let retry_at = match &lifecycle {
|
||||
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
|
||||
_ => panic!("failure must enter Failed"),
|
||||
};
|
||||
assert_eq!(retry_at, now + expected);
|
||||
assert!(expected <= MAX_RETRY_DELAY);
|
||||
now = retry_at;
|
||||
}
|
||||
|
||||
assert_eq!(retry_delay(7), MAX_RETRY_DELAY);
|
||||
assert_eq!(retry_delay(u32::MAX), MAX_RETRY_DELAY);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn successful_retry_consumes_pool_and_stops_future_wakes() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::listening();
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
lifecycle
|
||||
.complete_wake(1, Err("first attempt failed".into()), now)
|
||||
.unwrap();
|
||||
|
||||
let retry_at = match &lifecycle {
|
||||
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
|
||||
_ => panic!("expected Failed"),
|
||||
};
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2));
|
||||
lifecycle.complete_wake(2, Ok("pool"), retry_at).unwrap();
|
||||
|
||||
assert!(matches!(lifecycle, PoolLifecycle::Ready("pool")));
|
||||
assert_eq!(
|
||||
lifecycle.start_wake_if_due(true, retry_at + Duration::from_secs(600)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stale_or_duplicate_wake_result_is_rejected() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<()>::listening();
|
||||
assert_eq!(
|
||||
lifecycle.complete_wake(1, Ok(()), now),
|
||||
Err("wake completed while lifecycle was not Waking")
|
||||
);
|
||||
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
lifecycle.complete_wake(1, Ok(()), now).unwrap();
|
||||
assert_eq!(
|
||||
lifecycle.complete_wake(1, Ok(()), now),
|
||||
Err("wake completed while lifecycle was not Waking")
|
||||
);
|
||||
assert!(matches!(lifecycle, PoolLifecycle::Ready(())));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stale_attempt_result_cannot_replace_current_wake() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<&str>::listening();
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
lifecycle
|
||||
.complete_wake(1, Err("attempt one failed".into()), now)
|
||||
.unwrap();
|
||||
|
||||
let retry_at = match &lifecycle {
|
||||
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
|
||||
_ => panic!("expected Failed"),
|
||||
};
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2));
|
||||
assert_eq!(
|
||||
lifecycle.complete_wake(1, Ok("stale pool"), retry_at),
|
||||
Err("wake result attempt did not match Waking attempt")
|
||||
);
|
||||
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 }));
|
||||
lifecycle
|
||||
.complete_wake(2, Ok("current pool"), retry_at)
|
||||
.unwrap();
|
||||
assert!(matches!(lifecycle, PoolLifecycle::Ready("current pool")));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelled_wake_enters_failed_and_can_retry() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<()>::listening();
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
assert_eq!(lifecycle.waking_attempt(), Some(1));
|
||||
assert!(lifecycle.cancel_wake(1, "task panicked".into(), now));
|
||||
assert_eq!(lifecycle.failed_error(), Some("task panicked"));
|
||||
assert_eq!(
|
||||
lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_ready_transfers_pool_exactly_once() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::listening();
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
lifecycle.complete_wake(1, Ok("pool"), now).unwrap();
|
||||
assert_eq!(lifecycle.take_ready(), Some("pool"));
|
||||
assert_eq!(lifecycle.take_ready(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_state_preserves_attempt_deadline_and_error() {
|
||||
let now = Instant::now();
|
||||
let mut lifecycle = PoolLifecycle::<()>::listening();
|
||||
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
|
||||
lifecycle.complete_wake(1, Err("boom".into()), now).unwrap();
|
||||
|
||||
match lifecycle {
|
||||
PoolLifecycle::Failed {
|
||||
attempt,
|
||||
retry_at,
|
||||
error,
|
||||
} => {
|
||||
assert_eq!(attempt, 1);
|
||||
assert_eq!(retry_at, now + Duration::from_secs(5));
|
||||
assert_eq!(error, "boom");
|
||||
}
|
||||
_ => panic!("expected Failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
// Compile and run the lifecycle state-machine contract as an integration target.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/pool_lifecycle.rs"]
|
||||
mod pool_lifecycle;
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "buzz-admin"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Operator CLI for Buzz relay administration"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-admin"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
buzz-db = { workspace = true }
|
||||
buzz-core = { workspace = true }
|
||||
buzz-auth = { workspace = true }
|
||||
buzz-pubsub = { workspace = true }
|
||||
buzz-search = { workspace = true }
|
||||
buzz-audit = { workspace = true }
|
||||
buzz-workflow = { workspace = true }
|
||||
buzz-media = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
deadpool-redis = { workspace = true }
|
||||
# Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process
|
||||
# CryptoProvider installed at startup. The workspace redis TLS feature compiles
|
||||
# both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we
|
||||
# install ring explicitly in main(). Mirrors buzz-relay's setup.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
tracing = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
url = { workspace = true }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
@@ -0,0 +1,331 @@
|
||||
//! buzz-admin 的人类可读界面语言选择。
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use clap::ValueEnum;
|
||||
|
||||
/// 命令行人类可读文本的语言选项。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub enum Language {
|
||||
#[value(name = "auto")]
|
||||
Auto,
|
||||
#[value(name = "en", alias = "en-us", alias = "en-gb")]
|
||||
English,
|
||||
#[value(name = "zh", alias = "zh-cn", alias = "zh-hans")]
|
||||
SimplifiedChinese,
|
||||
#[value(name = "zh-tw", alias = "zh-hant")]
|
||||
TraditionalChinese,
|
||||
}
|
||||
|
||||
impl Default for Language {
|
||||
fn default() -> Self {
|
||||
Self::Auto
|
||||
}
|
||||
}
|
||||
|
||||
/// 已解析的命令行界面 locale。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Locale {
|
||||
En,
|
||||
ZhHans,
|
||||
ZhHant,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
/// 返回该 locale 是否为中文变体。
|
||||
pub const fn is_chinese(self) -> bool {
|
||||
matches!(self, Self::ZhHans | Self::ZhHant)
|
||||
}
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// 宽松解析环境变量或命令行中的语言值。
|
||||
pub fn parse_lossy(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"zh" | "zh-cn" | "zh-hans" | "zh-sg" => Self::SimplifiedChinese,
|
||||
"zh-tw" | "zh-hant" | "zh-hk" | "zh-mo" => Self::TraditionalChinese,
|
||||
"en" | "en-us" | "en-gb" | "en-au" | "en-ca" => Self::English,
|
||||
"auto" | "" => Self::Auto,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `BUZZ_LANGUAGE` 读取语言;未设置时使用自动检测。
|
||||
pub fn from_environment() -> Self {
|
||||
std::env::var("BUZZ_LANGUAGE")
|
||||
.ok()
|
||||
.map_or(Self::Auto, |value| Self::parse_lossy(&value))
|
||||
}
|
||||
|
||||
/// 将语言选项解析为实际使用的 locale。
|
||||
pub fn resolve(self) -> Locale {
|
||||
match self {
|
||||
Self::English => Locale::En,
|
||||
Self::SimplifiedChinese => Locale::ZhHans,
|
||||
Self::TraditionalChinese => Locale::ZhHant,
|
||||
Self::Auto => detect_system_locale(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据原始参数预扫描语言选项,以便在 clap 展示帮助前应用本地化。
|
||||
pub fn locale_for_args(args: &[OsString]) -> Locale {
|
||||
let mut language = Language::from_environment();
|
||||
let mut expect_value = false;
|
||||
for arg in args.iter().skip(1) {
|
||||
let value = arg.to_string_lossy();
|
||||
if expect_value {
|
||||
language = Language::parse_lossy(&value);
|
||||
expect_value = false;
|
||||
} else if value == "--language" {
|
||||
expect_value = true;
|
||||
} else if let Some(value) = value.strip_prefix("--language=") {
|
||||
language = Language::parse_lossy(value);
|
||||
}
|
||||
}
|
||||
language.resolve()
|
||||
}
|
||||
|
||||
fn detect_system_locale() -> Locale {
|
||||
for key in ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] {
|
||||
let Ok(value) = std::env::var(key) else {
|
||||
continue;
|
||||
};
|
||||
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||
if normalized.starts_with("zh-tw")
|
||||
|| normalized.starts_with("zh-hant")
|
||||
|| normalized.starts_with("zh-hk")
|
||||
|| normalized.starts_with("zh-mo")
|
||||
{
|
||||
return Locale::ZhHant;
|
||||
}
|
||||
if normalized.starts_with("zh") {
|
||||
return Locale::ZhHans;
|
||||
}
|
||||
if !normalized.is_empty() && normalized != "c" && normalized != "posix" {
|
||||
return Locale::En;
|
||||
}
|
||||
}
|
||||
Locale::En
|
||||
}
|
||||
|
||||
static CURRENT_LOCALE: OnceLock<Mutex<Locale>> = OnceLock::new();
|
||||
|
||||
fn locale_cell() -> &'static Mutex<Locale> {
|
||||
CURRENT_LOCALE.get_or_init(|| Mutex::new(Locale::En))
|
||||
}
|
||||
|
||||
/// 设置当前进程后续人类可读输出使用的 locale。
|
||||
pub fn set_current(locale: Locale) {
|
||||
if let Ok(mut current) = locale_cell().lock() {
|
||||
*current = locale;
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前进程的人类可读输出 locale。
|
||||
pub fn current() -> Locale {
|
||||
locale_cell()
|
||||
.lock()
|
||||
.map(|guard| *guard)
|
||||
.unwrap_or(Locale::En)
|
||||
}
|
||||
|
||||
/// 将高频命令说明替换为所选中文 locale 的文案。
|
||||
pub fn localize_command(command: &mut clap::Command, locale: Locale) {
|
||||
if !locale.is_chinese() {
|
||||
return;
|
||||
}
|
||||
let name = command.get_name().to_owned();
|
||||
if let Some(about) = about_for(&name, locale) {
|
||||
*command = command.clone().about(about);
|
||||
}
|
||||
*command = command.clone().mut_args(|arg| {
|
||||
let id = arg.get_id().as_str();
|
||||
argument_help(&name, id, locale).map_or(arg.clone(), |help| arg.help(help))
|
||||
});
|
||||
for subcommand in command.get_subcommands_mut() {
|
||||
localize_command(subcommand, locale);
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the visible clap prefix while preserving flag names, values and
|
||||
/// usage text. clap's parser still validates the original English ids and
|
||||
/// enum values; only the human-facing `error:` marker changes.
|
||||
pub fn localize_clap_rendered(rendered: &str, locale: Locale) -> String {
|
||||
if !locale.is_chinese() {
|
||||
return rendered.to_owned();
|
||||
}
|
||||
if let Some(rest) = rendered.strip_prefix("error: ") {
|
||||
format!("{}: {rest}", error_prefix(locale, "usage"))
|
||||
} else {
|
||||
rendered.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn argument_help(command: &str, id: &str, locale: Locale) -> Option<&'static str> {
|
||||
match locale {
|
||||
Locale::ZhHans => Some(match (command, id) {
|
||||
("buzz-admin", "language") => "人类可读界面语言;auto 遵循 BUZZ_LANGUAGE/LANG。",
|
||||
("add-member", "pubkey") | ("remove-member", "pubkey") => {
|
||||
"Nostr 公钥(bech32 npub 或 64 位十六进制)。"
|
||||
}
|
||||
("add-member", "role") => {
|
||||
"角色:\"admin\" 或 \"member\"(默认:member)。不能设为 \"owner\";请使用 RELAY_OWNER_PUBKEY 配置。"
|
||||
}
|
||||
("remove-member", "role") => "仅当成员当前角色匹配此值时才移除;省略则忽略角色。",
|
||||
("list", "limit") => "最多返回的记录数。",
|
||||
("reconcile-channels", "relay_key") => {
|
||||
"用于签名事件的中继私钥(hex)。回退到 BUZZ_RELAY_PRIVATE_KEY;都未设置时生成临时密钥。"
|
||||
}
|
||||
_ => return None,
|
||||
}),
|
||||
Locale::ZhHant => Some(match (command, id) {
|
||||
("buzz-admin", "language") => "人類可讀介面語言;auto 遵循 BUZZ_LANGUAGE/LANG。",
|
||||
("add-member", "pubkey") | ("remove-member", "pubkey") => {
|
||||
"Nostr 公鑰(bech32 npub 或 64 位十六進位)。"
|
||||
}
|
||||
("add-member", "role") => {
|
||||
"角色:\"admin\" 或 \"member\"(預設:member)。不能設為 \"owner\";請使用 RELAY_OWNER_PUBKEY 設定。"
|
||||
}
|
||||
("remove-member", "role") => "僅在成員目前角色符合此值時移除;省略則忽略角色。",
|
||||
("list", "limit") => "最多回傳的記錄數。",
|
||||
("reconcile-channels", "relay_key") => {
|
||||
"用於簽署事件的中繼私鑰(hex)。回退到 BUZZ_RELAY_PRIVATE_KEY;兩者皆未設定時產生暫時金鑰。"
|
||||
}
|
||||
_ => return None,
|
||||
}),
|
||||
Locale::En => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable error prefix used by runtime validation and anyhow failures.
|
||||
pub fn error_prefix(locale: Locale, category: &str) -> &'static str {
|
||||
match (locale, category) {
|
||||
(Locale::ZhHans, "usage") => "用法错误",
|
||||
(Locale::ZhHant, "usage") => "用法錯誤",
|
||||
(Locale::ZhHans, "error") => "错误",
|
||||
(Locale::ZhHant, "error") => "錯誤",
|
||||
(Locale::ZhHans, "warning") => "警告",
|
||||
(Locale::ZhHant, "warning") => "警告",
|
||||
(_, _) => "error",
|
||||
}
|
||||
}
|
||||
|
||||
fn about_for(name: &str, locale: Locale) -> Option<&'static str> {
|
||||
match locale {
|
||||
Locale::ZhHans => Some(match name {
|
||||
"buzz-admin" => "Buzz 实例管理命令行工具",
|
||||
"add-member" => "将公钥加入中继成员列表",
|
||||
"remove-member" => "从中继成员列表移除公钥",
|
||||
"list-members" => "列出所有中继成员",
|
||||
"generate-key" => "生成新的 Nostr 密钥对",
|
||||
"migrate" => "运行待处理的数据库迁移",
|
||||
"product-feedback" => "查看跨社区的 Buzz 产品反馈",
|
||||
"reconcile-channels" => "为缺少发现事件的频道补发事件",
|
||||
"list" => "以 JSON 列出产品反馈",
|
||||
_ => return None,
|
||||
}),
|
||||
Locale::ZhHant => Some(match name {
|
||||
"buzz-admin" => "Buzz 執行個體管理命令列工具",
|
||||
"add-member" => "將公鑰加入中繼成員清單",
|
||||
"remove-member" => "從中繼成員清單移除公鑰",
|
||||
"list-members" => "列出所有中繼成員",
|
||||
"generate-key" => "產生新的 Nostr 金鑰組",
|
||||
"migrate" => "執行待處理的資料庫遷移",
|
||||
"product-feedback" => "查看跨社群的 Buzz 產品回饋",
|
||||
"reconcile-channels" => "為缺少探索事件的頻道補發事件",
|
||||
"list" => "以 JSON 列出產品回饋",
|
||||
_ => return None,
|
||||
}),
|
||||
Locale::En => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回一个稳定的人类可读标签;未知键原样返回以保持兼容。
|
||||
pub fn label<'a>(locale: Locale, key: &'a str) -> &'a str {
|
||||
if !locale.is_chinese() {
|
||||
return match key {
|
||||
"public_key" => "Public key",
|
||||
"secret_key" => "Secret key",
|
||||
"set_private_key" => "Set BUZZ_PRIVATE_KEY to the secret key to use this identity.",
|
||||
"migrations_complete" => "Database migrations complete.",
|
||||
"added" => "added",
|
||||
"already_member" => "already a member",
|
||||
"removed" => "removed",
|
||||
"no_members" => "(no relay members)",
|
||||
"pubkey" => "pubkey",
|
||||
"role" => "role",
|
||||
"added_by" => "added_by",
|
||||
"warning" => "warning",
|
||||
"no_channels" => "No channels in database.",
|
||||
"reconciled" => "Reconciled",
|
||||
_ => key,
|
||||
};
|
||||
}
|
||||
match (locale, key) {
|
||||
(Locale::ZhHans, "public_key") => "公钥",
|
||||
(Locale::ZhHant, "public_key") => "公鑰",
|
||||
(Locale::ZhHans, "secret_key") => "私钥",
|
||||
(Locale::ZhHant, "secret_key") => "私鑰",
|
||||
(Locale::ZhHans, "set_private_key") => "请设置 BUZZ_PRIVATE_KEY 为该私钥以使用此身份。",
|
||||
(Locale::ZhHant, "set_private_key") => "請將 BUZZ_PRIVATE_KEY 設為此私鑰以使用此身分。",
|
||||
(Locale::ZhHans, "migrations_complete") => "数据库迁移已完成。",
|
||||
(Locale::ZhHant, "migrations_complete") => "資料庫遷移已完成。",
|
||||
(Locale::ZhHans, "added") => "已添加",
|
||||
(Locale::ZhHant, "added") => "已新增",
|
||||
(Locale::ZhHans, "already_member") => "已经是成员",
|
||||
(Locale::ZhHant, "already_member") => "已是成員",
|
||||
(Locale::ZhHans, "removed") => "已移除",
|
||||
(Locale::ZhHant, "removed") => "已移除",
|
||||
(Locale::ZhHans, "no_members") => "(没有中继成员)",
|
||||
(Locale::ZhHant, "no_members") => "(沒有中繼成員)",
|
||||
(Locale::ZhHans, "pubkey") => "公钥",
|
||||
(Locale::ZhHant, "pubkey") => "公鑰",
|
||||
(Locale::ZhHans, "role") => "角色",
|
||||
(Locale::ZhHant, "role") => "角色",
|
||||
(Locale::ZhHans, "added_by") => "添加者",
|
||||
(Locale::ZhHant, "added_by") => "新增者",
|
||||
(Locale::ZhHans, "warning") => "警告",
|
||||
(Locale::ZhHant, "warning") => "警告",
|
||||
(Locale::ZhHans, "no_channels") => "数据库中没有频道。",
|
||||
(Locale::ZhHant, "no_channels") => "資料庫中沒有頻道。",
|
||||
(Locale::ZhHans, "reconciled") => "已核对",
|
||||
(Locale::ZhHant, "reconciled") => "已核對",
|
||||
(_, _) => key,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_language_resolves() {
|
||||
assert_eq!(Language::SimplifiedChinese.resolve(), Locale::ZhHans);
|
||||
assert_eq!(Language::TraditionalChinese.resolve(), Locale::ZhHant);
|
||||
assert_eq!(Language::English.resolve(), Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_scan_supports_equals_and_separate_values() {
|
||||
assert_eq!(
|
||||
locale_for_args(&["buzz".into(), "--language".into(), "zh".into()]),
|
||||
Locale::ZhHans
|
||||
);
|
||||
assert_eq!(
|
||||
locale_for_args(&["buzz".into(), "--language=zh-hant".into()]),
|
||||
Locale::ZhHant
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_prefix_localization_preserves_parser_detail() {
|
||||
let rendered = "error: invalid value 'owner' for '--role <ROLE>'\n";
|
||||
assert_eq!(
|
||||
localize_clap_rendered(rendered, Locale::ZhHans),
|
||||
"用法错误: invalid value 'owner' for '--role <ROLE>'\n"
|
||||
);
|
||||
assert_eq!(localize_clap_rendered(rendered, Locale::En), rendered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
//! Buzz instance administration CLI.
|
||||
//!
|
||||
//! # Member management (NIP-43)
|
||||
//!
|
||||
//! ## Why only kind:13534 (membership list), not kind:8000/8001 (deltas)
|
||||
//!
|
||||
//! CLI intentionally does not emit kind 8000/8001 deltas —
|
||||
//! `publish_nip43_delta` is in-process-only (no Redis hop), so a sidecar call
|
||||
//! stores but never pushes. The 13534 list snapshot is the authoritative roster
|
||||
//! and rides Redis to live clients. Do not wire a delta call that passes
|
||||
//! in-process tests and silently no-ops in the deployed `compose exec` path.
|
||||
//!
|
||||
//! ## Same-second domination guard
|
||||
//!
|
||||
//! The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats
|
||||
//! same-second domination for serial invocations; it does NOT serialize
|
||||
//! concurrent CLI processes — two near-simultaneous adds can read the same
|
||||
//! newest timestamp and collide on the bumped second. run.sh serialization is
|
||||
//! the guard against parallel adds (e.g. `xargs -P`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
|
||||
use buzz_core::tenant::{relay_url_authority, TenantContext};
|
||||
use buzz_db::{Db, DbConfig};
|
||||
use buzz_pubsub::{EventTopic, PubSubManager};
|
||||
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use tracing::warn;
|
||||
|
||||
mod i18n;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "buzz-admin", about = "Buzz instance administration")]
|
||||
struct Cli {
|
||||
/// Human-readable interface language; `auto` follows BUZZ_LANGUAGE/LANG.
|
||||
#[arg(
|
||||
long,
|
||||
env = "BUZZ_LANGUAGE",
|
||||
value_enum,
|
||||
global = true,
|
||||
default_value = "auto"
|
||||
)]
|
||||
language: i18n::Language,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Add a pubkey to the relay membership list.
|
||||
///
|
||||
/// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row,
|
||||
/// publishes a kind:13534 membership roster via Redis so live clients see
|
||||
/// the updated list immediately.
|
||||
AddMember {
|
||||
/// Nostr public key — bech32 npub or 64-char hex.
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
|
||||
/// Role: "admin" or "member" (default: member). Cannot be "owner" —
|
||||
/// use RELAY_OWNER_PUBKEY config to set the relay owner.
|
||||
#[arg(long, default_value = "member")]
|
||||
role: String,
|
||||
},
|
||||
/// Remove a pubkey from the relay membership list.
|
||||
///
|
||||
/// Accepts a bech32 npub or 64-char hex pubkey. After removing the DB row,
|
||||
/// publishes a kind:13534 membership roster via Redis. Cannot remove the
|
||||
/// relay owner — change RELAY_OWNER_PUBKEY config instead.
|
||||
RemoveMember {
|
||||
/// Nostr public key — bech32 npub or 64-char hex.
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
|
||||
/// Only remove if the member's current role matches this value.
|
||||
/// Omit to remove regardless of role.
|
||||
#[arg(long)]
|
||||
role: Option<String>,
|
||||
},
|
||||
/// List all relay members.
|
||||
ListMembers,
|
||||
/// Generate a new Nostr keypair (for bootstrapping).
|
||||
GenerateKey,
|
||||
/// Run pending database migrations.
|
||||
Migrate,
|
||||
/// Inspect deployment-wide Buzz product feedback.
|
||||
ProductFeedback {
|
||||
#[command(subcommand)]
|
||||
command: ProductFeedbackCommand,
|
||||
},
|
||||
/// 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
|
||||
/// BUZZ_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>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ProductFeedbackCommand {
|
||||
/// List feedback across every community as JSON.
|
||||
List {
|
||||
/// Maximum records to return.
|
||||
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
||||
limit: u16,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Install the ring CryptoProvider for rustls. The workspace redis TLS
|
||||
// feature compiles both aws-lc-rs and ring in transitively, so rustls can't
|
||||
// auto-select a provider and would panic on the first rediss:// (ElastiCache)
|
||||
// Redis TLS connection without this. Mirrors buzz-relay's main().
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("failed to install rustls crypto provider");
|
||||
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
|
||||
let locale = i18n::locale_for_args(&args);
|
||||
i18n::set_current(locale);
|
||||
let cli = match parse_cli(args, locale) {
|
||||
Ok(cli) => cli,
|
||||
Err(error) => {
|
||||
let code = error.exit_code();
|
||||
// Render first so the visible `error:` marker can follow the
|
||||
// selected locale. Help/version output has no error marker and
|
||||
// is therefore byte-for-byte unchanged apart from translated
|
||||
// command metadata.
|
||||
let rendered = i18n::localize_clap_rendered(&error.render().to_string(), locale);
|
||||
if error.use_stderr() {
|
||||
eprint!("{rendered}");
|
||||
} else {
|
||||
print!("{rendered}");
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
};
|
||||
|
||||
let code = match run(cli).await {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("{}: {e}", i18n::error_prefix(locale, "error"));
|
||||
5
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn parse_cli(args: Vec<std::ffi::OsString>, locale: i18n::Locale) -> Result<Cli, clap::Error> {
|
||||
let mut command = Cli::command();
|
||||
i18n::localize_command(&mut command, locale);
|
||||
let matches = command.try_get_matches_from(args)?;
|
||||
Cli::from_arg_matches(&matches)
|
||||
}
|
||||
|
||||
async fn run(cli: Cli) -> Result<i32> {
|
||||
i18n::set_current(cli.language.resolve());
|
||||
let locale = i18n::current();
|
||||
match cli.command {
|
||||
Command::GenerateKey => {
|
||||
let keys = Keys::generate();
|
||||
println!(
|
||||
"{}: {}",
|
||||
i18n::label(locale, "public_key"),
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
println!(
|
||||
"{}: {}",
|
||||
i18n::label(locale, "secret_key"),
|
||||
keys.secret_key().display_secret()
|
||||
);
|
||||
if locale.is_chinese() {
|
||||
println!("\n{}", i18n::label(locale, "set_private_key"));
|
||||
} else {
|
||||
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
Command::Migrate => {
|
||||
let db = connect_db().await?;
|
||||
db.migrate().await?;
|
||||
println!("{}", i18n::label(locale, "migrations_complete"));
|
||||
Ok(0)
|
||||
}
|
||||
Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await,
|
||||
Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await,
|
||||
Command::ListMembers => cmd_list_members().await,
|
||||
Command::ProductFeedback {
|
||||
command: ProductFeedbackCommand::List { limit },
|
||||
} => cmd_list_product_feedback(limit).await,
|
||||
Command::ReconcileChannels { relay_key } => {
|
||||
reconcile_channels(relay_key).await?;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
|
||||
let locale = i18n::current();
|
||||
if let Err(msg) = validate_role(&role, locale) {
|
||||
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg, locale) {
|
||||
Ok(h) => h,
|
||||
Err(msg) => {
|
||||
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
|
||||
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
||||
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
match db
|
||||
.add_relay_member(tenant.community(), &pubkey_hex, &role, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
if locale.is_chinese() {
|
||||
println!(
|
||||
"{} {pubkey_hex},角色:{role}",
|
||||
i18n::label(locale, "added")
|
||||
);
|
||||
} else {
|
||||
println!("added {pubkey_hex} as {role}");
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
if locale.is_chinese() {
|
||||
println!(
|
||||
"{}:{pubkey_hex}(未更改)",
|
||||
i18n::label(locale, "already_member")
|
||||
);
|
||||
} else {
|
||||
println!("already a member: {pubkey_hex} (no change)");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{}: {}",
|
||||
i18n::error_prefix(locale, "error"),
|
||||
if locale.is_chinese() {
|
||||
format!("数据库写入失败:{e}")
|
||||
} else {
|
||||
format!("DB write failed: {e}")
|
||||
}
|
||||
);
|
||||
return Ok(5);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
|
||||
if locale.is_chinese() {
|
||||
eprintln!("警告:成员已写入数据库,但发布成员列表失败:{e}");
|
||||
} else {
|
||||
eprintln!("warning: member added to DB but list publish failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> Result<i32> {
|
||||
let locale = i18n::current();
|
||||
if let Some(ref role) = role_filter {
|
||||
if let Err(msg) = validate_role(role, locale) {
|
||||
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
|
||||
return Ok(1);
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg, locale) {
|
||||
Ok(h) => h,
|
||||
Err(msg) => {
|
||||
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
|
||||
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
||||
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
use buzz_db::relay_members::RemoveResult;
|
||||
let result = if let Some(ref role) = role_filter {
|
||||
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
|
||||
.await
|
||||
} else {
|
||||
db.remove_relay_member(tenant.community(), &pubkey_hex)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(RemoveResult::Removed) => {
|
||||
if locale.is_chinese() {
|
||||
println!("{} {pubkey_hex}", i18n::label(locale, "removed"));
|
||||
} else {
|
||||
println!("removed {pubkey_hex}");
|
||||
}
|
||||
}
|
||||
Ok(RemoveResult::NotFound) => {
|
||||
if locale.is_chinese() {
|
||||
eprintln!("错误:找不到成员:{pubkey_hex}");
|
||||
} else {
|
||||
eprintln!("error: member not found: {pubkey_hex}");
|
||||
}
|
||||
return Ok(2);
|
||||
}
|
||||
Ok(RemoveResult::IsOwner) => {
|
||||
if locale.is_chinese() {
|
||||
eprintln!(
|
||||
"错误:无法移除中继所有者:{pubkey_hex}\n请修改 RELAY_OWNER_PUBKEY 后重启。"
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"error: cannot remove relay owner: {pubkey_hex}\n\
|
||||
To change the owner, update RELAY_OWNER_PUBKEY and restart."
|
||||
);
|
||||
}
|
||||
return Ok(3);
|
||||
}
|
||||
Ok(RemoveResult::RoleMismatch) => {
|
||||
let role_str = role_filter.as_deref().unwrap_or("(unknown)");
|
||||
if locale.is_chinese() {
|
||||
eprintln!("错误:角色不匹配——{pubkey_hex} 当前不是“{role_str}”");
|
||||
} else {
|
||||
eprintln!("error: role mismatch — {pubkey_hex} is not currently '{role_str}'");
|
||||
}
|
||||
return Ok(4);
|
||||
}
|
||||
Err(e) => {
|
||||
if locale.is_chinese() {
|
||||
eprintln!("错误:数据库写入失败:{e}");
|
||||
} else {
|
||||
eprintln!("error: DB write failed: {e}");
|
||||
}
|
||||
return Ok(5);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
|
||||
if locale.is_chinese() {
|
||||
eprintln!("警告:成员已从数据库移除,但发布成员列表失败:{e}");
|
||||
} else {
|
||||
eprintln!("warning: member removed from DB but list publish failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn cmd_list_product_feedback(limit: u16) -> Result<i32> {
|
||||
let db = connect_db().await?;
|
||||
let feedback = db.list_product_feedback(i64::from(limit)).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&feedback)?);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn cmd_list_members() -> Result<i32> {
|
||||
let locale = i18n::current();
|
||||
let db = connect_db().await?;
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
let members = db.list_relay_members(tenant.community()).await?;
|
||||
|
||||
if members.is_empty() {
|
||||
println!("{}", i18n::label(locale, "no_members"));
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<66} {:<8} {:<66} created_at",
|
||||
i18n::label(locale, "pubkey"),
|
||||
i18n::label(locale, "role"),
|
||||
i18n::label(locale, "added_by")
|
||||
);
|
||||
println!("{}", "-".repeat(160));
|
||||
for m in &members {
|
||||
let added_by = m.added_by.as_deref().unwrap_or("-");
|
||||
println!(
|
||||
"{:<66} {:<8} {:<66} {}",
|
||||
m.pubkey,
|
||||
m.role,
|
||||
added_by,
|
||||
m.created_at.format("%Y-%m-%dT%H:%M:%SZ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
|
||||
/// Only the explanatory prose is localized; the role values remain wire
|
||||
/// literals because they are persisted in the membership table and event.
|
||||
fn validate_role(role: &str, locale: i18n::Locale) -> std::result::Result<(), String> {
|
||||
match role {
|
||||
"member" | "admin" => Ok(()),
|
||||
"owner" if matches!(locale, i18n::Locale::ZhHans) => {
|
||||
Err("角色 'owner' 不能通过 CLI 设置,请使用 RELAY_OWNER_PUBKEY 配置".to_string())
|
||||
}
|
||||
"owner" if matches!(locale, i18n::Locale::ZhHant) => {
|
||||
Err("角色 'owner' 不能透过 CLI 设置,请使用 RELAY_OWNER_PUBKEY 配置".to_string())
|
||||
}
|
||||
"owner" => {
|
||||
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
|
||||
}
|
||||
other if matches!(locale, i18n::Locale::ZhHans) => {
|
||||
Err(format!("无效角色 '{other}':必须是 'member' 或 'admin'"))
|
||||
}
|
||||
other if matches!(locale, i18n::Locale::ZhHant) => {
|
||||
Err(format!("无效角色 '{other}':必须是 'member' 或 'admin'"))
|
||||
}
|
||||
other => Err(format!(
|
||||
"invalid role '{other}': must be 'member' or 'admin'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a bech32 npub or 64-char hex pubkey into lowercase hex.
|
||||
fn parse_pubkey_hex(input: &str, locale: i18n::Locale) -> std::result::Result<String, String> {
|
||||
nostr::PublicKey::parse(input)
|
||||
.map(|pk| pk.to_hex())
|
||||
.map_err(|e| match locale {
|
||||
i18n::Locale::ZhHans => format!("无效公钥 '{input}':{e}"),
|
||||
i18n::Locale::ZhHant => format!("无效公钥 '{input}':{e}"),
|
||||
i18n::Locale::En => format!("invalid pubkey '{input}': {e}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish kind:13534 with `custom_created_at = max(now, newest_existing + 1s)`.
|
||||
///
|
||||
/// Guarantees the new event is not dominated by a same-second prior invocation,
|
||||
/// so `replace_addressable_event` always inserts and dispatches to Redis.
|
||||
///
|
||||
/// See module-level doc for the TOCTOU caveat on concurrent CLI processes.
|
||||
async fn publish_membership_list_with_bump(
|
||||
db: &Db,
|
||||
pubsub: &Arc<PubSubManager>,
|
||||
relay_keypair: &Keys,
|
||||
tenant: &TenantContext,
|
||||
) -> Result<()> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let relay_pubkey = relay_keypair.public_key();
|
||||
let relay_pubkey_bytes = relay_pubkey.to_bytes();
|
||||
|
||||
// Query the newest existing kind:13534 for this relay's pubkey (channel_id=None).
|
||||
let newest_ts = db
|
||||
.get_latest_global_replaceable(
|
||||
tenant.community(),
|
||||
KIND_NIP43_MEMBERSHIP_LIST as i32,
|
||||
&relay_pubkey_bytes,
|
||||
)
|
||||
.await?
|
||||
.map(|e| e.event.created_at.as_secs());
|
||||
|
||||
// custom_created_at = max(now, existing + 1s) — defeats same-second domination.
|
||||
let ts = match newest_ts {
|
||||
Some(existing) => (existing + 1).max(now),
|
||||
None => now,
|
||||
};
|
||||
|
||||
let members = db.list_relay_members(tenant.community()).await?;
|
||||
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
|
||||
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
|
||||
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
|
||||
for member in &members {
|
||||
tags.push(
|
||||
Tag::parse(["member", &member.pubkey, &member.role])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "")
|
||||
.tags(tags)
|
||||
.custom_created_at(nostr::Timestamp::from(ts))
|
||||
.sign_with_keys(relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
|
||||
|
||||
let (stored, was_inserted) = db
|
||||
.replace_addressable_event(tenant.community(), &event, None)
|
||||
.await?;
|
||||
if was_inserted {
|
||||
// Publish to Redis so live clients receive the updated roster.
|
||||
// Community-global scope (EventTopic::Global) matches the relay's own
|
||||
// membership-list publish path; the tenant fixes the community.
|
||||
if let Err(e) = pubsub
|
||||
.publish_event(tenant, EventTopic::Global, &stored.event)
|
||||
.await
|
||||
{
|
||||
warn!("Redis publish of kind:13534 failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
member_count = members.len(),
|
||||
ts,
|
||||
"NIP-43 membership list published by buzz-admin"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to DB, Redis pub/sub, and load the relay keypair.
|
||||
///
|
||||
/// `BUZZ_RELAY_PRIVATE_KEY` is required — the CLI signs kind:13534 events.
|
||||
async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {
|
||||
let db = connect_db().await?;
|
||||
|
||||
let relay_keypair = {
|
||||
let hex = std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member.\n\
|
||||
The relay must have a stable signing key to publish kind:13534 events."
|
||||
)
|
||||
})?;
|
||||
Keys::parse(&hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))?
|
||||
};
|
||||
|
||||
let redis_url =
|
||||
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
|
||||
|
||||
let redis_pool = {
|
||||
let cfg = deadpool_redis::Config::from_url(&redis_url);
|
||||
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
|
||||
};
|
||||
|
||||
let pubsub = Arc::new(
|
||||
PubSubManager::new(&redis_url, redis_pool)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("PubSub init failed: {e}"))?,
|
||||
);
|
||||
|
||||
Ok((db, pubsub, relay_keypair))
|
||||
}
|
||||
|
||||
async fn connect_db() -> Result<Db> {
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
|
||||
let db = Db::new(&DbConfig {
|
||||
database_url: db_url,
|
||||
..DbConfig::default()
|
||||
})
|
||||
.await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Resolve the deployment's tenant from the configured `RELAY_URL` host.
|
||||
///
|
||||
/// `buzz-admin` runs inside the relay container (`compose exec relay
|
||||
/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same
|
||||
/// single community against the durable `communities` host map. This is
|
||||
/// deliberately NOT a default tenant: an unmapped host fails closed with an
|
||||
/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is
|
||||
/// single-community per invocation — there is no cross-community sweep.
|
||||
async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
|
||||
let relay_url =
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
|
||||
// Derive the authority the *same* way startup seeding and live request
|
||||
// resolution do (`buzz_core::tenant::relay_url_authority`): host plus an
|
||||
// explicit non-default port, IPv6 brackets preserved. A plain
|
||||
// `Url::host_str()` drops the port/brackets, so for `ws://localhost:3000`
|
||||
// the admin would look up `localhost` while startup seeded `localhost:3000`
|
||||
// — and `wss://relay.example:8443` would resolve `relay.example`. Sharing
|
||||
// the helper keeps buzz-admin byte-identical to the community startup seeds.
|
||||
let host = relay_url_authority(&relay_url);
|
||||
let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"RELAY_URL host '{host}' is not mapped to a community.\n\
|
||||
buzz-admin operates on the configured relay's community; ensure the \
|
||||
relay has started and seeded its community (or set RELAY_URL to a \
|
||||
mapped host)."
|
||||
)
|
||||
})?;
|
||||
Ok(TenantContext::resolved(record.id, record.host))
|
||||
}
|
||||
|
||||
async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
|
||||
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
|
||||
use buzz_db::event::EventQuery;
|
||||
|
||||
let db = connect_db().await?;
|
||||
|
||||
// Resolve relay signing key: arg > env > ephemeral
|
||||
let locale = i18n::current();
|
||||
let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) {
|
||||
Some(key_hex) => {
|
||||
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
|
||||
}
|
||||
None => {
|
||||
let k = Keys::generate();
|
||||
if locale.is_chinese() {
|
||||
eprintln!(
|
||||
"警告:未提供中继密钥,将使用临时密钥 {}",
|
||||
k.public_key().to_hex()
|
||||
);
|
||||
eprintln!("本次运行结束后,用此密钥签名的事件将无法验证。");
|
||||
eprintln!("生产环境请传入 --relay-key 或设置 BUZZ_RELAY_PRIVATE_KEY。");
|
||||
} else {
|
||||
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 BUZZ_RELAY_PRIVATE_KEY for production use.");
|
||||
}
|
||||
k
|
||||
}
|
||||
};
|
||||
|
||||
let tenant = resolve_admin_tenant(&db).await?;
|
||||
let channels = db.list_channels(tenant.community(), None).await?;
|
||||
if channels.is_empty() {
|
||||
println!("{}", i18n::label(locale, "no_channels"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut reconciled = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
|
||||
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),
|
||||
..EventQuery::for_community(tenant.community())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if !existing.is_empty() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let members = db.get_members(tenant.community(), 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(tags)
|
||||
.sign_with_keys(&relay_keys)
|
||||
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
|
||||
db.replace_addressable_event(tenant.community(), &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(tags)
|
||||
.sign_with_keys(&relay_keys)
|
||||
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
|
||||
db.replace_addressable_event(tenant.community(), &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(tags)
|
||||
.sign_with_keys(&relay_keys)
|
||||
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
|
||||
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
||||
.await?;
|
||||
}
|
||||
|
||||
reconciled += 1;
|
||||
}
|
||||
|
||||
if locale.is_chinese() {
|
||||
println!(
|
||||
"{} {reconciled} 个频道({skipped} 个已有事件,共 {} 个)。",
|
||||
i18n::label(locale, "reconciled"),
|
||||
channels.len()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Reconciled {reconciled} channels ({skipped} already had events, {} total).",
|
||||
channels.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn member_validation_localizes_prose_but_preserves_role_literals() {
|
||||
assert!(validate_role("member", i18n::Locale::ZhHans).is_ok());
|
||||
assert!(validate_role("admin", i18n::Locale::ZhHans).is_ok());
|
||||
|
||||
let owner = validate_role("owner", i18n::Locale::ZhHans).unwrap_err();
|
||||
assert!(owner.contains("不能通过 CLI 设置"));
|
||||
assert!(owner.contains("'owner'"));
|
||||
assert!(owner.contains("RELAY_OWNER_PUBKEY"));
|
||||
|
||||
let invalid = validate_role("guest", i18n::Locale::ZhHans).unwrap_err();
|
||||
assert!(invalid.contains("无效角色 'guest'"));
|
||||
assert!(invalid.contains("'member'"));
|
||||
assert!(invalid.contains("'admin'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubkey_validation_preserves_the_rejected_input() {
|
||||
let error = parse_pubkey_hex("not-a-pubkey", i18n::Locale::ZhHans).unwrap_err();
|
||||
assert!(error.starts_with("无效公钥 'not-a-pubkey'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localized_admin_help_keeps_flags_and_role_values_stable() {
|
||||
let mut command = Cli::command();
|
||||
i18n::localize_command(&mut command, i18n::Locale::ZhHans);
|
||||
let add_member = command
|
||||
.get_subcommands()
|
||||
.find(|subcommand| subcommand.get_name() == "add-member")
|
||||
.expect("add-member command");
|
||||
let role_help = add_member
|
||||
.get_arguments()
|
||||
.find(|arg| arg.get_id().as_str() == "role")
|
||||
.and_then(|arg| arg.get_help())
|
||||
.map(ToString::to_string)
|
||||
.expect("role help");
|
||||
assert!(role_help.contains("'owner'") || role_help.contains("\"owner\""));
|
||||
assert!(role_help.contains("RELAY_OWNER_PUBKEY"));
|
||||
assert!(add_member
|
||||
.get_arguments()
|
||||
.any(|arg| arg.get_long() == Some("role")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "buzz-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"]
|
||||
|
||||
[lib]
|
||||
name = "buzz_agent"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-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", "net"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls", "form"] }
|
||||
rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] }
|
||||
arc-swap = "1"
|
||||
getrandom = "0.4"
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# OAuth 2.0 PKCE for Databricks (and future browser-auth providers).
|
||||
async-trait = "0.1"
|
||||
axum = { workspace = true }
|
||||
base64 = "0.22"
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
urlencoding = "2"
|
||||
webbrowser = "1"
|
||||
dirs = "6"
|
||||
|
||||
[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"] }
|
||||
axum = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,381 @@
|
||||
# buzz-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, buzz-acp, …) and an agent. [MCP](https://modelcontextprotocol.io) is how the agent talks to its tools.
|
||||
|
||||
`buzz-agent` is the agent.
|
||||
|
||||
## What It Is
|
||||
|
||||
```
|
||||
+--------+ stdio (JSON-RPC 2.0) +---------------+
|
||||
| client | <----------------------> | buzz-agent |
|
||||
+--------+ ACP frames +---------------+
|
||||
│ │
|
||||
│ │ rmcp (stdio)
|
||||
│ ▼
|
||||
│ MCP servers
|
||||
│ (your tools)
|
||||
▼
|
||||
HTTPS
|
||||
│
|
||||
▼
|
||||
Anthropic Messages API,
|
||||
OpenRouter, or any OpenAI-compat
|
||||
(vLLM, llama.cpp, Databricks,
|
||||
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 buzz-agent
|
||||
|
||||
# Run against Anthropic
|
||||
BUZZ_AGENT_PROVIDER=anthropic \
|
||||
ANTHROPIC_API_KEY=sk-ant-... \
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-5 \
|
||||
./target/release/buzz-agent
|
||||
|
||||
# Or any OpenAI-compatible endpoint
|
||||
BUZZ_AGENT_PROVIDER=openai \
|
||||
OPENAI_COMPAT_API_KEY=sk-... \
|
||||
OPENAI_COMPAT_MODEL=gpt-5 \
|
||||
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
|
||||
./target/release/buzz-agent
|
||||
|
||||
# Or OpenRouter
|
||||
BUZZ_AGENT_PROVIDER=openrouter \
|
||||
OPENROUTER_API_KEY=sk-or-v1-... \
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \
|
||||
./target/release/buzz-agent
|
||||
|
||||
# Or Databricks model serving via OAuth 2.0 PKCE
|
||||
BUZZ_AGENT_PROVIDER=databricks \
|
||||
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
|
||||
DATABRICKS_MODEL=goose-claude-4-6-sonnet \
|
||||
./target/release/buzz-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":"buzz-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 |
|
||||
|---|---|---|
|
||||
| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
|
||||
| `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, Ollama, etc. |
|
||||
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
|
||||
| `OPENROUTER_API_KEY` | — | Required when provider=openrouter. |
|
||||
| `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. |
|
||||
| `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | |
|
||||
| `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. |
|
||||
| `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. |
|
||||
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
|
||||
| `BUZZ_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. |
|
||||
| `BUZZ_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. |
|
||||
| `BUZZ_AGENT_MAX_ROUNDS` | `0` | Tool-loop iteration cap. 0 = unlimited. |
|
||||
| `BUZZ_AGENT_MAX_OUTPUT_TOKENS` | `32768` | Per LLM call. Headroom for large tool-call inputs (e.g. file writes via heredoc); Sonnet 4 / Opus 4 cap at 64K. |
|
||||
| `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. |
|
||||
| `BUZZ_AGENT_MAX_HANDOFFS` | `10` | Max context handoffs per session before falling back to truncation. |
|
||||
| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). |
|
||||
| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds |
|
||||
| `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) |
|
||||
| `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. |
|
||||
| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
|
||||
| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
|
||||
| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. |
|
||||
| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. |
|
||||
|
||||
|
||||
## Reply Guard
|
||||
|
||||
Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop
|
||||
sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is
|
||||
about to end without any recognized attempt to post to Buzz gets a reminder that
|
||||
its assistant text is invisible to humans, and is rerolled.
|
||||
|
||||
This exists because a Buzz agent's reasoning and tool output are not shown to
|
||||
anyone. A turn that does real work and never posts is a silent failure — the
|
||||
requester waits on a result that was produced and thrown away.
|
||||
|
||||
Mesh agents get it by default because they run on small local models, which are
|
||||
the ones most likely to do the work and then end the turn without publishing it.
|
||||
Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a
|
||||
mesh agent back out; the default never overrides an explicit value.
|
||||
|
||||
**Advisory, never a trap.** At most two reminders, then the turn ends whether or
|
||||
not anything was published. The guard catches accidental omission; it does not
|
||||
compel speech. The reminder text explicitly licenses silence, because the
|
||||
built-in system prompt says publishing is optional and silence is often the
|
||||
correct outcome.
|
||||
|
||||
**Recognition contract.** A turn counts as having replied when it issues a call
|
||||
that:
|
||||
|
||||
- resolves to a registered, non-hook tool (a hallucinated tool name is rejected
|
||||
at preflight and never runs, so it must not disarm the guard),
|
||||
- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly
|
||||
`shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and
|
||||
- whose `command` argument contains `messages send` or `reactions add`.
|
||||
|
||||
`messages send` also covers `messages send-diff`. Reactions count because the
|
||||
built-in prompt directs agents to react rather than post a bare
|
||||
acknowledgement, so nagging an agent that reacted would punish documented
|
||||
behavior.
|
||||
|
||||
Detection is checked **after** the per-turn tool-call cap
|
||||
(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded
|
||||
never ran.
|
||||
|
||||
**It recognizes an attempt, not a successful publish.** Only the command text is
|
||||
inspected, never the exit status. A send that fails still satisfies the guard —
|
||||
which is fine, since a failed send already returns a non-zero exit and error
|
||||
JSON to the model, louder feedback than a reminder.
|
||||
|
||||
**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or
|
||||
buried in a wrapper script is missed, so that turn is reminded despite having
|
||||
posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so
|
||||
that turn is not reminded. Missing a real post is the expensive direction, and
|
||||
substring matching is the forgiving one there. Neither edge is pinned by a test;
|
||||
the matcher is free to improve.
|
||||
|
||||
**Budget.** Reminders ride the existing `_Stop` gate and share
|
||||
`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection.
|
||||
At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off
|
||||
along with the hooks. A round carrying both a `_Stop` hook objection and a
|
||||
reminder costs one rejection and delivers both texts. This is not a new
|
||||
lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md).
|
||||
|
||||
|
||||
## Providers
|
||||
|
||||
`buzz-agent` speaks a few HTTP dialects. Pick with `BUZZ_AGENT_PROVIDER`.
|
||||
|
||||
| Provider | `BUZZ_AGENT_PROVIDER` | Endpoint (auto) | Tested with |
|
||||
|---|---|---|---|
|
||||
| Anthropic | `anthropic` | `POST {base}/v1/messages` | claude-sonnet-4-5, claude-opus-4 |
|
||||
| OpenAI | `openai` | `POST {base}/responses` | gpt-5, gpt-5-mini, o4-mini, gpt-4o |
|
||||
| vLLM | `openai` | `POST {base}/chat/completions` | any tool-calling model |
|
||||
| llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF |
|
||||
| Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder |
|
||||
| Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude |
|
||||
| OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) |
|
||||
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
|
||||
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 |
|
||||
|
||||
If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.
|
||||
|
||||
`provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format).
|
||||
|
||||
By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway).
|
||||
|
||||
`provider=openrouter` is first-class, not routed through `provider=openai`: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top —
|
||||
|
||||
- `reasoning.effort` is set on the request when reasoning effort is configured. The request deliberately carries no `provider.require_parameters` filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise `reasoning`, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead.
|
||||
- The response's `reasoning_details` array (opaque extended-thinking payload) is captured and replayed byte-for-byte on the next turn's assistant message, so multi-turn tool use keeps the model's chain-of-thought.
|
||||
- `anthropic/*` models get Anthropic-style `cache_control` breakpoints injected on the system message and the last two user messages.
|
||||
- Retryable statuses (429 and typed `provider_overloaded` 503) honor the documented `Retry-After` header (clamped to a small ceiling — see `RETRY_AFTER_CAP_SECS` in `llm.rs` — since the sleep happens outside `BUZZ_AGENT_LLM_TIMEOUT_SECS`); 502 and untyped 503 retry with jittered backoff instead. `401` is treated as an expired/invalid key and refreshed once, while `402` (no credits) and `403` (guardrail/moderation/permission) fail immediately without retry.
|
||||
|
||||
`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box<dyn>`, no async-trait. Adding a 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 | `BUZZ_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 | Unlimited concurrent sessions by default (configurable via `BUZZ_AGENT_MAX_SESSIONS`). 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 | `BUZZ_AGENT_MAX_LINE_BYTES` |
|
||||
| Single prompt | 1 MiB | `MAX_PROMPT_BYTES` |
|
||||
| History window | 1 MiB | `BUZZ_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 (total, incl. images) | 8 MiB | `MAX_TOOL_RESULT_BYTES` |
|
||||
| Tool result text | 50 KiB | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_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 | 0 (unlimited) | `BUZZ_AGENT_MAX_ROUNDS` |
|
||||
| LLM read inactivity timeout | 240 s | `BUZZ_AGENT_LLM_TIMEOUT_SECS` |
|
||||
| Tool call timeout | 660 s | `BUZZ_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 buzz-agent
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test -p buzz-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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,847 @@
|
||||
//! Token sources for the LLM transport layer.
|
||||
//!
|
||||
//! [`TokenSource`] decouples request auth from `Config::api_key`: providers
|
||||
//! can supply a static string ([`StaticTokenSource`]) or a refreshable OAuth
|
||||
//! 2.0 PKCE engine ([`PkceOAuthTokenSource`]). Engines own their own cache
|
||||
//! and refresh logic; the [`Llm`] just asks for a bearer per request.
|
||||
//!
|
||||
//! The PKCE engine implements RFC 6749 + RFC 7636 with on-disk token
|
||||
//! caching keyed by `sha256(discovery_url|client_id|scopes)`. It's the
|
||||
//! same shape goose uses for Databricks, but we own the wire format and
|
||||
//! cache directory so the two are independently upgradable.
|
||||
//!
|
||||
//! First-use (cache empty) requires a browser: the engine opens
|
||||
//! `authorization_endpoint` in `webbrowser`, listens on `127.0.0.1:0`,
|
||||
//! captures the redirect, and exchanges the code for a token. Subsequent
|
||||
//! calls hit the cache and silently refresh when expired.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::Digest;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::types::AgentError;
|
||||
|
||||
/// Buffer before `expires_at` to consider a cached token "still good".
|
||||
/// Keeps us off the cliff if the clock or the server's clock drifts.
|
||||
const TOKEN_REFRESH_LEEWAY: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Wall-clock budget for the interactive browser dance. Goose uses 60s.
|
||||
/// We match: any longer and the user has gone to lunch.
|
||||
const BROWSER_AUTH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Asynchronous source of a bearer token. The [`Llm`] calls this per
|
||||
/// request, so impls are expected to be cheap on the cache-hit path.
|
||||
#[async_trait]
|
||||
pub trait TokenSource: Send + Sync {
|
||||
async fn bearer(&self) -> Result<String, AgentError>;
|
||||
|
||||
/// Return a bearer token from cache or refresh, **never** opening a browser.
|
||||
///
|
||||
/// The default delegates to [`bearer`](Self::bearer) — correct for token
|
||||
/// sources (e.g. static API keys) that can never trigger a browser flow.
|
||||
/// [`PkceOAuthTokenSource`] overrides this to stop before the browser step.
|
||||
async fn bearer_no_browser(&self) -> Result<String, AgentError> {
|
||||
self.bearer().await
|
||||
}
|
||||
|
||||
/// Force a fresh bearer after the server rejected the current one (401).
|
||||
///
|
||||
/// `rejected` is the exact access token that just got the 401. Unlike
|
||||
/// [`bearer`](Self::bearer), which trusts the local expiry clock, this is
|
||||
/// driven by the server's verdict: the cached token looked valid to us
|
||||
/// (well within its local expiry) but the provider rejected it — clock
|
||||
/// skew, server-side revocation, or a node that never saw it. The clock
|
||||
/// therefore can't decide whether to refresh; the caller passes the
|
||||
/// rejected token so the impl can refresh unless a concurrent caller has
|
||||
/// *already* replaced it. Implementations must obtain a new token without
|
||||
/// any interactive step, so a headless harness never hangs. The default
|
||||
/// returns the existing bearer — correct for sources that can't refresh
|
||||
/// (a static key); the caller's retry then fails terminally rather than
|
||||
/// looping.
|
||||
async fn refresh_now(&self, _rejected: &str) -> Result<String, AgentError> {
|
||||
self.bearer().await
|
||||
}
|
||||
}
|
||||
|
||||
/// A token that never changes for the life of the process.
|
||||
pub struct StaticTokenSource(String);
|
||||
|
||||
impl StaticTokenSource {
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
Self(token.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenSource for StaticTokenSource {
|
||||
async fn bearer(&self) -> Result<String, AgentError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Static config for an OAuth 2.0 Authorization Code + PKCE provider.
|
||||
///
|
||||
/// The `discovery_url` must return a JSON document with at least
|
||||
/// `authorization_endpoint` and `token_endpoint` (RFC 8414). The
|
||||
/// `cache_namespace` is the directory under `~/.config/buzz-agent/oauth/`
|
||||
/// the token JSON lives in — separates providers' caches cleanly.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkceOAuthConfig {
|
||||
pub discovery_url: String,
|
||||
pub client_id: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub cache_namespace: String,
|
||||
/// When `Some`, the engine writes tokens here instead of
|
||||
/// `~/.config/buzz-agent/oauth/<cache_namespace>/`. Production code
|
||||
/// leaves this `None`. Integration tests use it to avoid stomping on
|
||||
/// a shared `$HOME` when running in parallel.
|
||||
pub cache_dir_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct CachedToken {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
/// Unix seconds. `None` means the server didn't advertise an expiry;
|
||||
/// we use it without checking and rely on refresh on 401.
|
||||
expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OidcEndpoints {
|
||||
authorization_endpoint: String,
|
||||
token_endpoint: String,
|
||||
}
|
||||
|
||||
/// PKCE OAuth token source with on-disk refresh cache.
|
||||
///
|
||||
/// First call:
|
||||
/// 1. Loads from cache if present and unexpired.
|
||||
/// 2. Otherwise tries `refresh_token` if cached.
|
||||
/// 3. Otherwise runs the full browser flow.
|
||||
///
|
||||
/// Subsequent calls hit an in-memory copy of the cached token and only
|
||||
/// touch disk/network if the access token is past `expires_at`.
|
||||
pub struct PkceOAuthTokenSource {
|
||||
cfg: PkceOAuthConfig,
|
||||
http: Client,
|
||||
cache_path: PathBuf,
|
||||
/// Single-flight guard: only one refresh/browser flow at a time, even
|
||||
/// if many tool calls land concurrently.
|
||||
state: Mutex<Option<CachedToken>>,
|
||||
}
|
||||
|
||||
impl PkceOAuthTokenSource {
|
||||
pub fn new(cfg: PkceOAuthConfig) -> Result<Arc<Self>, AgentError> {
|
||||
let cache_path = cache_path_for(&cfg)?;
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| AgentError::Llm(format!("oauth cache dir {parent:?}: {e}")))?;
|
||||
}
|
||||
let initial = read_cache(&cache_path);
|
||||
Ok(Arc::new(Self {
|
||||
cfg,
|
||||
http: Client::new(),
|
||||
cache_path,
|
||||
state: Mutex::new(initial),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Discover authorization + token endpoints from the well-known URL.
|
||||
async fn endpoints(&self) -> Result<OidcEndpoints, AgentError> {
|
||||
let v: Value = self
|
||||
.http
|
||||
.get(&self.cfg.discovery_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth discovery: {e}")))?
|
||||
.error_for_status()
|
||||
.map_err(|e| AgentError::Llm(format!("oauth discovery status: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth discovery json: {e}")))?;
|
||||
let auth = v
|
||||
.get("authorization_endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AgentError::Llm("oauth discovery: authorization_endpoint missing".into())
|
||||
})?
|
||||
.to_string();
|
||||
let token = v
|
||||
.get("token_endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| AgentError::Llm("oauth discovery: token_endpoint missing".into()))?
|
||||
.to_string();
|
||||
Ok(OidcEndpoints {
|
||||
authorization_endpoint: auth,
|
||||
token_endpoint: token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist a token to disk and the in-memory cell.
|
||||
fn save(&self, state: &mut Option<CachedToken>, token: CachedToken) -> Result<(), AgentError> {
|
||||
let body = serde_json::to_vec_pretty(&token)
|
||||
.map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?;
|
||||
// Atomic rename so a concurrent reader never sees a partial write.
|
||||
let tmp = self.cache_path.with_extension("json.tmp");
|
||||
fs::write(&tmp, &body)
|
||||
.map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?;
|
||||
fs::rename(&tmp, &self.cache_path)
|
||||
.map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?;
|
||||
*state = Some(token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exchange a refresh token for a fresh access token.
|
||||
async fn refresh(
|
||||
&self,
|
||||
endpoints: &OidcEndpoints,
|
||||
refresh_token: &str,
|
||||
) -> Result<CachedToken, AgentError> {
|
||||
let params = [
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
("client_id", &self.cfg.client_id),
|
||||
];
|
||||
let resp = self
|
||||
.http
|
||||
.post(&endpoints.token_endpoint)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth refresh: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AgentError::Llm(format!("oauth refresh failed: {body}")));
|
||||
}
|
||||
let v: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth refresh json: {e}")))?;
|
||||
token_from_response(&v, Some(refresh_token))
|
||||
}
|
||||
|
||||
/// Run the full browser-mediated Authorization Code + PKCE flow.
|
||||
/// Caller must hold a TTY/browser: this opens a window and blocks.
|
||||
pub async fn interactive_login(&self) -> Result<(), AgentError> {
|
||||
let endpoints = self.endpoints().await?;
|
||||
let token = browser_pkce_flow(&self.http, &self.cfg, &endpoints).await?;
|
||||
let mut state = self.state.lock().await;
|
||||
self.save(&mut state, token)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenSource for PkceOAuthTokenSource {
|
||||
async fn bearer(&self) -> Result<String, AgentError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// 1. In-memory cache hit, still fresh.
|
||||
if let Some(tok) = state.as_ref() {
|
||||
if !is_expired(tok) {
|
||||
return Ok(tok.access_token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Re-read disk — another process may have refreshed already.
|
||||
if let Some(disk_tok) = read_cache(&self.cache_path) {
|
||||
if !is_expired(&disk_tok) {
|
||||
let bearer = disk_tok.access_token.clone();
|
||||
*state = Some(disk_tok);
|
||||
return Ok(bearer);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try refresh if we have a refresh token. Discover endpoints once
|
||||
// here — deliberately hoisted above the refresh-token check so the
|
||||
// browser flow at step 5 (which also needs them) reuses this call.
|
||||
let endpoints = self.endpoints().await?;
|
||||
let refresh = state.as_ref().and_then(|t| t.refresh_token.clone());
|
||||
if let Some(rt) = refresh {
|
||||
match self.refresh(&endpoints, &rt).await {
|
||||
Ok(fresh) => {
|
||||
let bearer = fresh.access_token.clone();
|
||||
self.save(&mut state, fresh)?;
|
||||
return Ok(bearer);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "oauth refresh failed; falling back to browser flow");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Re-read disk after refresh failure — another process may have won the race.
|
||||
if let Some(disk_tok) = read_cache(&self.cache_path) {
|
||||
if !is_expired(&disk_tok) {
|
||||
let bearer = disk_tok.access_token.clone();
|
||||
*state = Some(disk_tok);
|
||||
return Ok(bearer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. No usable cache: full browser dance.
|
||||
let fresh = browser_pkce_flow(&self.http, &self.cfg, &endpoints).await?;
|
||||
let bearer = fresh.access_token.clone();
|
||||
self.save(&mut state, fresh)?;
|
||||
Ok(bearer)
|
||||
}
|
||||
|
||||
async fn bearer_no_browser(&self) -> Result<String, AgentError> {
|
||||
self.try_bearer_no_browser().await
|
||||
}
|
||||
|
||||
/// Force-refresh after a 401, never touching the browser flow.
|
||||
///
|
||||
/// `rejected` is the access token the server just 401'd. Coalescing keys
|
||||
/// off token *identity*, not the expiry clock: a 401 means the token was
|
||||
/// rejected while it still looked locally fresh, so `is_expired()` would
|
||||
/// say "keep it" and no grant would ever run. Instead, under the lock we
|
||||
/// compare the current cached token to `rejected` — if they differ, a
|
||||
/// concurrent caller (this process or a sibling) already refreshed, so we
|
||||
/// return the new token without burning a second grant. If they still
|
||||
/// match, this is the rejected token and we run the refresh-token grant
|
||||
/// unconditionally. The whole check→refresh→save runs under one lock hold
|
||||
/// so concurrent callers serialize. On any failure the refresh token is
|
||||
/// preserved (never nulled) and the error is terminal `LlmAuth` — no
|
||||
/// browser, no hang.
|
||||
async fn refresh_now(&self, rejected: &str) -> Result<String, AgentError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// 1. Coalesce by identity: if the cached token (in-memory, then disk)
|
||||
// is no longer the one the server rejected, someone already
|
||||
// refreshed it. Return that instead of grabbing another grant.
|
||||
if let Some(tok) = state.as_ref() {
|
||||
if tok.access_token != rejected {
|
||||
return Ok(tok.access_token.clone());
|
||||
}
|
||||
}
|
||||
if let Some(disk_tok) = read_cache(&self.cache_path) {
|
||||
if disk_tok.access_token != rejected {
|
||||
let bearer = disk_tok.access_token.clone();
|
||||
*state = Some(disk_tok);
|
||||
return Ok(bearer);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The cached token is still the rejected one. Run the refresh-token
|
||||
// grant unconditionally — the expiry clock can't be trusted here, a
|
||||
// locally-fresh token is exactly what got 401'd.
|
||||
let refresh = state.as_ref().and_then(|t| t.refresh_token.clone());
|
||||
let Some(rt) = refresh else {
|
||||
return Err(AgentError::LlmAuth(
|
||||
"token rejected and no refresh token available".into(),
|
||||
));
|
||||
};
|
||||
let endpoints = self.endpoints().await?;
|
||||
match self.refresh(&endpoints, &rt).await {
|
||||
Ok(fresh) => {
|
||||
let bearer = fresh.access_token.clone();
|
||||
self.save(&mut state, fresh)?;
|
||||
Ok(bearer)
|
||||
}
|
||||
// 3. Refresh token is itself dead. Terminal — surfacing LlmAuth
|
||||
// stops the retry loop instead of falling to the browser flow,
|
||||
// which would hang a headless harness.
|
||||
Err(e) => Err(AgentError::LlmAuth(format!("token refresh failed: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PkceOAuthTokenSource {
|
||||
/// Return a bearer token from cache or refresh, **never** opening a browser.
|
||||
///
|
||||
/// Follows the same steps as [`bearer`](TokenSource::bearer) but stops at
|
||||
/// step 4 — if no usable token is available after cache + refresh attempts,
|
||||
/// returns `Err(LlmAuth(...))` instead of launching the browser PKCE flow.
|
||||
/// Used by model-discovery paths that must not block on user interaction.
|
||||
pub(crate) async fn try_bearer_no_browser(&self) -> Result<String, AgentError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// 1. In-memory cache hit, still fresh.
|
||||
if let Some(tok) = state.as_ref() {
|
||||
if !is_expired(tok) {
|
||||
return Ok(tok.access_token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Re-read disk — another process may have refreshed already.
|
||||
if let Some(disk_tok) = read_cache(&self.cache_path) {
|
||||
if !is_expired(&disk_tok) {
|
||||
let bearer = disk_tok.access_token.clone();
|
||||
*state = Some(disk_tok);
|
||||
return Ok(bearer);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try refresh if we have a refresh token. Endpoints are discovered
|
||||
// lazily here — only when a refresh token is actually present — so
|
||||
// that an unreachable OIDC discovery URL cannot prevent the
|
||||
// no-token/no-cache path from returning `LlmAuth` (graceful
|
||||
// fallback) instead of `Llm` (hard error).
|
||||
let refresh = state.as_ref().and_then(|t| t.refresh_token.clone());
|
||||
if let Some(rt) = refresh {
|
||||
let endpoints = self.endpoints().await?;
|
||||
match self.refresh(&endpoints, &rt).await {
|
||||
Ok(fresh) => {
|
||||
let bearer = fresh.access_token.clone();
|
||||
self.save(&mut state, fresh)?;
|
||||
return Ok(bearer);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "oauth refresh failed during model discovery");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Re-read disk after refresh failure.
|
||||
if let Some(disk_tok) = read_cache(&self.cache_path) {
|
||||
if !is_expired(&disk_tok) {
|
||||
let bearer = disk_tok.access_token.clone();
|
||||
*state = Some(disk_tok);
|
||||
return Ok(bearer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No usable token — return error instead of opening a browser.
|
||||
Err(AgentError::LlmAuth(
|
||||
"no cached Databricks token; run `buzz-agent auth databricks` first".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
/// Aborts a spawned task when dropped. Used to guarantee the localhost
|
||||
/// callback server doesn't outlive a failed/abandoned PKCE attempt.
|
||||
struct AbortOnDrop(tokio::task::JoinHandle<()>);
|
||||
|
||||
impl Drop for AbortOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(t: &CachedToken) -> bool {
|
||||
let Some(exp) = t.expires_at else {
|
||||
return false;
|
||||
};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
now + TOKEN_REFRESH_LEEWAY.as_secs() >= exp
|
||||
}
|
||||
|
||||
fn cache_path_for(cfg: &PkceOAuthConfig) -> Result<PathBuf, AgentError> {
|
||||
let mut h = sha2::Sha256::new();
|
||||
h.update(cfg.discovery_url.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(cfg.client_id.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(cfg.scopes.join(",").as_bytes());
|
||||
let hash = hex::encode(h.finalize());
|
||||
|
||||
let dir = match &cfg.cache_dir_override {
|
||||
Some(p) => p.join(&cfg.cache_namespace),
|
||||
None => dirs::home_dir()
|
||||
.ok_or_else(|| AgentError::Llm("oauth cache: home directory not found".into()))?
|
||||
.join(".config")
|
||||
.join("buzz-agent")
|
||||
.join("oauth")
|
||||
.join(&cfg.cache_namespace),
|
||||
};
|
||||
Ok(dir.join(format!("{hash}.json")))
|
||||
}
|
||||
|
||||
fn read_cache(path: &PathBuf) -> Option<CachedToken> {
|
||||
let body = fs::read(path).ok()?;
|
||||
serde_json::from_slice(&body).ok()
|
||||
}
|
||||
|
||||
/// Parse a token-endpoint JSON response. Fails loudly when `access_token`
|
||||
/// is missing or empty — without this, a malformed server response would
|
||||
/// be cached and `bearer()` would silently return `""` until the entry
|
||||
/// expires or is deleted by hand.
|
||||
fn token_from_response(
|
||||
v: &Value,
|
||||
fallback_refresh: Option<&str>,
|
||||
) -> Result<CachedToken, AgentError> {
|
||||
let access_token = v
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| AgentError::Llm("oauth: token response missing/empty access_token".into()))?
|
||||
.to_string();
|
||||
let refresh_token = v
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| fallback_refresh.map(str::to_string));
|
||||
let expires_at = v.get("expires_in").and_then(Value::as_u64).map(|secs| {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
+ secs
|
||||
});
|
||||
Ok(CachedToken {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// PKCE pieces: URL-safe random verifier (~64 chars) and its SHA-256
|
||||
/// challenge (RFC 7636 §4.2).
|
||||
fn pkce_pair() -> Result<(String, String), AgentError> {
|
||||
let mut bytes = [0u8; 48];
|
||||
getrandom::fill(&mut bytes).map_err(|e| AgentError::Llm(format!("pkce rng: {e}")))?;
|
||||
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(sha2::Sha256::digest(verifier.as_bytes()));
|
||||
Ok((verifier, challenge))
|
||||
}
|
||||
|
||||
fn random_state() -> Result<String, AgentError> {
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::fill(&mut bytes).map_err(|e| AgentError::Llm(format!("state rng: {e}")))?;
|
||||
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
/// Spin up a localhost callback server, open the authorize URL in a
|
||||
/// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then
|
||||
/// exchange the code for a token.
|
||||
async fn browser_pkce_flow(
|
||||
http: &Client,
|
||||
cfg: &PkceOAuthConfig,
|
||||
endpoints: &OidcEndpoints,
|
||||
) -> Result<CachedToken, AgentError> {
|
||||
use axum::{extract::Query, response::Html, routing::get, Router};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
let (verifier, challenge) = pkce_pair()?;
|
||||
let state = random_state()?;
|
||||
|
||||
let (tx, rx) = oneshot::channel::<Result<String, String>>();
|
||||
let tx = Arc::new(Mutex::new(Some(tx)));
|
||||
|
||||
let expected_state = state.clone();
|
||||
let app = Router::new().route(
|
||||
"/",
|
||||
get(move |Query(params): Query<HashMap<String, String>>| {
|
||||
let tx = Arc::clone(&tx);
|
||||
let expected = expected_state.clone();
|
||||
async move {
|
||||
let result = match (params.get("code"), params.get("state")) {
|
||||
(Some(code), Some(st)) if st == &expected => Ok(code.clone()),
|
||||
(Some(_), Some(_)) => Err("state mismatch".to_string()),
|
||||
_ => Err(params
|
||||
.get("error")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "missing code".into())),
|
||||
};
|
||||
if let Some(sender) = tx.lock().await.take() {
|
||||
let _ = sender.send(result.clone());
|
||||
}
|
||||
match result {
|
||||
Ok(_) => Html(
|
||||
"<h2>Buzz: signed in</h2><p>You can close this window.</p>".to_string(),
|
||||
),
|
||||
Err(e) => Html(format!("<h2>Buzz auth failed</h2><pre>{e}</pre>")),
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth callback bind: {e}")))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| AgentError::Llm(format!("oauth callback addr: {e}")))?
|
||||
.port();
|
||||
let redirect_uri = format!("http://localhost:{port}");
|
||||
|
||||
// `_server` is held until this function returns; the drop guard aborts
|
||||
// the axum task on every exit path (timeout, callback error, token
|
||||
// exchange failure, or success), so we never leak a listener bound to
|
||||
// 127.0.0.1 past the auth attempt.
|
||||
let _server = AbortOnDrop(tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
}));
|
||||
|
||||
let auth_url = format!(
|
||||
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
|
||||
endpoints.authorization_endpoint,
|
||||
urlencoding::encode(&cfg.client_id),
|
||||
urlencoding::encode(&redirect_uri),
|
||||
urlencoding::encode(&cfg.scopes.join(" ")),
|
||||
urlencoding::encode(&state),
|
||||
urlencoding::encode(&challenge),
|
||||
);
|
||||
|
||||
eprintln!("Opening browser for authentication. If it doesn't open, visit:\n {auth_url}");
|
||||
let _ = webbrowser::open(&auth_url);
|
||||
|
||||
let code = tokio::time::timeout(BROWSER_AUTH_TIMEOUT, rx)
|
||||
.await
|
||||
.map_err(|_| AgentError::Llm("oauth: browser auth timed out".into()))?
|
||||
.map_err(|_| AgentError::Llm("oauth: callback sender dropped".into()))?
|
||||
.map_err(|e| AgentError::Llm(format!("oauth callback: {e}")))?;
|
||||
|
||||
// Exchange code for token.
|
||||
let params = [
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &code),
|
||||
("redirect_uri", &redirect_uri),
|
||||
("code_verifier", &verifier),
|
||||
("client_id", &cfg.client_id),
|
||||
];
|
||||
let resp = http
|
||||
.post(&endpoints.token_endpoint)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth exchange: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AgentError::Llm(format!("oauth exchange failed: {body}")));
|
||||
}
|
||||
let v: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("oauth exchange json: {e}")))?;
|
||||
token_from_response(&v, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pkce_pair_produces_valid_challenge() {
|
||||
let (verifier, challenge) = pkce_pair().unwrap();
|
||||
assert!(verifier.len() >= 43);
|
||||
let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(sha2::Sha256::digest(verifier.as_bytes()));
|
||||
assert_eq!(expected, challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_token_no_expiry_is_not_expired() {
|
||||
let t = CachedToken {
|
||||
access_token: "x".into(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
assert!(!is_expired(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_token_far_future_is_not_expired() {
|
||||
let future = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 3600;
|
||||
let t = CachedToken {
|
||||
access_token: "x".into(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(future),
|
||||
};
|
||||
assert!(!is_expired(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_token_within_leeway_is_expired() {
|
||||
let near = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 10; // 10s away, leeway is 60s → counts as expired
|
||||
let t = CachedToken {
|
||||
access_token: "x".into(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(near),
|
||||
};
|
||||
assert!(is_expired(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_path_uses_platform_home_directory() {
|
||||
let cfg = PkceOAuthConfig {
|
||||
discovery_url: "https://example.com/.well-known".into(),
|
||||
client_id: "abc".into(),
|
||||
scopes: vec!["a".into(), "b".into()],
|
||||
cache_namespace: "demo".into(),
|
||||
cache_dir_override: None,
|
||||
};
|
||||
let p = cache_path_for(&cfg).unwrap();
|
||||
let expected_dir = dirs::home_dir()
|
||||
.unwrap()
|
||||
.join(".config")
|
||||
.join("buzz-agent")
|
||||
.join("oauth")
|
||||
.join("demo");
|
||||
assert_eq!(p.parent(), Some(expected_dir.as_path()));
|
||||
assert_eq!(p.extension().and_then(|s| s.to_str()), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_response_uses_fallback_refresh() {
|
||||
let v: Value = serde_json::from_str(r#"{"access_token":"abc","expires_in":3600}"#).unwrap();
|
||||
let t = token_from_response(&v, Some("old-refresh")).unwrap();
|
||||
assert_eq!(t.access_token, "abc");
|
||||
assert_eq!(t.refresh_token.as_deref(), Some("old-refresh"));
|
||||
assert!(t.expires_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_response_rejects_missing_access_token() {
|
||||
let v: Value = serde_json::from_str(r#"{"expires_in":3600}"#).unwrap();
|
||||
assert!(token_from_response(&v, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_response_rejects_empty_access_token() {
|
||||
let v: Value = serde_json::from_str(r#"{"access_token":""}"#).unwrap();
|
||||
assert!(token_from_response(&v, None).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_reuses_disk_token_after_expiry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = PkceOAuthConfig {
|
||||
discovery_url: "https://example.com/.well-known".into(),
|
||||
client_id: "test-client".into(),
|
||||
scopes: vec!["offline_access".into()],
|
||||
cache_namespace: "test".into(),
|
||||
cache_dir_override: Some(dir.path().to_path_buf()),
|
||||
};
|
||||
let source = PkceOAuthTokenSource::new(cfg).unwrap();
|
||||
|
||||
// Expire the in-memory state.
|
||||
{
|
||||
let mut state = source.state.lock().await;
|
||||
*state = Some(CachedToken {
|
||||
access_token: "stale".into(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(0), // long expired
|
||||
});
|
||||
}
|
||||
|
||||
// Write a valid token to disk (simulating another process refreshing).
|
||||
let future_exp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 7200;
|
||||
let fresh_token = CachedToken {
|
||||
access_token: "fresh-from-disk".into(),
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at: Some(future_exp),
|
||||
};
|
||||
let body = serde_json::to_vec_pretty(&fresh_token).unwrap();
|
||||
fs::write(&source.cache_path, &body).unwrap();
|
||||
|
||||
// bearer() should pick up the disk token without any network call.
|
||||
let result = source.bearer().await.unwrap();
|
||||
assert_eq!(result, "fresh-from-disk");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_falls_through_to_browser_when_disk_also_expired() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = PkceOAuthConfig {
|
||||
discovery_url: "https://example.com/.well-known".into(),
|
||||
client_id: "test-client".into(),
|
||||
scopes: vec!["offline_access".into()],
|
||||
cache_namespace: "test".into(),
|
||||
cache_dir_override: Some(dir.path().to_path_buf()),
|
||||
};
|
||||
let source = PkceOAuthTokenSource::new(cfg).unwrap();
|
||||
|
||||
// Expire the in-memory state.
|
||||
{
|
||||
let mut state = source.state.lock().await;
|
||||
*state = Some(CachedToken {
|
||||
access_token: "stale".into(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(0),
|
||||
});
|
||||
}
|
||||
|
||||
// Write an expired token to disk too.
|
||||
let expired_token = CachedToken {
|
||||
access_token: "also-stale".into(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(0),
|
||||
};
|
||||
let body = serde_json::to_vec_pretty(&expired_token).unwrap();
|
||||
fs::write(&source.cache_path, &body).unwrap();
|
||||
|
||||
// bearer() should fall through past the disk check.
|
||||
// It will fail at the endpoints() discovery call since there's no server,
|
||||
// which proves it didn't short-circuit on the expired disk token.
|
||||
let result = source.bearer().await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = format!("{}", result.unwrap_err());
|
||||
assert!(
|
||||
err_msg.contains("oauth discovery"),
|
||||
"expected discovery error, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `try_bearer_no_browser` with an empty cache and no refresh token must
|
||||
/// return `LlmAuth` immediately — it must NOT attempt OIDC discovery even
|
||||
/// when the `discovery_url` is unreachable/invalid. This guards the
|
||||
/// regression where `endpoints()` was called unconditionally before the
|
||||
/// refresh-token check, causing an `Llm` error (hard failure) instead of
|
||||
/// the intended graceful `LlmAuth` fallback.
|
||||
#[tokio::test]
|
||||
async fn test_try_bearer_no_browser_empty_cache_no_refresh_returns_llm_auth_without_discovery()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Intentionally invalid/unreachable discovery URL — if endpoints() is
|
||||
// called, the test will get an `Llm` error and the assertion below fails.
|
||||
let cfg = PkceOAuthConfig {
|
||||
discovery_url: "https://invalid.example.test/.well-known/oauth-authorization-server"
|
||||
.into(),
|
||||
client_id: "test-client".into(),
|
||||
scopes: vec!["offline_access".into()],
|
||||
cache_namespace: "test".into(),
|
||||
cache_dir_override: Some(dir.path().to_path_buf()),
|
||||
};
|
||||
let source = PkceOAuthTokenSource::new(cfg).unwrap();
|
||||
|
||||
// Empty in-memory state (no token, no refresh token).
|
||||
{
|
||||
let mut state = source.state.lock().await;
|
||||
*state = None;
|
||||
}
|
||||
|
||||
// No disk cache file either — dir is empty.
|
||||
|
||||
let result = source.try_bearer_no_browser().await;
|
||||
assert!(result.is_err(), "expected Err, got Ok");
|
||||
match result.unwrap_err() {
|
||||
AgentError::LlmAuth(_) => {} // correct: graceful fallback
|
||||
other => panic!(
|
||||
"expected LlmAuth (no discovery attempted), got: {other:?}\n\
|
||||
This means endpoints() was called before the refresh-token check."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Built-in tools that run in-process, bypassing MCP.
|
||||
//!
|
||||
//! Currently: `load_skill` — reads a skill's full SKILL.md body from disk
|
||||
//! and returns it so the agent can load skill content on demand rather than
|
||||
//! having every skill inlined into the system prompt at session start.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::hints::{strip_frontmatter, SkillEntry, MAX_SKILL_BODY_BYTES};
|
||||
use crate::mcp::truncate_at_boundary;
|
||||
use crate::types::{ToolDef, ToolResult, ToolResultContent};
|
||||
|
||||
pub const LOAD_SKILL_TOOL: &str = "load_skill";
|
||||
|
||||
/// Return the `ToolDef` for `load_skill` to include in the LLM tool list.
|
||||
pub fn load_skill_def() -> ToolDef {
|
||||
ToolDef {
|
||||
name: LOAD_SKILL_TOOL.to_owned(),
|
||||
description: "Load the full content of a skill by name. \
|
||||
Call this before using a skill — the system prompt lists skill names \
|
||||
and descriptions only; the full instructions are loaded on demand. \
|
||||
To load a supporting file within a skill, use the form \
|
||||
\"skill-name/relative/path\" (e.g. \"my-skill/references/foo.md\")."
|
||||
.to_owned(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The skill name as listed in the Available Skills section, \
|
||||
or \"skill-name/relative/path\" to load a supporting file."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a `load_skill` call. Returns a `ToolResult` on success or a
|
||||
/// user-visible error result if the skill is not found or cannot be read.
|
||||
pub async fn call_load_skill(arguments: &Value, skills: &[SkillEntry]) -> ToolResult {
|
||||
let name = match arguments.get("name").and_then(Value::as_str) {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
return error_result("load_skill: missing required argument \"name\"");
|
||||
}
|
||||
};
|
||||
|
||||
// Two forms:
|
||||
// "skill-name" → load SKILL.md body + ## Supporting Files section
|
||||
// "skill-name/rel/path" → load a specific supporting file
|
||||
if let Some((skill_name, rel_path)) = name.split_once('/') {
|
||||
return load_supporting_file(skill_name, rel_path, skills).await;
|
||||
}
|
||||
|
||||
// Plain skill-name form: load SKILL.md body.
|
||||
let entry = match skills.iter().find(|s| s.name == name) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let available: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
return error_result(&format!(
|
||||
"load_skill: skill {name:?} not found. Available: {available:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Read the file off the async executor to avoid blocking a Tokio worker.
|
||||
let skill_path = entry.path.clone();
|
||||
let raw = match tokio::task::spawn_blocking(move || std::fs::read_to_string(&skill_path))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return error_result(&format!("load_skill: could not read {:?}: {e}", entry.path));
|
||||
}
|
||||
};
|
||||
|
||||
// Strip the YAML frontmatter — the agent already knows name/description
|
||||
// from the system prompt; return only the body.
|
||||
let body = strip_frontmatter(&raw);
|
||||
|
||||
let mut output = body.to_owned();
|
||||
|
||||
// Append ## Supporting Files section if this skill has any.
|
||||
if !entry.supporting_files.is_empty() {
|
||||
let skill_dir = entry.path.parent().unwrap_or(&entry.path);
|
||||
output.push_str("\n\n## Supporting Files\n\n");
|
||||
for file in &entry.supporting_files {
|
||||
if let Ok(rel) = file.strip_prefix(skill_dir) {
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
output.push_str(&format!(
|
||||
"- {} (load_skill(name: \"{}/{}\"))\n",
|
||||
rel_str, entry.name, rel_str
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the size cap to the full output (body + Supporting Files section)
|
||||
// so the total tool result stays within MAX_SKILL_BODY_BYTES.
|
||||
let output = if output.len() > MAX_SKILL_BODY_BYTES {
|
||||
truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned()
|
||||
} else {
|
||||
output
|
||||
};
|
||||
|
||||
ToolResult {
|
||||
provider_id: String::new(),
|
||||
content: vec![ToolResultContent::Text(output)],
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a supporting file identified by `skill_name/rel_path`.
|
||||
/// Matches against the pre-enumerated `supporting_files` list and applies a
|
||||
/// canonicalize-based traversal guard before reading.
|
||||
async fn load_supporting_file(
|
||||
skill_name: &str,
|
||||
rel_path: &str,
|
||||
skills: &[SkillEntry],
|
||||
) -> ToolResult {
|
||||
let rel_path = rel_path.replace('\\', "/");
|
||||
|
||||
let entry = match skills.iter().find(|s| s.name == skill_name) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let available: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
return error_result(&format!(
|
||||
"load_skill: skill {skill_name:?} not found. Available: {available:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let skill_dir = match entry.path.parent() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return error_result(&format!(
|
||||
"load_skill: could not determine skill directory for {skill_name:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Match rel_path against the pre-enumerated supporting_files list.
|
||||
let matched = entry.supporting_files.iter().find(|f| {
|
||||
f.strip_prefix(skill_dir)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/") == rel_path)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let file_path = match matched {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let available: Vec<String> = entry
|
||||
.supporting_files
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
f.strip_prefix(skill_dir)
|
||||
.ok()
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect();
|
||||
if available.is_empty() {
|
||||
return error_result(&format!(
|
||||
"load_skill: skill {skill_name:?} has no supporting files."
|
||||
));
|
||||
}
|
||||
return error_result(&format!(
|
||||
"load_skill: file {rel_path:?} not found in skill {skill_name:?}. \
|
||||
Available: {available:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Traversal guard: canonicalize both paths and verify the file stays inside
|
||||
// the skill directory. Fail hard if the skill directory itself can't be
|
||||
// canonicalized — a degraded guard is worse than no guard.
|
||||
let canonical_skill_dir = match skill_dir.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return error_result(&format!(
|
||||
"load_skill: could not canonicalize skill directory for {skill_name:?}: {e}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Clone the path so we can move it into spawn_blocking.
|
||||
let file_path = file_path.clone();
|
||||
let skill_name = skill_name.to_owned();
|
||||
let rel_path_owned = rel_path.clone();
|
||||
|
||||
match tokio::task::spawn_blocking(move || file_path.canonicalize().map(|c| (c, file_path)))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
|
||||
{
|
||||
Ok((canonical_file, resolved_path)) if canonical_file.starts_with(&canonical_skill_dir) => {
|
||||
match tokio::task::spawn_blocking(move || std::fs::read_to_string(&resolved_path))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
|
||||
{
|
||||
Ok(content) => {
|
||||
let output = format!(
|
||||
"# Loaded: {}/{}\n\n{}\n\n---\nFile loaded into context.",
|
||||
skill_name, rel_path_owned, content
|
||||
);
|
||||
let output = if output.len() > MAX_SKILL_BODY_BYTES {
|
||||
truncate_at_boundary(&output, MAX_SKILL_BODY_BYTES).to_owned()
|
||||
} else {
|
||||
output
|
||||
};
|
||||
ToolResult {
|
||||
provider_id: String::new(),
|
||||
content: vec![ToolResultContent::Text(output)],
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
Err(e) => error_result(&format!(
|
||||
"load_skill: could not read {skill_name:?}/{rel_path_owned}: {e}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
Ok(_) => error_result(&format!(
|
||||
"load_skill: refusing to load {skill_name:?}/{rel_path_owned}: \
|
||||
resolves outside the skill directory"
|
||||
)),
|
||||
Err(e) => error_result(&format!(
|
||||
"load_skill: could not resolve {skill_name:?}/{rel_path_owned}: {e}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_result(msg: &str) -> ToolResult {
|
||||
ToolResult {
|
||||
provider_id: String::new(),
|
||||
content: vec![ToolResultContent::Text(msg.to_owned())],
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn text_content(result: &ToolResult) -> String {
|
||||
match &result.content[0] {
|
||||
ToolResultContent::Text(t) => t.clone(),
|
||||
ToolResultContent::Image { .. } => panic!("unexpected Image content in test"),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_skill(name: &str, description: &str, path: PathBuf) -> SkillEntry {
|
||||
SkillEntry {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
path,
|
||||
supporting_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_skill_with_files(
|
||||
name: &str,
|
||||
description: &str,
|
||||
path: PathBuf,
|
||||
supporting_files: Vec<PathBuf>,
|
||||
) -> SkillEntry {
|
||||
SkillEntry {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
path,
|
||||
supporting_files,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_missing_name_arg() {
|
||||
let result = call_load_skill(&serde_json::json!({}), &[]).await;
|
||||
assert!(result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(text.contains("missing required argument"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_skill_not_found() {
|
||||
let result = call_load_skill(&serde_json::json!({"name": "no-such"}), &[]).await;
|
||||
assert!(result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(text.contains("not found"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_returns_body_strips_frontmatter() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_md = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: test\ndescription: A test\n---\nSkill body here.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = vec![make_skill("test", "A test", skill_md)];
|
||||
let result = call_load_skill(&serde_json::json!({"name": "test"}), &skills).await;
|
||||
assert!(!result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(text.contains("Skill body here."), "got: {text}");
|
||||
assert!(
|
||||
!text.contains("---"),
|
||||
"frontmatter should be stripped: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_appends_supporting_files_section() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: my-skill\ndescription: desc\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
let ref_file = refs_dir.join("foo.md");
|
||||
std::fs::write(&ref_file, "Reference content.").unwrap();
|
||||
|
||||
let skills = vec![make_skill_with_files(
|
||||
"my-skill",
|
||||
"desc",
|
||||
skill_md,
|
||||
vec![ref_file],
|
||||
)];
|
||||
let result = call_load_skill(&serde_json::json!({"name": "my-skill"}), &skills).await;
|
||||
assert!(!result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(text.contains("Body."), "body missing: {text}");
|
||||
assert!(
|
||||
text.contains("## Supporting Files"),
|
||||
"missing Supporting Files section: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("references/foo.md"),
|
||||
"missing file listing: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("load_skill(name: \"my-skill/references/foo.md\")"),
|
||||
"missing load_skill hint: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_no_supporting_files_section_when_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_md = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: bare\ndescription: desc\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = vec![make_skill("bare", "desc", skill_md)];
|
||||
let result = call_load_skill(&serde_json::json!({"name": "bare"}), &skills).await;
|
||||
assert!(!result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(
|
||||
!text.contains("## Supporting Files"),
|
||||
"should not have Supporting Files section when none: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_supporting_file_returns_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: my-skill\ndescription: desc\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
let ref_file = refs_dir.join("foo.md");
|
||||
std::fs::write(&ref_file, "Reference content here.").unwrap();
|
||||
|
||||
let skills = vec![make_skill_with_files(
|
||||
"my-skill",
|
||||
"desc",
|
||||
skill_md,
|
||||
vec![ref_file],
|
||||
)];
|
||||
let result = call_load_skill(
|
||||
&serde_json::json!({"name": "my-skill/references/foo.md"}),
|
||||
&skills,
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "expected success, got error");
|
||||
let text = text_content(&result);
|
||||
assert!(
|
||||
text.contains("Reference content here."),
|
||||
"file content missing: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# Loaded: my-skill/references/foo.md"),
|
||||
"missing header: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_supporting_file_not_found_lists_available() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: my-skill\ndescription: desc\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
let ref_file = refs_dir.join("foo.md");
|
||||
std::fs::write(&ref_file, "content").unwrap();
|
||||
|
||||
let skills = vec![make_skill_with_files(
|
||||
"my-skill",
|
||||
"desc",
|
||||
skill_md,
|
||||
vec![ref_file],
|
||||
)];
|
||||
let result = call_load_skill(
|
||||
&serde_json::json!({"name": "my-skill/references/missing.md"}),
|
||||
&skills,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(text.contains("not found"), "got: {text}");
|
||||
assert!(
|
||||
text.contains("references/foo.md"),
|
||||
"should list available: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_no_supporting_files_error_message() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_md = tmp.path().join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: bare\ndescription: desc\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = vec![make_skill("bare", "desc", skill_md)];
|
||||
let result =
|
||||
call_load_skill(&serde_json::json!({"name": "bare/anything.md"}), &skills).await;
|
||||
assert!(result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(text.contains("no supporting files"), "got: {text}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_traversal_guard_rejects_escape() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path().join("my-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
"---\nname: my-skill\ndescription: desc\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create a file outside the skill dir that we'll try to reference.
|
||||
let outside_file = tmp.path().join("secret.txt");
|
||||
std::fs::write(&outside_file, "secret content").unwrap();
|
||||
|
||||
// Manually construct a SkillEntry with a supporting_files entry that
|
||||
// points outside the skill dir — simulating a crafted/malicious entry.
|
||||
// The traversal guard should catch this.
|
||||
let skills = vec![make_skill_with_files(
|
||||
"my-skill",
|
||||
"desc",
|
||||
skill_md.clone(),
|
||||
vec![outside_file.clone()],
|
||||
)];
|
||||
|
||||
// The slash form splits "my-skill/../secret.txt" into skill_name="my-skill"
|
||||
// and rel_path="../secret.txt". strip_prefix(skill_dir) on outside_file
|
||||
// fails, so it won't match any supporting_files entry — the pre-enumeration
|
||||
// guard rejects it before the canonicalize guard even fires.
|
||||
let result = call_load_skill(
|
||||
&serde_json::json!({"name": "my-skill/../secret.txt"}),
|
||||
&skills,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_error, "traversal attempt should be rejected");
|
||||
let text = text_content(&result);
|
||||
assert!(
|
||||
!text.contains("secret content"),
|
||||
"secret content must not be returned: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_truncates_large_body() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
// Build a body that exceeds MAX_SKILL_BODY_BYTES (32 KiB).
|
||||
let large_body = "x".repeat(40 * 1024);
|
||||
std::fs::write(
|
||||
&skill_md,
|
||||
format!("---\nname: big\ndescription: desc\n---\n{large_body}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
// Add a supporting file so the Supporting Files section is also appended
|
||||
// before the cap is applied.
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
let ref_file = refs_dir.join("extra.md");
|
||||
std::fs::write(&ref_file, "extra content").unwrap();
|
||||
|
||||
let skills = vec![make_skill_with_files(
|
||||
"big",
|
||||
"desc",
|
||||
skill_md,
|
||||
vec![ref_file],
|
||||
)];
|
||||
let result = call_load_skill(&serde_json::json!({"name": "big"}), &skills).await;
|
||||
assert!(!result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(
|
||||
text.len() <= MAX_SKILL_BODY_BYTES,
|
||||
"output length {} exceeds MAX_SKILL_BODY_BYTES {}",
|
||||
text.len(),
|
||||
MAX_SKILL_BODY_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_load_skill_truncates_large_supporting_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_md, "---\nname: big\ndescription: desc\n---\nBody.\n").unwrap();
|
||||
|
||||
let refs_dir = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs_dir).unwrap();
|
||||
let ref_file = refs_dir.join("huge.md");
|
||||
std::fs::write(&ref_file, "x".repeat(MAX_SKILL_BODY_BYTES * 2)).unwrap();
|
||||
|
||||
let skills = vec![make_skill_with_files(
|
||||
"big",
|
||||
"desc",
|
||||
skill_md,
|
||||
vec![ref_file],
|
||||
)];
|
||||
let result = call_load_skill(
|
||||
&serde_json::json!({"name": "big/references/huge.md"}),
|
||||
&skills,
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error);
|
||||
let text = text_content(&result);
|
||||
assert!(
|
||||
text.len() <= MAX_SKILL_BODY_BYTES,
|
||||
"output length {} exceeds MAX_SKILL_BODY_BYTES {}",
|
||||
text.len(),
|
||||
MAX_SKILL_BODY_BYTES
|
||||
);
|
||||
assert!(
|
||||
text.starts_with("# Loaded: big/references/huge.md"),
|
||||
"missing supporting-file header: {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Databricks model catalog discovery.
|
||||
//!
|
||||
//! Exposes [`discover_databricks_models`] — an async helper that lists
|
||||
//! available models for the `databricks` and `databricks_v2` providers
|
||||
//! without triggering a browser OAuth flow. Auth is acquired in-process via
|
||||
//! [`build_token_source`](crate::llm::build_token_source):
|
||||
//!
|
||||
//! - Static bearer (`DATABRICKS_TOKEN`): returned immediately.
|
||||
//! - PKCE cache hit: returned from disk without a network round-trip.
|
||||
//! - PKCE cache empty / no token: returns `Err(AgentError::LlmAuth)`.
|
||||
//!
|
||||
//! This helper never opens a browser. Callers choose whether to reject, degrade,
|
||||
//! or start a separate interactive authentication flow.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::{
|
||||
auth::TokenSource,
|
||||
config::{Config, Provider},
|
||||
llm::build_token_source,
|
||||
types::AgentError,
|
||||
};
|
||||
|
||||
/// A discovered model entry: `id` is the picker value, `name` is the display
|
||||
/// label (same as `id` for Databricks — the API has no separate display name).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Known Databricks AI Gateway v2 models — used only when an authenticated
|
||||
/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list.
|
||||
/// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`.
|
||||
pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] =
|
||||
&["databricks-gpt-5-5", "databricks-claude-opus-4-7"];
|
||||
|
||||
const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)";
|
||||
|
||||
fn authenticated_empty_v2_catalog() -> Vec<ModelEntry> {
|
||||
DATABRICKS_V2_KNOWN_MODELS
|
||||
.iter()
|
||||
.map(|id| ModelEntry {
|
||||
id: id.to_string(),
|
||||
name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves
|
||||
/// chat/completions traffic.
|
||||
///
|
||||
/// The v1 `serving-endpoints` payload carries `task`, so [`parse_v1_endpoints`]
|
||||
/// can filter on it directly. The v2 `ai-gateway/v2/endpoints` payload carries
|
||||
/// no task or readiness field at all, so the only signal available here is the
|
||||
/// endpoint name. Embedding endpoints are the one family that reliably cannot
|
||||
/// serve a chat request — they reject it with
|
||||
/// `API type 'mlflow/v1/chat/completions' is not supported by '<name>'` — so
|
||||
/// they are dropped rather than offered as selectable models.
|
||||
///
|
||||
/// Deliberately narrow: image-capable endpoints (e.g.
|
||||
/// `databricks-gemini-3-pro-image`) do answer chat requests, so they stay. Any
|
||||
/// name this heuristic does not recognise is kept — preferring to include over
|
||||
/// silently dropping, matching [`parse_v1_endpoints`].
|
||||
pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.contains("embedding") {
|
||||
return false;
|
||||
}
|
||||
// Segment match so `bge`/`gte` cannot fire on a substring of a longer word.
|
||||
!lower
|
||||
.split('-')
|
||||
.any(|segment| matches!(segment, "bge" | "gte"))
|
||||
}
|
||||
|
||||
/// Discover available models for a Databricks provider.
|
||||
///
|
||||
/// Returns a non-empty `Vec<ModelEntry>` on success. Returns
|
||||
/// `Err(AgentError::LlmAuth)` when no token is available (no static token,
|
||||
/// no PKCE cache). The helper itself never starts interactive authentication.
|
||||
///
|
||||
/// # Panics
|
||||
/// Never panics.
|
||||
pub async fn discover_databricks_models(cfg: &Config) -> Result<Vec<ModelEntry>, AgentError> {
|
||||
discover_databricks_models_with_token_source(cfg, build_token_source(cfg)?).await
|
||||
}
|
||||
|
||||
async fn discover_databricks_models_with_token_source(
|
||||
cfg: &Config,
|
||||
token_source: Arc<dyn TokenSource>,
|
||||
) -> Result<Vec<ModelEntry>, AgentError> {
|
||||
let mut bearer = token_source.bearer_no_browser().await?;
|
||||
let http = Client::new();
|
||||
let host = cfg.base_url.trim_end_matches('/');
|
||||
let mut refreshed = false;
|
||||
|
||||
loop {
|
||||
let result = match cfg.provider {
|
||||
Provider::Databricks => fetch_v1_models(&http, host, &bearer).await,
|
||||
Provider::DatabricksV2 => fetch_v2_models(&http, host, &bearer).await,
|
||||
_ => {
|
||||
return Err(AgentError::InvalidParams(
|
||||
"discover_databricks_models called for non-Databricks provider".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(AgentError::LlmAuth(_)) if !refreshed => {
|
||||
refreshed = true;
|
||||
let fresh = token_source.refresh_now(&bearer).await?;
|
||||
if fresh == bearer {
|
||||
return Err(AgentError::LlmAuth(
|
||||
"Databricks rejected the configured credential".into(),
|
||||
));
|
||||
}
|
||||
bearer = fresh;
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v1 — api/2.0/serving-endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fetch_v1_models(
|
||||
http: &Client,
|
||||
host: &str,
|
||||
bearer: &str,
|
||||
) -> Result<Vec<ModelEntry>, AgentError> {
|
||||
let url = format!("{host}/api/2.0/serving-endpoints");
|
||||
let response = http
|
||||
.get(&url)
|
||||
.bearer_auth(bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Llm(format!("Databricks model discovery request failed: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(AgentError::LlmAuth(format!(
|
||||
"Databricks model discovery HTTP {status}"
|
||||
)));
|
||||
}
|
||||
return Err(AgentError::Llm(format!(
|
||||
"Databricks model discovery HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
AgentError::Llm(format!(
|
||||
"Databricks model discovery response parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
parse_v1_endpoints(&json)
|
||||
}
|
||||
|
||||
/// Parse a `GET api/2.0/serving-endpoints` response.
|
||||
///
|
||||
/// Filters to endpoints that are READY and serve an LLM chat/completions task.
|
||||
/// When `state.ready` or `task` is absent the endpoint is included — prefer
|
||||
/// including over silently dropping, per spec.
|
||||
pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result<Vec<ModelEntry>, AgentError> {
|
||||
let endpoints = json
|
||||
.get("endpoints")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| {
|
||||
AgentError::Llm(
|
||||
"Databricks model discovery: unexpected response (missing 'endpoints' array)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let models = endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| {
|
||||
let name = endpoint.get("name")?.as_str()?.to_string();
|
||||
|
||||
// Require READY state when present; include when absent.
|
||||
let state_ready = endpoint
|
||||
.get("state")
|
||||
.and_then(|s| s.get("ready"))
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|r| r == "READY")
|
||||
.unwrap_or(true);
|
||||
if !state_ready {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Require LLM chat or completions task when present.
|
||||
let task_ok = endpoint
|
||||
.get("task")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t == "llm/v1/chat" || t == "llm/v1/completions")
|
||||
.unwrap_or(true);
|
||||
if !task_ok {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ModelEntry {
|
||||
id: name.clone(),
|
||||
name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 — api/ai-gateway/v2/endpoints (paginated)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Percent-encode a string for use as a URL query parameter value.
|
||||
/// Only encodes characters that are not unreserved (RFC 3986).
|
||||
fn percent_encode(s: &str) -> String {
|
||||
s.bytes()
|
||||
.flat_map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
vec![b as char]
|
||||
}
|
||||
_ => format!("%{b:02X}").chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn fetch_v2_models(
|
||||
http: &Client,
|
||||
host: &str,
|
||||
bearer: &str,
|
||||
) -> Result<Vec<ModelEntry>, AgentError> {
|
||||
let mut all_endpoints: Vec<V2Endpoint> = Vec::new();
|
||||
let mut page_token: Option<String> = None;
|
||||
let base_url = format!("{host}/api/ai-gateway/v2/endpoints");
|
||||
|
||||
// Cap at 20 pages (2 000 endpoints) to bound execution time.
|
||||
for _ in 0..20 {
|
||||
// Build URL with query params manually — avoids requiring the `query`
|
||||
// reqwest feature in buzz-agent's Cargo.toml.
|
||||
let url = match &page_token {
|
||||
Some(tok) => format!(
|
||||
"{base_url}?page_size=100&page_token={}",
|
||||
percent_encode(tok)
|
||||
),
|
||||
None => format!("{base_url}?page_size=100"),
|
||||
};
|
||||
let response = http
|
||||
.get(&url)
|
||||
.bearer_auth(bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AgentError::Llm(format!("Databricks v2 model discovery request failed: {e}"))
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(AgentError::LlmAuth(format!(
|
||||
"Databricks v2 model discovery HTTP {status}"
|
||||
)));
|
||||
}
|
||||
return Err(AgentError::Llm(format!(
|
||||
"Databricks v2 model discovery HTTP {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
AgentError::Llm(format!(
|
||||
"Databricks v2 model discovery response parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let (page_endpoints, next) = parse_v2_endpoints_page(&json)?;
|
||||
all_endpoints.extend(page_endpoints);
|
||||
|
||||
match next {
|
||||
Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to known-model list if the API returned nothing.
|
||||
if all_endpoints.is_empty() {
|
||||
return Ok(authenticated_empty_v2_catalog());
|
||||
}
|
||||
|
||||
sort_v2_endpoints_newest_first(&mut all_endpoints);
|
||||
|
||||
Ok(all_endpoints
|
||||
.into_iter()
|
||||
.map(|endpoint| endpoint.entry)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A v2 gateway endpoint plus the key discovery orders the catalog by.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct V2Endpoint {
|
||||
pub(crate) entry: ModelEntry,
|
||||
/// `created_timestamp` as epoch milliseconds. `None` when the field is
|
||||
/// absent or unparseable — those sort last rather than jumping the queue.
|
||||
pub(crate) created_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// Read `created_timestamp` from one endpoint object.
|
||||
///
|
||||
/// The gateway sends epoch milliseconds as a JSON *string*
|
||||
/// (`"created_timestamp": "1699610000000"`); accept a bare number too, so a
|
||||
/// wire-shape change doesn't silently drop every endpoint to the bottom.
|
||||
fn endpoint_created_ms(endpoint: &serde_json::Value) -> Option<i64> {
|
||||
let value = endpoint.get("created_timestamp")?;
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_str()?.trim().parse::<i64>().ok())
|
||||
}
|
||||
|
||||
/// Order the catalog newest-first, breaking ties by name.
|
||||
///
|
||||
/// The gateway returns endpoints in two phases — Databricks-managed first, then
|
||||
/// workspace-created — each alphabetical by name, which buries a brand-new
|
||||
/// frontier model deep in the list. Newest-first puts the models people are
|
||||
/// reaching for at the top of the picker.
|
||||
///
|
||||
/// Endpoints with no usable timestamp sort last, and the name tiebreak keeps the
|
||||
/// result stable: several managed endpoints share one placeholder timestamp, so
|
||||
/// without it their relative order would be arbitrary.
|
||||
pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) {
|
||||
endpoints.sort_by(|a, b| {
|
||||
// `None` < `Some(_)`, so reversing puts timestamped endpoints first.
|
||||
b.created_ms
|
||||
.cmp(&a.created_ms)
|
||||
.then_with(|| a.entry.name.cmp(&b.entry.name))
|
||||
});
|
||||
}
|
||||
|
||||
/// Parse one page of a `GET api/ai-gateway/v2/endpoints` response.
|
||||
///
|
||||
/// Returns `(endpoints, next_page_token)`. An empty or absent `next_page_token`
|
||||
/// signals the last page. Endpoints that cannot serve chat traffic are dropped
|
||||
/// (see [`is_chat_capable_endpoint`]) so the model picker only offers models the
|
||||
/// agent can actually run. Page order is preserved here; the caller sorts once
|
||||
/// every page is in (see [`sort_v2_endpoints_newest_first`]).
|
||||
pub(crate) fn parse_v2_endpoints_page(
|
||||
json: &serde_json::Value,
|
||||
) -> Result<(Vec<V2Endpoint>, Option<String>), AgentError> {
|
||||
let endpoints = json
|
||||
.get("endpoints")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| {
|
||||
AgentError::Llm(
|
||||
"Databricks v2 model discovery: unexpected response (missing 'endpoints' array)"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let models = endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| {
|
||||
let name = endpoint.get("name")?.as_str()?.to_string();
|
||||
if !is_chat_capable_endpoint(&name) {
|
||||
return None;
|
||||
}
|
||||
Some(V2Endpoint {
|
||||
entry: ModelEntry {
|
||||
id: name.clone(),
|
||||
name,
|
||||
},
|
||||
created_ms: endpoint_created_ms(endpoint),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_page_token = json
|
||||
.get("next_page_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
Ok((models, next_page_token))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct RefreshingTestTokenSource {
|
||||
refreshes: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenSource for RefreshingTestTokenSource {
|
||||
async fn bearer(&self) -> Result<String, AgentError> {
|
||||
Ok("rejected".into())
|
||||
}
|
||||
|
||||
async fn refresh_now(&self, rejected: &str) -> Result<String, AgentError> {
|
||||
assert_eq!(rejected, "rejected");
|
||||
self.refreshes.fetch_add(1, Ordering::SeqCst);
|
||||
Ok("fresh".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_refreshes_rejected_bearer_once_then_retries_successfully() {
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let host = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let requests_for_route = requests.clone();
|
||||
let app = Router::new().route(
|
||||
"/api/ai-gateway/v2/endpoints",
|
||||
get(
|
||||
move |headers: HeaderMap, Query(_query): Query<HashMap<String, String>>| {
|
||||
let requests = requests_for_route.clone();
|
||||
async move {
|
||||
requests.fetch_add(1, Ordering::SeqCst);
|
||||
match headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some("Bearer fresh") => Ok(Json(serde_json::json!({
|
||||
"endpoints": [{"name": "discovered-model"}],
|
||||
"next_page_token": null,
|
||||
}))),
|
||||
_ => Err((StatusCode::UNAUTHORIZED, "rejected")),
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
let source = Arc::new(RefreshingTestTokenSource {
|
||||
refreshes: AtomicUsize::new(0),
|
||||
});
|
||||
let cfg = Config::for_discovery(Provider::DatabricksV2, String::new(), host);
|
||||
let models = discover_databricks_models_with_token_source(&cfg, source.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(models[0].id, "discovered-model");
|
||||
assert_eq!(source.refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_parse_filters_ready_chat_endpoints() {
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
// included: READY + llm/v1/chat
|
||||
{"name": "my-llm", "state": {"ready": "READY"}, "task": "llm/v1/chat"},
|
||||
// included: READY + llm/v1/completions
|
||||
{"name": "my-completions", "state": {"ready": "READY"}, "task": "llm/v1/completions"},
|
||||
// excluded: NOT_READY
|
||||
{"name": "dead-endpoint", "state": {"ready": "NOT_READY"}, "task": "llm/v1/chat"},
|
||||
// excluded: wrong task
|
||||
{"name": "embedding-ep", "state": {"ready": "READY"}, "task": "llm/v1/embedding"},
|
||||
// included: no state field → include by default
|
||||
{"name": "no-state", "task": "llm/v1/chat"},
|
||||
// included: no task field → include by default
|
||||
{"name": "no-task", "state": {"ready": "READY"}},
|
||||
]
|
||||
});
|
||||
|
||||
let models = parse_v1_endpoints(&json).unwrap();
|
||||
let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["my-llm", "my-completions", "no-state", "no-task"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_parse_errors_on_missing_endpoints_array() {
|
||||
let json = serde_json::json!({"data": []});
|
||||
let err = parse_v1_endpoints(&json).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("missing 'endpoints' array"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_parse_empty_endpoints_returns_empty_vec() {
|
||||
let json = serde_json::json!({"endpoints": []});
|
||||
let models = parse_v1_endpoints(&json).unwrap();
|
||||
assert!(models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_extracts_names_and_page_token() {
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
{"name": "databricks-claude-opus-4-7"},
|
||||
{"name": "databricks-gpt-5-5"},
|
||||
{"name": "custom-model"}
|
||||
],
|
||||
"next_page_token": "tok123"
|
||||
});
|
||||
|
||||
let (models, next) = parse_v2_endpoints_page(&json).unwrap();
|
||||
let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"databricks-claude-opus-4-7",
|
||||
"databricks-gpt-5-5",
|
||||
"custom-model"
|
||||
]
|
||||
);
|
||||
assert_eq!(next.as_deref(), Some("tok123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_empty_token_signals_last_page() {
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [{"name": "only-model"}],
|
||||
"next_page_token": ""
|
||||
});
|
||||
|
||||
let (models, next) = parse_v2_endpoints_page(&json).unwrap();
|
||||
assert_eq!(models.len(), 1);
|
||||
assert!(
|
||||
next.is_none(),
|
||||
"empty token should be treated as no more pages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_absent_token_signals_last_page() {
|
||||
let json = serde_json::json!({"endpoints": [{"name": "only-model"}]});
|
||||
let (_, next) = parse_v2_endpoints_page(&json).unwrap();
|
||||
assert!(next.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_errors_on_missing_endpoints_array() {
|
||||
let json = serde_json::json!({"data": []});
|
||||
let err = parse_v2_endpoints_page(&json).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("missing 'endpoints' array"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_drops_embedding_endpoints() {
|
||||
// The v2 payload carries no `task`, so embedding endpoints are only
|
||||
// recognisable by name. They reject chat requests, so offering them in
|
||||
// the picker can only produce a 400 at send time.
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
{"name": "databricks-bge-large-en"},
|
||||
{"name": "databricks-gte-large-en"},
|
||||
{"name": "databricks-qwen3-embedding-0-6b"},
|
||||
{"name": "databricks-claude-opus-5"},
|
||||
{"name": "databricks-gemini-3-pro-image"},
|
||||
]
|
||||
});
|
||||
|
||||
let (models, _) = parse_v2_endpoints_page(&json).unwrap();
|
||||
let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect();
|
||||
// Image endpoints DO answer chat requests, so they are retained.
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_reads_created_timestamp_in_either_wire_shape() {
|
||||
// The gateway sends epoch ms as a string; a bare number must work too.
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
{"name": "string-ts", "created_timestamp": "1784932442251"},
|
||||
{"name": "number-ts", "created_timestamp": 1784932442251i64},
|
||||
{"name": "junk-ts", "created_timestamp": "not-a-number"},
|
||||
{"name": "no-ts"},
|
||||
]
|
||||
});
|
||||
|
||||
let (models, _) = parse_v2_endpoints_page(&json).unwrap();
|
||||
let stamps: Vec<Option<i64>> = models.iter().map(|m| m.created_ms).collect();
|
||||
assert_eq!(
|
||||
stamps,
|
||||
vec![Some(1784932442251), Some(1784932442251), None, None,]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_endpoints_sort_newest_first_then_by_name() {
|
||||
// Mirrors the real catalog: the gateway pages Databricks-managed
|
||||
// endpoints first, then workspace-created ones, each alphabetical — so
|
||||
// the newest model is buried mid-list until this sort runs.
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
{"name": "databricks-claude-opus-5", "created_timestamp": "1784851200000"},
|
||||
{"name": "databricks-gpt-5-6-sol", "created_timestamp": "1784073600000"},
|
||||
{"name": "databricks-gpt-5-6-luna", "created_timestamp": "1784073600000"},
|
||||
{"name": "databricks-llama-4-maverick", "created_timestamp": "1699610000000"},
|
||||
{"name": "goose-claude-opus-5", "created_timestamp": "1784932442251"},
|
||||
{"name": "endpoint-without-timestamp"},
|
||||
]
|
||||
});
|
||||
|
||||
let (mut models, _) = parse_v2_endpoints_page(&json).unwrap();
|
||||
sort_v2_endpoints_newest_first(&mut models);
|
||||
|
||||
let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
// Newest first, across both pagination phases.
|
||||
"goose-claude-opus-5",
|
||||
"databricks-claude-opus-5",
|
||||
// Same timestamp — the name tiebreak keeps this deterministic.
|
||||
"databricks-gpt-5-6-luna",
|
||||
"databricks-gpt-5-6-sol",
|
||||
"databricks-llama-4-maverick",
|
||||
// No usable timestamp sorts last, never first.
|
||||
"endpoint-without-timestamp",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_empty_v2_catalog_marks_fallback_provenance() {
|
||||
let models = authenticated_empty_v2_catalog();
|
||||
let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
|
||||
|
||||
assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS);
|
||||
assert!(models.iter().all(|model| {
|
||||
model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_chat_capable_endpoint_keeps_unrecognised_names() {
|
||||
// Prefer including over silently dropping — an unknown family is kept.
|
||||
assert!(is_chat_capable_endpoint("databricks-glm-5-2"));
|
||||
assert!(is_chat_capable_endpoint("some-teams-custom-endpoint"));
|
||||
// `bge`/`gte` match as whole segments only, never as substrings.
|
||||
assert!(is_chat_capable_endpoint("databricks-budget-gtex-model"));
|
||||
assert!(!is_chat_capable_endpoint("databricks-bge-large-en"));
|
||||
assert!(!is_chat_capable_endpoint("databricks-gte-large-en"));
|
||||
assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,650 @@
|
||||
use crate::agent::RunCtx;
|
||||
use crate::config::{
|
||||
HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_MIN_PROMPT_BUDGET_BYTES,
|
||||
HANDOFF_ORIGINAL_TASK_MAX_BYTES, MAX_CONTEXT_RECOVERIES_PER_RUN,
|
||||
};
|
||||
use crate::llm::summary_completion_cap;
|
||||
use crate::types::HistoryItem;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct HandoffTokenCounts {
|
||||
before: u64,
|
||||
after: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HandoffTokenCounts {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} -> {} tokens", self.before, self.after)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum HandoffOutcome {
|
||||
Performed,
|
||||
Skipped,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Result of the reactive context-recovery ladder.
|
||||
pub(crate) enum ContextRecovery {
|
||||
/// History was reset; the caller should retry the request.
|
||||
Recovered,
|
||||
/// Cancelled mid-recovery.
|
||||
Cancelled,
|
||||
/// No rescue remains — the caller must surface the provider error. Either
|
||||
/// the per-`run()` budget is spent or the prompt budget fell below the
|
||||
/// floor where a summary can still be useful.
|
||||
Exhausted,
|
||||
}
|
||||
|
||||
/// System prompt for the handoff summarizer. `LazyLock` + `format!` so the
|
||||
/// token figure is derived from [`HANDOFF_MAX_OUTPUT_TOKENS`] instead of a
|
||||
/// duplicated literal, and "visible plain-text summary" makes explicit that
|
||||
/// the limit is on summary text, not on any hidden reasoning the model does
|
||||
/// first (which is budgeted separately on the wire — see
|
||||
/// `openrouter_summary_body`).
|
||||
static HANDOFF_SYSTEM_PROMPT: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
format!(
|
||||
"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. Keep the visible plain-text summary under \
|
||||
{HANDOFF_MAX_OUTPUT_TOKENS} tokens."
|
||||
)
|
||||
});
|
||||
|
||||
impl RunCtx<'_> {
|
||||
pub(crate) async fn maybe_handoff(&mut self, handoff_attempts: &mut usize) -> HandoffOutcome {
|
||||
if !self.should_handoff() {
|
||||
return HandoffOutcome::Skipped;
|
||||
}
|
||||
if *handoff_attempts >= self.cfg.max_handoffs {
|
||||
let projected = self.projected_handoff_input_tokens();
|
||||
let threshold =
|
||||
token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens);
|
||||
tracing::warn!(
|
||||
session_id = self.session_id,
|
||||
reason = "preflight",
|
||||
handoff_attempts = *handoff_attempts,
|
||||
max_handoffs = self.cfg.max_handoffs,
|
||||
projected_tokens = projected,
|
||||
threshold_tokens = threshold,
|
||||
"handoff cap reached; using truncation",
|
||||
);
|
||||
return HandoffOutcome::Skipped;
|
||||
}
|
||||
// Consume one attempt slot before calling handoff(). This ensures
|
||||
// that empty-summary, summarize-error, and cancellation outcomes all
|
||||
// burn budget — not just successful compactions — so the cap cannot
|
||||
// be bypassed by a flaky summarizer.
|
||||
*handoff_attempts += 1;
|
||||
self.handoff(None).await
|
||||
}
|
||||
|
||||
/// Handoff forced by a provider context-window rejection, bypassing both
|
||||
/// gates in [`Self::maybe_handoff`].
|
||||
///
|
||||
/// The gates exist to *predict* overflow; a 400 naming a context-length
|
||||
/// overflow is overflow already observed, so neither prediction applies.
|
||||
/// `should_handoff()` reads a token count frozen at the last SUCCESSFUL
|
||||
/// request (a failed request reports no usage), so it is under threshold by
|
||||
/// construction — that frozen reading is the permanent stick. And
|
||||
/// `max_handoffs` is a cost cap whose only alternative here is a request
|
||||
/// that cannot succeed.
|
||||
///
|
||||
/// `history_budget_bytes` is explicit rather than derived from
|
||||
/// `cfg.max_context_tokens`: that window is the quantity the provider just
|
||||
/// contradicted, so the recovery ladder must not be computed from it.
|
||||
pub(crate) async fn forced_handoff(&mut self, history_budget_bytes: usize) -> HandoffOutcome {
|
||||
tracing::warn!(
|
||||
"provider reported context overflow; forcing handoff (history budget {history_budget_bytes} bytes)"
|
||||
);
|
||||
self.handoff(Some(history_budget_bytes)).await
|
||||
}
|
||||
|
||||
/// The reactive context-recovery ladder, run after the provider rejected a
|
||||
/// request with a context-window 400.
|
||||
///
|
||||
/// `attempts` is the caller's per-`run()` recovery counter, advanced here as
|
||||
/// rungs are consumed. The caller owns it so the budget spans every
|
||||
/// context-400 in the turn, not just the rungs of one ladder.
|
||||
///
|
||||
/// The shrink schedule is anchored on the history that was just *observed*
|
||||
/// to be too large, halving from there — not on `cfg.max_context_tokens`,
|
||||
/// which the provider just contradicted and which may be overstated by an
|
||||
/// unknown factor. Halving needs no calibration: by the third rung it is at
|
||||
/// 1/8 of the rejected size.
|
||||
///
|
||||
/// Loops rather than returning after one rung because the summarize call
|
||||
/// travels the same provider path and can be rejected for the same reason.
|
||||
/// Treating that as unrecoverable would reproduce the very stick this fixes:
|
||||
/// the next rung halves the summarizer's own prompt, which is the only way
|
||||
/// out.
|
||||
///
|
||||
/// Gives up when the next budget would fall below
|
||||
/// [`HANDOFF_MIN_PROMPT_BUDGET_BYTES`]. That can happen on the FIRST rung
|
||||
/// when history is already small — correct, not premature: if a few KiB of
|
||||
/// history still overflows the window, the overflow is dominated by what a
|
||||
/// handoff cannot shrink (system prompt, tool schemas, the live user
|
||||
/// prompt), so further halving would only issue smaller doomed requests in
|
||||
/// place of a clear error.
|
||||
pub(crate) async fn recover_from_context_overflow(
|
||||
&mut self,
|
||||
attempts: &mut u32,
|
||||
) -> ContextRecovery {
|
||||
let rejected_bytes: usize = self
|
||||
.history
|
||||
.iter()
|
||||
.map(HistoryItem::context_pressure_bytes)
|
||||
.sum();
|
||||
loop {
|
||||
if *attempts >= MAX_CONTEXT_RECOVERIES_PER_RUN {
|
||||
tracing::error!(
|
||||
"context recovery budget spent ({MAX_CONTEXT_RECOVERIES_PER_RUN} attempts this turn); surfacing provider error"
|
||||
);
|
||||
return ContextRecovery::Exhausted;
|
||||
}
|
||||
// Shift by `attempts + 1`: the first rung already halves, since
|
||||
// rebuilding the rejected size would just fail again.
|
||||
let shift = (*attempts + 1).min(usize::BITS - 1);
|
||||
let budget = rejected_bytes >> shift;
|
||||
*attempts += 1;
|
||||
if budget < HANDOFF_MIN_PROMPT_BUDGET_BYTES {
|
||||
tracing::error!(
|
||||
"context recovery would shrink the handoff prompt to {budget} bytes, below \
|
||||
the {HANDOFF_MIN_PROMPT_BUDGET_BYTES}-byte floor (history {rejected_bytes} \
|
||||
bytes); surfacing provider error"
|
||||
);
|
||||
return ContextRecovery::Exhausted;
|
||||
}
|
||||
match self.forced_handoff(budget).await {
|
||||
HandoffOutcome::Performed => return ContextRecovery::Recovered,
|
||||
HandoffOutcome::Cancelled => return ContextRecovery::Cancelled,
|
||||
// Summarizer errored or returned nothing — possibly because its
|
||||
// own prompt overflowed. Truncation is not a usable fallback
|
||||
// (it sizes against the request-body budget, not context
|
||||
// pressure), so take the next rung with a smaller prompt.
|
||||
HandoffOutcome::Skipped => {
|
||||
tracing::warn!(
|
||||
"forced handoff at {budget} bytes did not run; shrinking further"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The handoff mechanism itself: summarize, reset, re-seat the live prompt.
|
||||
/// Holds no gate — callers decide whether a handoff is warranted.
|
||||
async fn handoff(&mut self, history_budget_bytes: Option<usize>) -> HandoffOutcome {
|
||||
let prompt = self.build_handoff_prompt(history_budget_bytes);
|
||||
let tokens_before = self.projected_handoff_input_tokens();
|
||||
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,
|
||||
self.effective_model,
|
||||
) => 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 and hook output are injected as a synthetic user
|
||||
// message in one block. This keeps `_PostCompact` untrusted while also
|
||||
// avoiding orphan tool-result messages in the fresh context: OpenAI
|
||||
// Chat/Responses require tool outputs to follow an assistant tool call,
|
||||
// but handoff reset intentionally discards the old assistant turn.
|
||||
let mut handoff_text = format!("[Context Handoff]\n{summary}");
|
||||
if !post_compact.is_empty() {
|
||||
handoff_text.push_str("\n\n[Post-compact hook output — untrusted]\n");
|
||||
handoff_text.push_str(&hook_outputs_text(&post_compact));
|
||||
}
|
||||
self.history.push(HistoryItem::User(handoff_text));
|
||||
if let Some(prompt) = current_prompt {
|
||||
self.history.push(HistoryItem::User(prompt));
|
||||
}
|
||||
*self.handoff_count += 1;
|
||||
let token_counts = HandoffTokenCounts {
|
||||
before: tokens_before,
|
||||
after: estimate_history_tokens(self.history),
|
||||
};
|
||||
tracing::info!(
|
||||
"handoff #{} (history {prior} -> {} items; {token_counts})",
|
||||
*self.handoff_count,
|
||||
self.history.len()
|
||||
);
|
||||
HandoffOutcome::Performed
|
||||
}
|
||||
|
||||
fn should_handoff(&self) -> bool {
|
||||
match *self.last_request_input_tokens {
|
||||
Some(_) => {
|
||||
self.projected_handoff_input_tokens()
|
||||
>= token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens)
|
||||
}
|
||||
None => {
|
||||
let bytes: usize = self
|
||||
.history
|
||||
.iter()
|
||||
.map(HistoryItem::context_pressure_bytes)
|
||||
.sum();
|
||||
bytes
|
||||
> byte_fallback_threshold(
|
||||
self.cfg.max_context_tokens,
|
||||
self.cfg.max_output_tokens,
|
||||
self.cfg.max_history_bytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn projected_handoff_input_tokens(&self) -> u64 {
|
||||
let current_tokens = estimate_history_tokens(self.history);
|
||||
match *self.last_request_input_tokens {
|
||||
// Token-first: the provider told us exactly how many input tokens
|
||||
// the PREVIOUS request used. But history has grown since that
|
||||
// measurement — new assistant text, tool results, and the next
|
||||
// user prompt are appended before the next `complete()`. The exact
|
||||
// count alone would miss "previous request was under threshold, but
|
||||
// newly appended content pushes the next one over" (the stale-usage
|
||||
// cousin of the original stale-bytes bug). So we add a conservative
|
||||
// token estimate of the bytes added since the measurement.
|
||||
Some(measured_tokens) => {
|
||||
let measured_bytes = self.last_request_history_bytes.unwrap_or(0);
|
||||
let current_bytes: usize = self
|
||||
.history
|
||||
.iter()
|
||||
.map(HistoryItem::context_pressure_bytes)
|
||||
.sum();
|
||||
let grown = current_bytes.saturating_sub(measured_bytes);
|
||||
measured_tokens.saturating_add(estimate_tokens_from_bytes(grown))
|
||||
}
|
||||
// No usage yet (first request, or just after a handoff reset).
|
||||
// Fall back to the byte heuristic, capped conservatively so a
|
||||
// single pre-usage request can't blow the window. We map the token
|
||||
// threshold to bytes using a deliberately LOW bytes/token ratio:
|
||||
// a low ratio implies more tokens per byte, so the byte cap is
|
||||
// small and the handoff fires early rather than late. Never raise
|
||||
// the cap above the configured byte budget.
|
||||
//
|
||||
// Caveat: this can't shrink a single oversized current prompt,
|
||||
// since a handoff re-adds the current prompt verbatim — that is a
|
||||
// prompt-cap concern (MAX_PROMPT_BYTES), not this gate.
|
||||
None => current_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the summarizer prompt. `history_budget_bytes` overrides the
|
||||
/// budget normally derived from `cfg.max_context_tokens`; `None` keeps the
|
||||
/// derived value, which is what the proactive path uses.
|
||||
fn build_handoff_prompt(&self, history_budget_bytes: Option<usize>) -> 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# Session History (oldest first)\n";
|
||||
let fixed_bytes = head.len() + history_header.len() + tail.len();
|
||||
// An explicit budget is the allowance for the whole prompt, so subtract
|
||||
// the fixed frame from it exactly as the derived path does — otherwise
|
||||
// a caller's ceiling would be silently exceeded by the frame. When the
|
||||
// frame alone is larger than the budget, history drops to zero and the
|
||||
// frame is what remains: it is already independently clamped
|
||||
// (`HANDOFF_ORIGINAL_TASK_MAX_BYTES`, `HANDOFF_MAX_TOOL_NAMES`) and is
|
||||
// not reducible from here.
|
||||
let prompt_budget = match history_budget_bytes {
|
||||
Some(explicit) => explicit.saturating_sub(fixed_bytes),
|
||||
None => handoff_prompt_budget_bytes(
|
||||
self.cfg.max_context_tokens,
|
||||
summary_completion_cap(self.cfg.provider, HANDOFF_MAX_OUTPUT_TOKENS),
|
||||
fixed_bytes,
|
||||
),
|
||||
};
|
||||
|
||||
let mut snippets: Vec<String> = Vec::new();
|
||||
let mut snippets_bytes = 0usize;
|
||||
let mut dropped = 0usize;
|
||||
for item in self.history.iter().rev() {
|
||||
let mut snippet = String::new();
|
||||
push_history_snippet(&mut snippet, item);
|
||||
let snippet_bytes = snippet.len();
|
||||
if snippets_bytes.saturating_add(snippet_bytes) > prompt_budget {
|
||||
if snippets.is_empty() {
|
||||
snippets.push(clamp_bytes(&snippet, prompt_budget));
|
||||
snippets_bytes = prompt_budget;
|
||||
}
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
snippets_bytes += snippet_bytes;
|
||||
snippets.push(snippet);
|
||||
}
|
||||
snippets.reverse();
|
||||
if dropped > 0 {
|
||||
tracing::info!(
|
||||
"handoff prompt budget, dropped {dropped} oldest snippets; kept {} bytes",
|
||||
snippets_bytes
|
||||
);
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(
|
||||
head.len()
|
||||
+ history_header.len()
|
||||
+ tail.len()
|
||||
+ 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 hook_outputs_text(outputs: &[(String, String)]) -> String {
|
||||
outputs
|
||||
.iter()
|
||||
.map(|(name, text)| format!("[{name}]\n{text}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn push_history_snippet(out: &mut String, item: &HistoryItem) {
|
||||
match item {
|
||||
HistoryItem::User(s) => {
|
||||
out.push_str("[user] ");
|
||||
out.push_str(s);
|
||||
out.push('\n');
|
||||
}
|
||||
HistoryItem::Assistant {
|
||||
text,
|
||||
tool_calls,
|
||||
reasoning_details: _,
|
||||
} => {
|
||||
out.push_str("[assistant] ");
|
||||
if !text.is_empty() {
|
||||
out.push_str(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(&r.text());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte budget for session-history text inside the handoff prompt. The
|
||||
/// summarizer uses the same provider/model config as normal completion, so
|
||||
/// derive the input budget from the model context window instead of applying a
|
||||
/// separate fixed prompt cap. We keep the same 1 byte/token upper-bound
|
||||
/// estimate used by the handoff gate, which is conservative: it may drop old
|
||||
/// history early for unusually large sessions, but it should not build a prompt
|
||||
/// that exceeds the configured context window.
|
||||
fn handoff_prompt_budget_bytes(
|
||||
max_context_tokens: u64,
|
||||
max_output_tokens: u32,
|
||||
fixed_prompt_bytes: usize,
|
||||
) -> usize {
|
||||
max_context_tokens
|
||||
.saturating_sub(u64::from(max_output_tokens))
|
||||
.saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN)
|
||||
.saturating_sub(u64::try_from(fixed_prompt_bytes).unwrap_or(u64::MAX))
|
||||
.try_into()
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
/// Conservative bytes-per-token ratio used when estimating tokens from raw
|
||||
/// history bytes. We use 1: a token is always at least one byte, so treating
|
||||
/// every byte as a whole token is an unconditional UPPER bound on the true
|
||||
/// token count — it can never undercount, regardless of content density (even
|
||||
/// the densest real content sits at ~1.4 bytes/token). That over-estimate is
|
||||
/// exactly what a fail-early preflight gate wants: it hands off sooner rather
|
||||
/// than risk the next request exceeding the window.
|
||||
const CONSERVATIVE_BYTES_PER_TOKEN: u64 = 1;
|
||||
|
||||
fn estimate_history_tokens(history: &[HistoryItem]) -> u64 {
|
||||
estimate_tokens_from_bytes(
|
||||
history
|
||||
.iter()
|
||||
.map(HistoryItem::context_pressure_bytes)
|
||||
.sum(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Estimate tokens from a byte count at the conservative ratio (rounding up,
|
||||
/// so a partial token still counts). At a 1:1 ratio this is just the byte
|
||||
/// count — a guaranteed upper bound on tokens.
|
||||
fn estimate_tokens_from_bytes(bytes: usize) -> u64 {
|
||||
(bytes as u64).div_ceil(CONSERVATIVE_BYTES_PER_TOKEN)
|
||||
}
|
||||
|
||||
/// Input-token count at which to hand off. Caps at the configured fraction of
|
||||
/// the window and also leaves room for `max_output_tokens`, so input + output
|
||||
/// can't together exceed the window. Free function so the policy math is unit
|
||||
/// testable without constructing a [`RunCtx`].
|
||||
fn token_threshold(max_context_tokens: u64, max_output_tokens: u32) -> u64 {
|
||||
// Integer math: handoff threshold is 90%, i.e. window * 9 / 10.
|
||||
let fractional = max_context_tokens / 10 * 9;
|
||||
let output_reserved = max_context_tokens.saturating_sub(u64::from(max_output_tokens));
|
||||
fractional.min(output_reserved)
|
||||
}
|
||||
|
||||
/// Conservative byte cap used only before any usage is known. Maps the token
|
||||
/// threshold to bytes at the conservative bytes/token ratio (so the cap is
|
||||
/// small and the handoff fires early), clamped to the configured byte budget
|
||||
/// so it can only ever be more conservative than the old byte-only behavior.
|
||||
fn byte_fallback_threshold(
|
||||
max_context_tokens: u64,
|
||||
max_output_tokens: u32,
|
||||
max_history_bytes: usize,
|
||||
) -> usize {
|
||||
let derived = token_threshold(max_context_tokens, max_output_tokens)
|
||||
.saturating_mul(CONSERVATIVE_BYTES_PER_TOKEN);
|
||||
let byte_cap = max_history_bytes / 10 * 9;
|
||||
usize::try_from(derived).unwrap_or(usize::MAX).min(byte_cap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
byte_fallback_threshold, estimate_tokens_from_bytes, handoff_prompt_budget_bytes,
|
||||
summary_completion_cap, token_threshold, HANDOFF_SYSTEM_PROMPT,
|
||||
};
|
||||
use crate::config::{Provider, HANDOFF_MAX_OUTPUT_TOKENS};
|
||||
|
||||
#[test]
|
||||
fn handoff_prompt_budget_reserves_summary_output_and_fixed_prompt() {
|
||||
assert_eq!(handoff_prompt_budget_bytes(25_000, 8_192, 1_000), 15_808);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_prompt_budget_saturates_when_fixed_prompt_exceeds_window() {
|
||||
assert_eq!(handoff_prompt_budget_bytes(1_000, 2_000, 10_000), 0);
|
||||
}
|
||||
|
||||
/// OpenRouter's summary request grants reasoning an equal budget on top of
|
||||
/// the visible-text budget, so its completion cap is 2× the handoff text
|
||||
/// budget; the input budget must reserve that doubled cap. At the
|
||||
/// 1-byte/token upper bound, prompt bytes bound prompt tokens, so the join
|
||||
/// to pin is: (budget + fixed prompt) + actual completion cap ≤ window.
|
||||
/// Reserving only `HANDOFF_MAX_OUTPUT_TOKENS` would break this by exactly
|
||||
/// one extra reasoning budget at the maximum constructed prompt.
|
||||
#[test]
|
||||
fn openrouter_prompt_budget_reserves_doubled_completion_cap() {
|
||||
let cap = summary_completion_cap(Provider::OpenRouter, HANDOFF_MAX_OUTPUT_TOKENS);
|
||||
assert_eq!(
|
||||
cap,
|
||||
2 * HANDOFF_MAX_OUTPUT_TOKENS,
|
||||
"OpenRouter doubles: text + reasoning"
|
||||
);
|
||||
let window = 200_000u64;
|
||||
let fixed = 1_000usize;
|
||||
let budget = handoff_prompt_budget_bytes(window, cap, fixed);
|
||||
assert_eq!(budget, 182_616); // 200_000 - 16_384 - 1_000
|
||||
let max_prompt_tokens = estimate_tokens_from_bytes(budget + fixed);
|
||||
assert!(
|
||||
max_prompt_tokens + u64::from(cap) <= window,
|
||||
"input + completion allowance must fit the configured window"
|
||||
);
|
||||
// The old single reservation violates the same join — the regression
|
||||
// this guards against.
|
||||
let stale_budget = handoff_prompt_budget_bytes(window, HANDOFF_MAX_OUTPUT_TOKENS, fixed);
|
||||
assert!(
|
||||
estimate_tokens_from_bytes(stale_budget + fixed) + u64::from(cap) > window,
|
||||
"reserving only the text budget must be observable as an overflow here"
|
||||
);
|
||||
}
|
||||
|
||||
/// Anthropic/OpenAI/Databricks summary bodies request exactly the caller's
|
||||
/// budget, so their input reservation is unchanged.
|
||||
#[test]
|
||||
fn non_openrouter_completion_cap_is_the_callers_budget() {
|
||||
for provider in [
|
||||
Provider::Anthropic,
|
||||
Provider::OpenAi,
|
||||
Provider::Databricks,
|
||||
Provider::DatabricksV2,
|
||||
] {
|
||||
assert_eq!(
|
||||
summary_completion_cap(provider, HANDOFF_MAX_OUTPUT_TOKENS),
|
||||
HANDOFF_MAX_OUTPUT_TOKENS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt's token figure is derived from `HANDOFF_MAX_OUTPUT_TOKENS`
|
||||
/// and names the *visible plain-text summary* as its target, so hidden
|
||||
/// reasoning (budgeted separately on the wire) is not the referent.
|
||||
#[test]
|
||||
fn handoff_system_prompt_derives_limit_and_targets_visible_text() {
|
||||
let expected = format!(
|
||||
"Keep the visible plain-text summary under {HANDOFF_MAX_OUTPUT_TOKENS} tokens."
|
||||
);
|
||||
assert!(
|
||||
HANDOFF_SYSTEM_PROMPT.contains(&expected),
|
||||
"prompt must derive its token figure from HANDOFF_MAX_OUTPUT_TOKENS: {}",
|
||||
*HANDOFF_SYSTEM_PROMPT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_threshold_uses_fraction_when_output_is_small() {
|
||||
// 200k window, 1k output. fractional = 0.9*200000 = 180000;
|
||||
// output_reserved = 200000-1000 = 199000; min = 180000.
|
||||
assert_eq!(token_threshold(200_000, 1_000), 180_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_threshold_reserves_output_headroom() {
|
||||
// Large output relative to window: the output-reserve term dominates,
|
||||
// keeping input+output within the window.
|
||||
// 100k window, 40k output: fractional=90k, reserved=60k -> 60k.
|
||||
assert_eq!(token_threshold(100_000, 40_000), 60_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_threshold_saturates_when_output_exceeds_window() {
|
||||
// Degenerate (config validation forbids this, but math must not panic):
|
||||
// reserved saturates to 0, so threshold is 0 -> always hand off.
|
||||
assert_eq!(token_threshold(1000, 5000), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_fallback_is_conservative_and_capped() {
|
||||
// Derived = token_threshold * 1 (1 byte/token upper bound). For
|
||||
// 200k/1k: 180000 bytes, well under a 16 MiB byte budget, so derived
|
||||
// wins (early handoff).
|
||||
let t = byte_fallback_threshold(200_000, 1_000, 16 * 1024 * 1024);
|
||||
assert_eq!(t, 180_000);
|
||||
// With a tiny byte budget the cap wins -> never exceeds it (window*90%).
|
||||
let capped = byte_fallback_threshold(200_000, 1_000, 8192);
|
||||
assert_eq!(capped, 8192 / 10 * 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_tokens_is_upper_bound_on_tokens() {
|
||||
// 1 byte/token: a token is always >= 1 byte, so byte count is an
|
||||
// unconditional upper bound on the true token count.
|
||||
assert_eq!(estimate_tokens_from_bytes(0), 0);
|
||||
assert_eq!(estimate_tokens_from_bytes(1), 1);
|
||||
assert_eq!(estimate_tokens_from_bytes(4), 4);
|
||||
assert_eq!(estimate_tokens_from_bytes(5), 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::mcp::truncate_at_boundary;
|
||||
|
||||
const MAX_HINTS_BYTES: usize = 128 * 1024;
|
||||
pub const MAX_SKILL_BODY_BYTES: usize = 32 * 1024;
|
||||
const SKILL_DIRS: &[&str] = &[".agents/skills", ".goose/skills", ".claude/skills"];
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var("HOME").ok().map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SkillEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Absolute path to the SKILL.md file; used by `load_skill` to read on demand.
|
||||
pub path: PathBuf,
|
||||
/// Absolute paths to every non-SKILL.md file in the skill directory tree.
|
||||
/// Pre-enumerated at discovery time so `load_skill` can match by relative path
|
||||
/// without doing arbitrary filesystem lookups at call time.
|
||||
pub supporting_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Handles both normal repos (`.git/` dir) and worktrees (`.git` file).
|
||||
fn find_git_root(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = start.to_path_buf();
|
||||
loop {
|
||||
if current.join(".git").exists() {
|
||||
return Some(current);
|
||||
}
|
||||
match current.parent() {
|
||||
Some(parent) => current = parent.to_path_buf(),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_hint_files_impl(cwd: &Path, home: Option<&Path>) -> String {
|
||||
let mut chain = match find_git_root(cwd) {
|
||||
Some(root) => {
|
||||
let mut c: Vec<PathBuf> = cwd
|
||||
.ancestors()
|
||||
.take_while(|a| a.starts_with(&root))
|
||||
.map(|a| a.to_path_buf())
|
||||
.collect();
|
||||
// ancestors() yields cwd first, root last — reverse for root→cwd.
|
||||
c.reverse();
|
||||
c
|
||||
}
|
||||
None => vec![cwd.to_path_buf()],
|
||||
};
|
||||
|
||||
// Prepend ~/AGENTS.md as global layer, unless ~ is already in the chain.
|
||||
if let Some(home) = home {
|
||||
if !chain.iter().any(|d| d == home) {
|
||||
chain.insert(0, home.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
for dir in &chain {
|
||||
let path = dir.join("AGENTS.md");
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !result.is_empty() {
|
||||
result.push_str("\n\n");
|
||||
}
|
||||
let remaining = MAX_HINTS_BYTES.saturating_sub(result.len());
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if content.len() <= remaining {
|
||||
result.push_str(&content);
|
||||
} else {
|
||||
let truncated = truncate_at_boundary(&content, remaining);
|
||||
result.push_str(truncated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> {
|
||||
// Must start with `---`
|
||||
let rest = content.strip_prefix("---\n")?;
|
||||
// Find the closing `---`
|
||||
let close_pos = rest.find("\n---")?;
|
||||
let yaml_block = &rest[..close_pos];
|
||||
|
||||
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_block).ok()?;
|
||||
let name = map
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)?;
|
||||
let description = map
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Some((name, description))
|
||||
}
|
||||
|
||||
fn scan_skill_dir(dir: &Path, seen: &mut HashSet<String>, skills: &mut Vec<SkillEntry>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut subdirs: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
// Use std::fs::metadata (follows symlinks) rather than DirEntry::file_type
|
||||
// (which returns FileType::Symlink for symlinks, causing is_dir() to return
|
||||
// false even when the symlink target is a directory).
|
||||
.filter(|e| {
|
||||
std::fs::metadata(e.path())
|
||||
.map(|m| m.is_dir())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
subdirs.sort();
|
||||
|
||||
for subdir in subdirs {
|
||||
let skill_md = subdir.join("SKILL.md");
|
||||
let Ok(content) = std::fs::read_to_string(&skill_md) else {
|
||||
continue;
|
||||
};
|
||||
let Some((name, description)) = parse_skill_frontmatter(&content) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(name.clone());
|
||||
|
||||
// Collect supporting files: every non-SKILL.md file in the skill dir tree.
|
||||
// Don't descend into subdirs that themselves have a SKILL.md — those are
|
||||
// separate skills with their own entries.
|
||||
let supporting_files = collect_supporting_files(&subdir);
|
||||
|
||||
skills.push(SkillEntry {
|
||||
name,
|
||||
description,
|
||||
path: skill_md,
|
||||
supporting_files,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `skill_dir` recursively and return the absolute path of every file
|
||||
/// that is not `SKILL.md`. Subdirectories that contain their own `SKILL.md`
|
||||
/// are treated as separate skills and are not descended into.
|
||||
fn collect_supporting_files(skill_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let mut visited_dirs = HashSet::new();
|
||||
collect_supporting_files_impl(skill_dir, &mut result, &mut visited_dirs);
|
||||
result.sort();
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_supporting_files_impl(
|
||||
current: &Path,
|
||||
out: &mut Vec<PathBuf>,
|
||||
visited_dirs: &mut HashSet<PathBuf>,
|
||||
) {
|
||||
let Ok(canonical_current) = current.canonicalize() else {
|
||||
return;
|
||||
};
|
||||
if !visited_dirs.insert(canonical_current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(current) else {
|
||||
return;
|
||||
};
|
||||
let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||
items.sort_by_key(|e| e.path());
|
||||
|
||||
for entry in items {
|
||||
let path = entry.path();
|
||||
// Use std::fs::metadata (follows symlinks) so symlinked subdirs and files
|
||||
// inside a skill directory are handled correctly.
|
||||
let ft = match std::fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_dir() {
|
||||
// Don't descend into subdirs that are themselves skills.
|
||||
if path.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
collect_supporting_files_impl(&path, out, visited_dirs);
|
||||
} else if ft.is_file() && path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec<SkillEntry> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut skills = Vec::new();
|
||||
|
||||
for dir_suffix in SKILL_DIRS {
|
||||
scan_skill_dir(&cwd.join(dir_suffix), &mut seen, &mut skills);
|
||||
}
|
||||
|
||||
if let Some(home) = home {
|
||||
scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills);
|
||||
}
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn build_hints_section(cwd: &Path) -> (String, Vec<SkillEntry>) {
|
||||
build_hints_section_impl(cwd, home_dir().as_deref())
|
||||
}
|
||||
|
||||
fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec<SkillEntry>) {
|
||||
let hints_text = load_hint_files_impl(cwd, home);
|
||||
let skills = discover_skills_impl(cwd, home);
|
||||
|
||||
if hints_text.is_empty() && skills.is_empty() {
|
||||
return (String::new(), skills);
|
||||
}
|
||||
|
||||
let mut out = String::from("# Additional Instructions\n");
|
||||
|
||||
if !hints_text.is_empty() {
|
||||
out.push_str("\n## Project Hints\n");
|
||||
out.push_str(&hints_text);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
if !skills.is_empty() {
|
||||
out.push_str("\n## Available Skills\n");
|
||||
for skill in &skills {
|
||||
out.push_str(&format!("- {}: {}\n", skill.name, skill.description));
|
||||
}
|
||||
out.push_str(
|
||||
"\nUse the `load_skill` tool to read the full content of a skill before using it.\n",
|
||||
);
|
||||
}
|
||||
|
||||
(out, skills)
|
||||
}
|
||||
|
||||
/// Strip the YAML frontmatter block from a skill file's content and return
|
||||
/// the body. If no valid frontmatter is found, returns the content unchanged.
|
||||
pub(crate) fn strip_frontmatter(content: &str) -> &str {
|
||||
let Some(rest) = content.strip_prefix("---\n") else {
|
||||
return content;
|
||||
};
|
||||
let Some(close_pos) = rest.find("\n---") else {
|
||||
return content;
|
||||
};
|
||||
let after = &rest[close_pos + 4..]; // skip "\n---"
|
||||
after.strip_prefix('\n').unwrap_or(after)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn find_git_root_normal_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
assert_eq!(find_git_root(root), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_worktree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
// .git as a file (worktree)
|
||||
std::fs::write(root.join(".git"), "gitdir: ../main/.git/worktrees/wt").unwrap();
|
||||
assert_eq!(find_git_root(root), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_none() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// No .git anywhere under tmp
|
||||
let result = find_git_root(tmp.path());
|
||||
// In a CI environment the test itself may live inside a real git repo,
|
||||
// so only assert None when tmp is truly isolated (not a subpath of a git repo).
|
||||
// We verify by checking that any found root is NOT inside tmp.
|
||||
if let Some(found) = result {
|
||||
assert!(!found.starts_with(tmp.path()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_from_subdirectory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
let deep = root.join("sub").join("deep");
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
assert_eq!(find_git_root(&deep), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_single_at_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
// No .git → no git root discovery; only cwd is checked.
|
||||
std::fs::write(cwd.join("AGENTS.md"), "cwd hints").unwrap();
|
||||
let result = load_hint_files_impl(cwd, None);
|
||||
assert_eq!(result, "cwd hints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_git_root_and_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join("AGENTS.md"), "root hints").unwrap();
|
||||
let sub = root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("AGENTS.md"), "sub hints").unwrap();
|
||||
let result = load_hint_files_impl(&sub, None);
|
||||
// Root hints must come first.
|
||||
assert!(
|
||||
result.starts_with("root hints"),
|
||||
"expected root hints first, got: {result:?}"
|
||||
);
|
||||
assert!(result.contains("sub hints"), "missing sub hints");
|
||||
let root_pos = result.find("root hints").unwrap();
|
||||
let sub_pos = result.find("sub hints").unwrap();
|
||||
assert!(root_pos < sub_pos, "root hints should precede sub hints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_missing_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = load_hint_files_impl(tmp.path(), None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_finds_across_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
// Skill in .agents/skills/
|
||||
let agents_skill = cwd.join(".agents/skills/my-skill");
|
||||
std::fs::create_dir_all(&agents_skill).unwrap();
|
||||
std::fs::write(
|
||||
agents_skill.join("SKILL.md"),
|
||||
"---\nname: my-skill\ndescription: A skill\n---\nSkill body here.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Skill in .goose/skills/
|
||||
let goose_skill = cwd.join(".goose/skills/other-skill");
|
||||
std::fs::create_dir_all(&goose_skill).unwrap();
|
||||
std::fs::write(
|
||||
goose_skill.join("SKILL.md"),
|
||||
"---\nname: other-skill\ndescription: Another skill\n---\nOther body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert_eq!(skills.len(), 2);
|
||||
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(names.contains(&"my-skill"), "missing my-skill");
|
||||
assert!(names.contains(&"other-skill"), "missing other-skill");
|
||||
// Paths should point to the SKILL.md files.
|
||||
for skill in &skills {
|
||||
assert!(skill.path.exists(), "path does not exist: {:?}", skill.path);
|
||||
assert!(skill.path.ends_with("SKILL.md"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_dedup_by_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
// Same name in .agents/skills/ (first) and .goose/skills/ (second)
|
||||
let agents_skill = cwd.join(".agents/skills/shared");
|
||||
std::fs::create_dir_all(&agents_skill).unwrap();
|
||||
std::fs::write(
|
||||
agents_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from agents\n---\nAgents body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let goose_skill = cwd.join(".goose/skills/shared");
|
||||
std::fs::create_dir_all(&goose_skill).unwrap();
|
||||
std::fs::write(
|
||||
goose_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from goose\n---\nGoose body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert_eq!(skills.len(), 1, "duplicate name should be deduplicated");
|
||||
assert_eq!(
|
||||
skills[0].description, "from agents",
|
||||
"first wins (.agents/)"
|
||||
);
|
||||
// Path should point to the .agents/ version (first wins).
|
||||
assert!(skills[0]
|
||||
.path
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains(".agents/skills/shared"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_skips_missing_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
let skill_dir = cwd.join(".agents/skills/no-name");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: No name here\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert!(skills.is_empty(), "entry without name should be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hints_section_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (result, skills) = build_hints_section_impl(tmp.path(), None);
|
||||
assert_eq!(result, "");
|
||||
assert!(skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hints_section_combined() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
std::fs::write(cwd.join("AGENTS.md"), "Project-level hints.").unwrap();
|
||||
|
||||
let skill_dir = cwd.join(".agents/skills/buzz-cli");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: buzz-cli\ndescription: CLI reference for Buzz managed agents\n---\nUse `buzz` to manage agents.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (result, skills) = build_hints_section_impl(cwd, None);
|
||||
|
||||
assert!(
|
||||
result.contains("# Additional Instructions"),
|
||||
"missing header"
|
||||
);
|
||||
assert!(result.contains("## Project Hints"), "missing Project Hints");
|
||||
assert!(
|
||||
result.contains("Project-level hints."),
|
||||
"missing hints content"
|
||||
);
|
||||
assert!(
|
||||
result.contains("## Available Skills"),
|
||||
"missing Available Skills"
|
||||
);
|
||||
assert!(
|
||||
result.contains("buzz-cli: CLI reference for Buzz managed agents"),
|
||||
"missing skill bullet"
|
||||
);
|
||||
// Body must NOT be inlined — lazy loading only.
|
||||
assert!(
|
||||
!result.contains("Use `buzz` to manage agents."),
|
||||
"skill body must not be inlined in system prompt"
|
||||
);
|
||||
// The load_skill instruction must be present.
|
||||
assert!(
|
||||
result.contains("load_skill"),
|
||||
"missing load_skill instruction"
|
||||
);
|
||||
// The old ### heading format must not appear.
|
||||
assert!(
|
||||
!result.contains("### buzz-cli"),
|
||||
"skill body heading must not be inlined"
|
||||
);
|
||||
// The returned skills list should contain the discovered skill.
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "buzz-cli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_global_loaded_first() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
std::fs::write(home.path().join("AGENTS.md"), "global hints").unwrap();
|
||||
std::fs::write(cwd.path().join("AGENTS.md"), "local hints").unwrap();
|
||||
let result = load_hint_files_impl(cwd.path(), Some(home.path()));
|
||||
let global_pos = result.find("global hints").unwrap();
|
||||
let local_pos = result.find("local hints").unwrap();
|
||||
assert!(
|
||||
global_pos < local_pos,
|
||||
"global hints should precede local hints"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_home_missing_agents_md() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap();
|
||||
let result = load_hint_files_impl(cwd.path(), Some(home.path()));
|
||||
assert_eq!(result, "local only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_no_home_dir() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap();
|
||||
let result = load_hint_files_impl(cwd.path(), None);
|
||||
assert_eq!(result, "local only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_dedup_when_home_in_chain() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::write(home.join("AGENTS.md"), "single load").unwrap();
|
||||
let result = load_hint_files_impl(home, Some(home));
|
||||
assert_eq!(
|
||||
result.matches("single load").count(),
|
||||
1,
|
||||
"AGENTS.md should be loaded exactly once when CWD is home"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_dedup_when_home_is_git_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir(home.join(".git")).unwrap();
|
||||
std::fs::write(home.join("AGENTS.md"), "root+home hints").unwrap();
|
||||
let sub = home.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let result = load_hint_files_impl(&sub, Some(home));
|
||||
assert_eq!(
|
||||
result.matches("root+home hints").count(),
|
||||
1,
|
||||
"AGENTS.md should be loaded once when home is git root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_global_skills_loaded() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let skill_dir = home.path().join(".agents/skills/global-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = discover_skills_impl(cwd.path(), Some(home.path()));
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "global-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_project_wins_over_global() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let project_skill = cwd.path().join(".agents/skills/shared");
|
||||
std::fs::create_dir_all(&project_skill).unwrap();
|
||||
std::fs::write(
|
||||
project_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from project\n---\nProject body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_skill = home.path().join(".agents/skills/shared");
|
||||
std::fs::create_dir_all(&global_skill).unwrap();
|
||||
std::fs::write(
|
||||
global_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from global\n---\nGlobal body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd.path(), Some(home.path()));
|
||||
assert_eq!(skills.len(), 1, "duplicate name should be deduplicated");
|
||||
assert_eq!(
|
||||
skills[0].description, "from project",
|
||||
"project-level should win over global"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_no_home_dir() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let skill_dir = cwd.path().join(".agents/skills/local");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: local\ndescription: Local skill\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = discover_skills_impl(cwd.path(), None);
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_supporting_files_finds_non_skill_md_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
// SKILL.md should be excluded.
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
|
||||
// A references subdir with files.
|
||||
let refs = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs).unwrap();
|
||||
std::fs::write(refs.join("foo.md"), "foo").unwrap();
|
||||
std::fs::write(refs.join("bar.md"), "bar").unwrap();
|
||||
// A script at the top level.
|
||||
std::fs::write(skill_dir.join("setup.sh"), "#!/bin/sh").unwrap();
|
||||
|
||||
let files = collect_supporting_files(skill_dir);
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"foo.md".to_owned()),
|
||||
"missing foo.md: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"bar.md".to_owned()),
|
||||
"missing bar.md: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"setup.sh".to_owned()),
|
||||
"missing setup.sh: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"SKILL.md".to_owned()),
|
||||
"SKILL.md should be excluded: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_supporting_files_does_not_descend_into_nested_skills() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
|
||||
std::fs::write(skill_dir.join("helper.sh"), "#!/bin/sh").unwrap();
|
||||
|
||||
// A nested subdir that is itself a skill — should not be descended into.
|
||||
let nested = skill_dir.join("nested-skill");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("SKILL.md"), "---\nname: nested\n---\n").unwrap();
|
||||
std::fs::write(nested.join("secret.md"), "should not appear").unwrap();
|
||||
|
||||
let files = collect_supporting_files(skill_dir);
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"helper.sh".to_owned()),
|
||||
"missing helper.sh: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"secret.md".to_owned()),
|
||||
"nested skill's files should not appear: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn collect_supporting_files_skips_symlink_cycles() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "---\nname: x\n---\n").unwrap();
|
||||
|
||||
let refs = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs).unwrap();
|
||||
let guide = refs.join("guide.md");
|
||||
std::fs::write(&guide, "guide").unwrap();
|
||||
std::os::unix::fs::symlink(&refs, refs.join("loop")).unwrap();
|
||||
|
||||
let files = collect_supporting_files(skill_dir);
|
||||
assert_eq!(files, vec![guide]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_populates_supporting_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skill_dir = cwd.join(".agents/skills/with-refs");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: with-refs\ndescription: Has refs\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let refs = skill_dir.join("references");
|
||||
std::fs::create_dir_all(&refs).unwrap();
|
||||
std::fs::write(refs.join("guide.md"), "guide content").unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "with-refs");
|
||||
assert_eq!(skills[0].supporting_files.len(), 1);
|
||||
assert!(
|
||||
skills[0].supporting_files[0].ends_with("references/guide.md"),
|
||||
"unexpected path: {:?}",
|
||||
skills[0].supporting_files[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
#![forbid(unsafe_code)]
|
||||
mod agent;
|
||||
pub mod auth;
|
||||
mod builtin;
|
||||
pub mod catalog;
|
||||
pub mod config;
|
||||
mod handoff;
|
||||
mod hints;
|
||||
mod llm;
|
||||
mod mcp;
|
||||
pub mod types;
|
||||
mod wire;
|
||||
|
||||
pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS};
|
||||
pub use config::Provider;
|
||||
pub use types::AgentError;
|
||||
|
||||
/// Environment keys the Windows Git Bash resolver may inspect. `spawn_one()`
|
||||
/// forwards every key in this list into its otherwise-cleared MCP child; Doctor
|
||||
/// uses the same contract so a ready agent can always start its shell tool.
|
||||
#[cfg(windows)]
|
||||
pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[
|
||||
"PATH",
|
||||
"BUZZ_SHELL",
|
||||
"GIT_BASH",
|
||||
"SystemRoot",
|
||||
"ProgramFiles",
|
||||
"ProgramFiles(x86)",
|
||||
"LOCALAPPDATA",
|
||||
];
|
||||
|
||||
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, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION};
|
||||
use crate::hints::SkillEntry;
|
||||
use crate::llm::Llm;
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::types::{ContentBlock, HistoryItem};
|
||||
use crate::wire::{
|
||||
classify, goose_session_update, Inbound, InitializeParams, SessionCancelParams,
|
||||
SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireMsg,
|
||||
WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR,
|
||||
};
|
||||
|
||||
struct App {
|
||||
cfg: Config,
|
||||
llm: Arc<Llm>,
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
/// Cached model catalog for Databricks providers. Populated lazily on the
|
||||
/// first successful `session/new` discovery call. Failed discovery is never
|
||||
/// cached: static-token authentication errors reject session creation, while
|
||||
/// OAuth authentication and non-auth errors use the configured model for that
|
||||
/// response and retry on the next session.
|
||||
models_cache: tokio::sync::OnceCell<Vec<ModelEntry>>,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
id: String,
|
||||
mcp: Arc<McpRegistry>,
|
||||
/// Skills discovered at session creation; used by the built-in `load_skill` tool.
|
||||
skills: Vec<SkillEntry>,
|
||||
history: Vec<HistoryItem>,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
busy: bool,
|
||||
/// Run id of the in-flight prompt, set when a prompt starts and cleared
|
||||
/// when it ends. `None` means no active run — a steer request targeting
|
||||
/// this session is rejected. Steer-capable clients learn this value from
|
||||
/// the `params.update._meta.goose.activeRunId` field on `session/update`.
|
||||
active_run_id: Option<String>,
|
||||
/// Sender for mid-turn steer messages. Created fresh per prompt (like
|
||||
/// `cancel_tx`); the running prompt loop holds the matching receiver and
|
||||
/// drains queued steers at round boundaries. `None` when no prompt is in
|
||||
/// flight.
|
||||
steer_tx: Option<mpsc::UnboundedSender<Vec<ContentBlock>>>,
|
||||
original_task: Option<String>,
|
||||
handoff_count: usize,
|
||||
/// Cache-summed input tokens the provider reported for this session's most
|
||||
/// recent request, or `None` before the first response (or after a handoff
|
||||
/// resets the context). Drives the token-based handoff gate; see
|
||||
/// [`RunCtx::should_handoff`].
|
||||
last_request_input_tokens: Option<u64>,
|
||||
/// History byte size when `last_request_input_tokens` was measured, paired
|
||||
/// with it so the gate can account for history appended since.
|
||||
last_request_history_bytes: Option<usize>,
|
||||
effective_system_prompt: Arc<str>,
|
||||
/// Per-session model override set by `session/set_model`. When `Some`,
|
||||
/// overrides `App::cfg.model` for all LLM calls on this session. Persists
|
||||
/// across `session/prompt` calls until changed.
|
||||
effective_model: Option<String>,
|
||||
/// Session-cumulative input tokens across all turns. Sent in the
|
||||
/// `_goose/unstable/session/update` usage notification so buzz-acp's
|
||||
/// `UsageTracker` can compute per-turn deltas symmetrically with goose.
|
||||
accumulated_input_tokens: u64,
|
||||
/// Session-cumulative output tokens across all turns.
|
||||
accumulated_output_tokens: u64,
|
||||
/// Session-cumulative cache-served input tokens across all turns — a subset
|
||||
/// of `accumulated_input_tokens`, not an addition to it. Emitted alongside
|
||||
/// it so a consumer can price the cached slice at the provider's discounted
|
||||
/// rate instead of assuming every input token cost full price.
|
||||
accumulated_cached_input_tokens: u64,
|
||||
/// Session-cumulative total-token state across all turns.
|
||||
///
|
||||
/// Mirrors the per-turn `TurnTotalState` tri-state: starts `Unseen`,
|
||||
/// becomes `Exact(n)` as turns with genuine provider totals complete,
|
||||
/// transitions permanently to `Unknown` when any turn lacks a total or
|
||||
/// when the cumulative would otherwise decrease. Only emitted in the
|
||||
/// `usage_update` notification when `Exact`.
|
||||
accumulated_total_state: crate::types::TurnTotalState,
|
||||
}
|
||||
|
||||
fn die(msg: String) -> ! {
|
||||
tracing::error!("{msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if matches!(args.get(1).map(String::as_str), Some("auth")) {
|
||||
return tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(auth_subcommand(&args[2..]));
|
||||
}
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async_main());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn authenticate_databricks(host: &str) -> Result<(), AgentError> {
|
||||
auth::PkceOAuthTokenSource::new(llm::databricks_pkce_config(host))?
|
||||
.interactive_login()
|
||||
.await
|
||||
}
|
||||
|
||||
/// `buzz-agent auth <provider>` — run the interactive auth flow for a
|
||||
/// provider and persist the result, then exit. Today this supports Databricks
|
||||
/// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the
|
||||
/// machine.
|
||||
async fn auth_subcommand(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let provider = args.first().map(String::as_str);
|
||||
match provider {
|
||||
Some("databricks" | "databricks_v2" | "databricks-v2") => {
|
||||
let host = std::env::var("DATABRICKS_HOST")
|
||||
.map_err(|_| "auth databricks: DATABRICKS_HOST required")?;
|
||||
authenticate_databricks(&host).await?;
|
||||
eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/.");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("auth: unknown provider {other:?}").into()),
|
||||
None => Err("auth: provider required (try: buzz-agent auth databricks)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn async_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()),
|
||||
models_cache: tokio::sync::OnceCell::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/set_model" => {
|
||||
set_model_session(app, id, params, wire_tx).await;
|
||||
}
|
||||
"session/cancel" => {
|
||||
cancel_session(app, params).await;
|
||||
wire::send(wire_tx, wire::ok(id, Value::Null)).await;
|
||||
}
|
||||
// goose-compatible non-standard extension: inject user input into the
|
||||
// currently active prompt without starting a new one. Mirrors goose's
|
||||
// `_goose/unstable/session/steer` wire contract so a single client-side
|
||||
// delivery path serves both agents.
|
||||
"_goose/unstable/session/steer" => {
|
||||
steer_session(app, id, params, wire_tx).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,
|
||||
};
|
||||
// Honest negotiation: respond with the minimum of what the client
|
||||
// requested and what we support.
|
||||
// NOTE: gating `[Base]` injection on `protocol_version < 2` is a deliberate
|
||||
// temporary measure — we are squatting on ACP v2 ahead of the upstream ACP
|
||||
// RFD. Revisit when that RFD merges; otherwise a genuine upstream-v2 agent
|
||||
// would silently lose `[Base]`.
|
||||
let negotiated_version = p.protocol_version.min(PROTOCOL_VERSION);
|
||||
wire::send(
|
||||
wire_tx,
|
||||
wire::ok(
|
||||
id,
|
||||
json!({
|
||||
"protocolVersion": negotiated_version,
|
||||
"agentCapabilities": {
|
||||
"loadSession": false,
|
||||
"promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
|
||||
"mcpCapabilities": { "http": false, "sse": false },
|
||||
},
|
||||
"agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") },
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Resolve the Databricks model catalog for one `session/new` call.
|
||||
///
|
||||
/// Tries to use a previously-cached successful discovery result. If the cache is empty,
|
||||
/// runs `discover` and — on success — populates the cache for future calls. On failure
|
||||
/// the error is returned and the cell is intentionally left empty so the next session retries.
|
||||
///
|
||||
/// Extracted from `session_new` so that tests can drive this path with an injected
|
||||
/// discovery future without requiring a full `App` / transport stack.
|
||||
async fn resolve_models_catalog(
|
||||
cache: &tokio::sync::OnceCell<Vec<ModelEntry>>,
|
||||
discover: impl std::future::Future<Output = Result<Vec<ModelEntry>, AgentError>>,
|
||||
) -> Result<Vec<ModelEntry>, AgentError> {
|
||||
cache.get_or_try_init(|| discover).await.cloned()
|
||||
}
|
||||
|
||||
/// Return the configured model as a one-entry catalog for this response.
|
||||
///
|
||||
/// This value is never written to `models_cache`; failed discovery must be retried by
|
||||
/// the next session rather than pinning degraded state for the process lifetime.
|
||||
fn configured_model_fallback(model: &str) -> Vec<ModelEntry> {
|
||||
let model = model.trim().to_string();
|
||||
vec![ModelEntry {
|
||||
id: model.clone(),
|
||||
name: model,
|
||||
}]
|
||||
}
|
||||
|
||||
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 (hints_text, skills) = if app.cfg.hints_enabled {
|
||||
hints::build_hints_section(std::path::Path::new(&p.cwd))
|
||||
} else {
|
||||
(String::new(), Vec::new())
|
||||
};
|
||||
let effective_system_prompt: Arc<str> = {
|
||||
// When the harness provides a systemPrompt (base_prompt + persona), use
|
||||
// it as the primary content and suppress the default. The default is only
|
||||
// a fallback for legacy harnesses that don't send systemPrompt.
|
||||
let base = match p.system_prompt.as_deref() {
|
||||
Some(client_prompt) if !client_prompt.trim().is_empty() => client_prompt.to_owned(),
|
||||
_ => app.cfg.system_prompt.clone(),
|
||||
};
|
||||
let prompt = if hints_text.is_empty() {
|
||||
base
|
||||
} else {
|
||||
format!("{base}\n\n{hints_text}")
|
||||
};
|
||||
// Reject combined prompts exceeding 512KB.
|
||||
if prompt.len() > MAX_SYSTEM_PROMPT_BYTES {
|
||||
return reject(
|
||||
wire_tx,
|
||||
id,
|
||||
INVALID_PARAMS,
|
||||
&format!(
|
||||
"session/new: combined system prompt exceeds {}KB limit ({} bytes)",
|
||||
MAX_SYSTEM_PROMPT_BYTES / 1024,
|
||||
prompt.len()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Arc::from(prompt)
|
||||
};
|
||||
// Resolve the model catalog before spawning MCP servers or registering a
|
||||
// session. A configured static credential cannot recover interactively, so
|
||||
// its authentication failure rejects before allocation. OAuth authentication
|
||||
// failures and other catalog failures use only the configured model for this
|
||||
// response, without caching, so session/prompt can run the existing PKCE flow.
|
||||
let available_models: Vec<Value> = {
|
||||
use crate::config::Provider;
|
||||
match app.cfg.provider {
|
||||
Provider::Databricks | Provider::DatabricksV2 => {
|
||||
let models = match resolve_models_catalog(
|
||||
&app.models_cache,
|
||||
discover_databricks_models(&app.cfg),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(models) => models,
|
||||
Err(error @ AgentError::LlmAuth(_)) if !app.cfg.api_key.is_empty() => {
|
||||
return reject(wire_tx, id, error.json_rpc_code(), &error.to_string())
|
||||
.await;
|
||||
}
|
||||
Err(error @ AgentError::LlmAuth(_)) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
"Databricks OAuth model catalog unavailable; using configured model"
|
||||
);
|
||||
configured_model_fallback(&app.cfg.model)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
"Databricks model catalog unavailable; using configured model"
|
||||
);
|
||||
configured_model_fallback(&app.cfg.model)
|
||||
}
|
||||
};
|
||||
models
|
||||
.iter()
|
||||
.map(|m| json!({ "modelId": m.id, "name": m.name }))
|
||||
.collect()
|
||||
}
|
||||
_ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })],
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
skills,
|
||||
history: Vec::new(),
|
||||
cancel_tx,
|
||||
busy: false,
|
||||
active_run_id: None,
|
||||
steer_tx: None,
|
||||
original_task: None,
|
||||
handoff_count: 0,
|
||||
last_request_input_tokens: None,
|
||||
last_request_history_bytes: None,
|
||||
effective_system_prompt,
|
||||
effective_model: None,
|
||||
accumulated_input_tokens: 0,
|
||||
accumulated_output_tokens: 0,
|
||||
accumulated_cached_input_tokens: 0,
|
||||
accumulated_total_state: crate::types::TurnTotalState::Unseen,
|
||||
},
|
||||
);
|
||||
drop(sessions);
|
||||
|
||||
wire::send(
|
||||
wire_tx,
|
||||
wire::ok(
|
||||
id,
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"models": {
|
||||
"currentModelId": app.cfg.model,
|
||||
"availableModels": available_models,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `session/set_model`: apply a per-session model override immediately.
|
||||
///
|
||||
/// Validation:
|
||||
/// - Unknown `sessionId` → `invalid_params`.
|
||||
/// - Empty `modelId` → `invalid_params`.
|
||||
///
|
||||
/// On success: stores `model_id` on the session and responds `{ sessionId, modelId }`.
|
||||
/// The override is picked up by the next `session/prompt` call on this session.
|
||||
async fn set_model_session(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
|
||||
let p: SessionSetModelParams = match decode(params, "session/set_model") {
|
||||
Ok(p) => p,
|
||||
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
|
||||
};
|
||||
if p.model_id.trim().is_empty() {
|
||||
return reject(
|
||||
wire_tx,
|
||||
id,
|
||||
INVALID_PARAMS,
|
||||
"session/set_model: modelId must not be empty",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut sessions = app.sessions.lock().await;
|
||||
let Some(s) = sessions.get_mut(&p.session_id) else {
|
||||
return reject(
|
||||
wire_tx,
|
||||
id,
|
||||
INVALID_PARAMS,
|
||||
"session/set_model: unknown session",
|
||||
)
|
||||
.await;
|
||||
};
|
||||
s.effective_model = Some(p.model_id.clone());
|
||||
tracing::info!(
|
||||
session_id = %p.session_id,
|
||||
model_id = %p.model_id,
|
||||
"session/set_model: model overridden"
|
||||
);
|
||||
drop(sessions);
|
||||
wire::send(
|
||||
wire_tx,
|
||||
wire::ok(
|
||||
id,
|
||||
json!({ "sessionId": p.session_id, "modelId": p.model_id }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Handle `_goose/unstable/session/steer`: queue user input into the in-flight
|
||||
/// prompt. Validation mirrors goose's `on_steer_session`:
|
||||
/// - empty prompt → `invalid_params`
|
||||
/// - no active run (no prompt in flight) → `invalid_params`
|
||||
/// - `expectedRunId` mismatch → `invalid_params` (caller is steering a turn
|
||||
/// that already ended or rotated; it must fall back to cancel+merge)
|
||||
///
|
||||
/// On success the message is queued for pickup at the next round boundary and
|
||||
/// we reply `{ runId, messageId }`, then emit a `queuedSteer` session/update so
|
||||
/// the client can correlate the accepted steer with its eventual pickup.
|
||||
async fn steer_session(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
|
||||
let p: SessionSteerParams = match decode(params, "_goose/unstable/session/steer") {
|
||||
Ok(p) => p,
|
||||
Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await,
|
||||
};
|
||||
if p.prompt.is_empty() {
|
||||
return reject(
|
||||
wire_tx,
|
||||
id,
|
||||
INVALID_PARAMS,
|
||||
"steer: prompt must not be empty",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if p.expected_run_id.is_empty() {
|
||||
return reject(
|
||||
wire_tx,
|
||||
id,
|
||||
INVALID_PARAMS,
|
||||
"steer: expectedRunId must not be empty",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let message_id = format!("steer_{}", session_token().unwrap_or_else(|_| "x".into()));
|
||||
let run_id = {
|
||||
let sessions = app.sessions.lock().await;
|
||||
let Some(s) = sessions.get(&p.session_id) else {
|
||||
return reject(wire_tx, id, INVALID_PARAMS, "steer: unknown session").await;
|
||||
};
|
||||
let Some(active) = s.active_run_id.as_deref() else {
|
||||
return reject(wire_tx, id, INVALID_PARAMS, "steer: no active run to steer").await;
|
||||
};
|
||||
if active != p.expected_run_id {
|
||||
return reject(
|
||||
wire_tx,
|
||||
id,
|
||||
INVALID_PARAMS,
|
||||
&format!(
|
||||
"steer: expected active run id `{}` but found `{active}`",
|
||||
p.expected_run_id
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// A live run always has a steer_tx; if the channel is gone the run is
|
||||
// tearing down — treat as no active run rather than queue into the void.
|
||||
match &s.steer_tx {
|
||||
Some(tx) if tx.send(p.prompt).is_ok() => active.to_owned(),
|
||||
_ => return reject(wire_tx, id, INVALID_PARAMS, "steer: no active run to steer").await,
|
||||
}
|
||||
};
|
||||
wire::send(
|
||||
wire_tx,
|
||||
wire::ok(id, json!({ "runId": run_id, "messageId": message_id })),
|
||||
)
|
||||
.await;
|
||||
// Best-effort correlation hint for the client; mirrors goose's
|
||||
// `send_queued_steer_update`. Not load-bearing for delivery.
|
||||
wire::send(
|
||||
wire_tx,
|
||||
wire::session_update_with_goose_meta(
|
||||
&p.session_id,
|
||||
json!({ "sessionUpdate": "session_info_update" }),
|
||||
json!({ "queuedSteer": { "messageId": message_id, "runId": run_id } }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
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,
|
||||
skills,
|
||||
mut history,
|
||||
mut original_task,
|
||||
mut handoff_count,
|
||||
mut last_request_input_tokens,
|
||||
mut last_request_history_bytes,
|
||||
mut cancel_rx,
|
||||
effective_system_prompt,
|
||||
effective_model_override,
|
||||
run_id,
|
||||
mut steer_rx,
|
||||
usage_baseline,
|
||||
) = 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
|
||||
}
|
||||
};
|
||||
// Advertise the active run id so steer-capable clients can target this turn
|
||||
// via `expectedRunId`. Mirrors goose's `send_active_run_update`.
|
||||
wire::send(
|
||||
&wire_tx,
|
||||
wire::session_update_with_goose_meta(
|
||||
&sid,
|
||||
json!({ "sessionUpdate": "session_info_update" }),
|
||||
json!({ "activeRunId": run_id }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
// Resolve effective model: session override wins over config default.
|
||||
let effective_model_str = effective_model_override
|
||||
.as_deref()
|
||||
.unwrap_or(&app.cfg.model);
|
||||
let mut turn_input_tokens: Option<u64> = None;
|
||||
let mut turn_output_tokens: Option<u64> = None;
|
||||
let mut turn_cached_input_tokens: Option<u64> = None;
|
||||
let mut turn_total_state = crate::types::TurnTotalState::Unseen;
|
||||
let mut ctx = RunCtx {
|
||||
cfg: &app.cfg,
|
||||
effective_model: effective_model_str,
|
||||
session_id: &sid,
|
||||
system_prompt: &effective_system_prompt,
|
||||
llm: &app.llm,
|
||||
mcp: &mcp,
|
||||
skills: &skills,
|
||||
wire: &wire_tx,
|
||||
cancel: &mut cancel_rx,
|
||||
steer: &mut steer_rx,
|
||||
history: &mut history,
|
||||
original_task: &mut original_task,
|
||||
handoff_count: &mut handoff_count,
|
||||
run_id,
|
||||
last_request_input_tokens: &mut last_request_input_tokens,
|
||||
last_request_history_bytes: &mut last_request_history_bytes,
|
||||
turn_input_tokens: &mut turn_input_tokens,
|
||||
turn_output_tokens: &mut turn_output_tokens,
|
||||
turn_cached_input_tokens: &mut turn_cached_input_tokens,
|
||||
turn_total_state: &mut turn_total_state,
|
||||
usage_baseline,
|
||||
};
|
||||
let result = ctx.run(p.prompt).await;
|
||||
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
|
||||
s.busy = false;
|
||||
// Clear run state so a late steer can't queue into a finished turn.
|
||||
s.active_run_id = None;
|
||||
s.steer_tx = None;
|
||||
s.history = history;
|
||||
s.original_task = original_task;
|
||||
s.handoff_count = handoff_count;
|
||||
s.last_request_input_tokens = last_request_input_tokens;
|
||||
s.last_request_history_bytes = last_request_history_bytes;
|
||||
}
|
||||
// Update session-cumulative token counters and emit the usage notification
|
||||
// BEFORE sending the session/prompt response. buzz-acp's UsageTracker
|
||||
// processes the notification while the turn is still in-flight (i.e. before
|
||||
// the response triggers take_turn_usage()), which is required for the
|
||||
// begin_turn gate to recognise it as publishable.
|
||||
//
|
||||
// Only emit when at least one token count was observed — a turn with no
|
||||
// provider response (validation failure, pre-response cancellation) carries
|
||||
// no information and must not produce a kind 44200 record per NIP-AM.
|
||||
if turn_input_tokens.is_some() || turn_output_tokens.is_some() {
|
||||
let accumulated = {
|
||||
let mut sessions = app.sessions.lock().await;
|
||||
if let Some(s) = sessions.get_mut(&sid) {
|
||||
s.accumulated_input_tokens = s
|
||||
.accumulated_input_tokens
|
||||
.saturating_add(turn_input_tokens.unwrap_or(0));
|
||||
s.accumulated_output_tokens = s
|
||||
.accumulated_output_tokens
|
||||
.saturating_add(turn_output_tokens.unwrap_or(0));
|
||||
s.accumulated_cached_input_tokens = s
|
||||
.accumulated_cached_input_tokens
|
||||
.saturating_add(turn_cached_input_tokens.unwrap_or(0));
|
||||
// Fold the per-turn total state into the session cumulative.
|
||||
// Unknown poisons the session permanently; Exact adds to running sum;
|
||||
// Unseen (turn emitted no usage) leaves the cumulative unchanged.
|
||||
// Uses TurnTotalState::merge_session, which applies the same
|
||||
// checked-add / overflow-poisons contract as the per-response fold.
|
||||
s.accumulated_total_state =
|
||||
s.accumulated_total_state.merge_session(turn_total_state);
|
||||
Some((
|
||||
s.accumulated_input_tokens,
|
||||
s.accumulated_output_tokens,
|
||||
s.accumulated_cached_input_tokens,
|
||||
s.accumulated_total_state,
|
||||
))
|
||||
} else {
|
||||
// Session is gone — the accumulated baseline no longer exists, so
|
||||
// there is nothing correct to emit. Skip the usage notification.
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) =
|
||||
accumulated
|
||||
{
|
||||
// Same builder the run loop uses for its per-round reports, so the
|
||||
// final notification is shape-identical to the ones that preceded
|
||||
// it and a consumer taking the high-water mark lands on this one.
|
||||
let update = wire::usage_update_payload(
|
||||
accumulated_in,
|
||||
accumulated_out,
|
||||
accumulated_cached,
|
||||
accumulated_total,
|
||||
effective_model_str,
|
||||
);
|
||||
wire::send(&wire_tx, goose_session_update(&sid, update)).await;
|
||||
}
|
||||
}
|
||||
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<SkillEntry>,
|
||||
Vec<HistoryItem>,
|
||||
Option<String>,
|
||||
usize,
|
||||
Option<u64>,
|
||||
Option<usize>,
|
||||
watch::Receiver<bool>,
|
||||
Arc<str>,
|
||||
Option<String>,
|
||||
String,
|
||||
mpsc::UnboundedReceiver<Vec<ContentBlock>>,
|
||||
crate::types::SessionUsageBaseline,
|
||||
),
|
||||
&'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");
|
||||
}
|
||||
// Generate the run id before mutating session state. On RNG failure we reject
|
||||
// the prompt cleanly: the session stays idle and the caller can retry. Generating
|
||||
// after `s.busy = true` with `?` would wedge the session permanently busy.
|
||||
let run_id = format!(
|
||||
"run_{}",
|
||||
session_token().map_err(|_| "rng failure; retry prompt")?
|
||||
);
|
||||
s.busy = true;
|
||||
let (tx, rx) = watch::channel(false);
|
||||
s.cancel_tx = tx;
|
||||
// Skills are read-only after session creation; clone the Vec so RunCtx
|
||||
// can hold a reference without holding the sessions lock.
|
||||
let skills = s.skills.clone();
|
||||
// Fresh run id + steer channel for this turn. The run id lets steer-capable
|
||||
// clients target *this* turn (rejecting steers aimed at a turn that already
|
||||
// ended); the channel carries mid-turn injections to the run loop.
|
||||
s.active_run_id = Some(run_id.clone());
|
||||
let (steer_tx, steer_rx) = mpsc::unbounded_channel();
|
||||
s.steer_tx = Some(steer_tx);
|
||||
let effective_model = s.effective_model.clone();
|
||||
Ok((
|
||||
s.id.clone(),
|
||||
s.mcp.clone(),
|
||||
skills,
|
||||
std::mem::take(&mut s.history),
|
||||
s.original_task.take(),
|
||||
s.handoff_count,
|
||||
s.last_request_input_tokens,
|
||||
s.last_request_history_bytes,
|
||||
rx,
|
||||
Arc::clone(&s.effective_system_prompt),
|
||||
effective_model,
|
||||
run_id,
|
||||
steer_rx,
|
||||
// Snapshot rather than a handle: the run loop reports cumulative usage
|
||||
// after every LLM round, and taking the sessions lock on each of those
|
||||
// would serialise concurrent sessions behind one another's provider
|
||||
// round-trips. Nothing else advances these counters while this turn
|
||||
// holds `busy`, so the snapshot cannot go stale under it.
|
||||
crate::types::SessionUsageBaseline {
|
||||
input_tokens: s.accumulated_input_tokens,
|
||||
output_tokens: s.accumulated_output_tokens,
|
||||
cached_input_tokens: s.accumulated_cached_input_tokens,
|
||||
total_state: s.accumulated_total_state,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn session_token() -> Result<String, String> {
|
||||
let mut b = [0u8; 8];
|
||||
getrandom::fill(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?;
|
||||
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::catalog::ModelEntry;
|
||||
use crate::types::AgentError;
|
||||
|
||||
/// Regression: a discovery error must not pin the models_cache for the process lifetime.
|
||||
///
|
||||
/// `resolve_models_catalog` uses `get_or_try_init` so an `Err` leaves the `OnceCell`
|
||||
/// empty and the next `session/new` retries discovery. This test calls
|
||||
/// `resolve_models_catalog` directly — the same function `session_new` calls — so
|
||||
/// reverting `session_new` to `get_or_init` (or any other cache-on-error variant) would
|
||||
/// break this test, not just the standalone `OnceCell` semantics.
|
||||
#[tokio::test]
|
||||
async fn models_cache_does_not_pin_on_discovery_error() {
|
||||
let cache: tokio::sync::OnceCell<Vec<ModelEntry>> = tokio::sync::OnceCell::new();
|
||||
|
||||
// First call — discovery failure is surfaced and leaves the cell empty.
|
||||
let error = crate::resolve_models_catalog(&cache, async {
|
||||
Err::<Vec<ModelEntry>, AgentError>(AgentError::Llm("transient failure".into()))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AgentError::Llm(_)));
|
||||
|
||||
// Second call — discovery succeeds. Cell is now populated and returned.
|
||||
let discovered = vec![ModelEntry {
|
||||
id: "databricks-meta-llama-3-1-70b-instruct".into(),
|
||||
name: "databricks-meta-llama-3-1-70b-instruct".into(),
|
||||
}];
|
||||
let discovered_clone = discovered.clone();
|
||||
let second = crate::resolve_models_catalog(&cache, async move {
|
||||
Ok::<Vec<ModelEntry>, AgentError>(discovered_clone)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second, discovered,
|
||||
"second call must return the discovered catalog"
|
||||
);
|
||||
assert!(
|
||||
cache.get().is_some(),
|
||||
"cell must be populated after successful discovery"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.get().unwrap(),
|
||||
&discovered,
|
||||
"cache must hold the successful discovery result"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_catalog_does_not_cache_oauth_auth_fallback() {
|
||||
let cache: tokio::sync::OnceCell<Vec<ModelEntry>> = tokio::sync::OnceCell::new();
|
||||
let error = crate::resolve_models_catalog(&cache, async {
|
||||
Err::<Vec<ModelEntry>, AgentError>(AgentError::LlmAuth("sign in again".into()))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, AgentError::LlmAuth(_)));
|
||||
assert!(cache.get().is_none());
|
||||
|
||||
let discovered = vec![ModelEntry {
|
||||
id: "authenticated-model".into(),
|
||||
name: "authenticated-model".into(),
|
||||
}];
|
||||
let result = crate::resolve_models_catalog(&cache, async {
|
||||
Ok::<Vec<ModelEntry>, AgentError>(discovered.clone())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, discovered);
|
||||
assert_eq!(cache.get(), Some(&discovered));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_fallback_is_trimmed_and_singular() {
|
||||
assert_eq!(
|
||||
crate::configured_model_fallback(" configured-model "),
|
||||
vec![ModelEntry {
|
||||
id: "configured-model".into(),
|
||||
name: "configured-model".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
if let Err(e) = buzz_agent::run() {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,703 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Byte-equivalent charged to the handoff/context-pressure gate for a single
|
||||
/// image tool result. The gate maps bytes to tokens at 1 byte/token (see
|
||||
/// `handoff::CONSERVATIVE_BYTES_PER_TOKEN`), so this is also the per-image
|
||||
/// token budget. Providers bill an image as visual *tiles*, not its base64
|
||||
/// length: Anthropic caps at ~1600 tokens/image and OpenAI high-detail lands
|
||||
/// ~1.1K–1.5K. We charge 16 KiB — a generous ceiling that still over-counts
|
||||
/// the real ~2K cost, while being ~190× smaller than the base64 length of a
|
||||
/// typical multi-MiB screenshot. Charging `data.len()` to the gate instead
|
||||
/// made a single `view_image` (~3.1M base64 bytes) trip the handoff gate on a
|
||||
/// fresh context.
|
||||
const IMAGE_CONTEXT_TOKEN_EQUIV: usize = 16 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ToolResultContent {
|
||||
Text(String),
|
||||
Image { data: String, mime_type: String },
|
||||
}
|
||||
|
||||
impl ToolResultContent {
|
||||
/// Real serialized size in bytes. Used by `truncate_history` to keep the
|
||||
/// outgoing request body under `max_history_bytes` — an image rides the
|
||||
/// wire as its full base64 string, so that string's length is what counts
|
||||
/// here. For context-window/handoff pressure use
|
||||
/// [`Self::context_pressure_bytes`] instead, which charges an image its
|
||||
/// (far smaller) visual-token equivalent.
|
||||
pub fn estimated_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::Text(s) => s.len(),
|
||||
Self::Image { data, mime_type } => data.len() + mime_type.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Token-equivalent context-window pressure, in bytes (the handoff gate
|
||||
/// maps bytes→tokens at 1:1). Identical to [`Self::estimated_bytes`] for
|
||||
/// text, but an image is charged a flat [`IMAGE_CONTEXT_TOKEN_EQUIV`]
|
||||
/// budget rather than its base64 length — providers bill it as visual
|
||||
/// tiles (~2K tokens), so counting `data.len()` over-counts by ~1500× and
|
||||
/// forces a handoff on a single image.
|
||||
pub fn context_pressure_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::Text(s) => s.len(),
|
||||
Self::Image { data: _, mime_type } => IMAGE_CONTEXT_TOKEN_EQUIV + mime_type.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text_lossy(&self) -> String {
|
||||
match self {
|
||||
Self::Text(s) => s.clone(),
|
||||
Self::Image { data, mime_type } => {
|
||||
format!("[image: {mime_type}, {} base64 bytes]", data.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HistoryItem {
|
||||
User(String),
|
||||
Assistant {
|
||||
text: String,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
reasoning_details: Option<Value>,
|
||||
},
|
||||
ToolResult(ToolResult),
|
||||
}
|
||||
|
||||
impl HistoryItem {
|
||||
pub fn estimated_bytes(&self) -> usize {
|
||||
self.size_with(ToolResultContent::estimated_bytes)
|
||||
}
|
||||
|
||||
/// Token-equivalent context-window pressure, in bytes. Mirrors
|
||||
/// [`Self::estimated_bytes`] but charges image tool results their visual-
|
||||
/// token equivalent rather than their base64 length — see
|
||||
/// [`ToolResultContent::context_pressure_bytes`]. The handoff gate uses
|
||||
/// this; `truncate_history` (request-body sizing) uses `estimated_bytes`.
|
||||
pub fn context_pressure_bytes(&self) -> usize {
|
||||
self.size_with(ToolResultContent::context_pressure_bytes)
|
||||
}
|
||||
|
||||
fn size_with(&self, content_size: fn(&ToolResultContent) -> usize) -> usize {
|
||||
match self {
|
||||
Self::User(s) => s.len(),
|
||||
Self::Assistant {
|
||||
text,
|
||||
tool_calls,
|
||||
reasoning_details,
|
||||
} => {
|
||||
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)
|
||||
// `provider_extra` (e.g. a Gemini
|
||||
// `thoughtSignature`) is re-serialized into
|
||||
// every replayed call, so it counts toward the
|
||||
// request body and the context-pressure gate.
|
||||
+ serde_json::to_vec(&c.provider_extra)
|
||||
.map(|b| b.len())
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum::<usize>()
|
||||
+ reasoning_details
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_vec(v).ok())
|
||||
.map(|b| b.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
Self::ToolResult(r) => {
|
||||
r.provider_id.len() + r.content.iter().map(content_size).sum::<usize>()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCall {
|
||||
pub provider_id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
/// Fields the provider put on the tool call that we do not model, kept so
|
||||
/// the assistant turn can be replayed the way it arrived.
|
||||
///
|
||||
/// Gemini on the Databricks MLflow route returns a `thoughtSignature` per
|
||||
/// call and *requires* it echoed back: replaying without it fails the whole
|
||||
/// request with `Function call is missing a thought_signature in functionCall
|
||||
/// parts`. For an agent loop that lands on the very first tool call, so the
|
||||
/// model is unusable without this. Carrying whatever we did not model,
|
||||
/// rather than naming that one field, means the next provider with an opaque
|
||||
/// per-call token needs no change here.
|
||||
pub provider_extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResult {
|
||||
pub provider_id: String,
|
||||
pub content: Vec<ToolResultContent>,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn text(&self) -> String {
|
||||
self.content
|
||||
.iter()
|
||||
.map(ToolResultContent::as_text_lossy)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmResponse {
|
||||
pub text: String,
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
pub stop: ProviderStop,
|
||||
/// Total input tokens the provider reported for this request, or `None`
|
||||
/// if the response carried no usage. For Anthropic/Databricks this is the
|
||||
/// inclusive sum `input_tokens + cache_read_input_tokens +
|
||||
/// cache_creation_input_tokens` (plain `input_tokens` excludes cached
|
||||
/// tokens, so reading it alone would undercount). Used to gate handoff on
|
||||
/// the real token budget rather than a byte estimate.
|
||||
pub input_tokens: Option<u64>,
|
||||
/// The portion of `input_tokens` the provider served from its prompt cache,
|
||||
/// or `None` when the response reported no cache split. Providers bill this
|
||||
/// slice at a large discount (roughly 10x for both OpenAI and Anthropic),
|
||||
/// so a consumer that prices all of `input_tokens` at the full rate
|
||||
/// *overstates* cost — by a lot on an append-only agent loop, where most of
|
||||
/// each request is a prefix the provider already has.
|
||||
///
|
||||
/// This is a subset of `input_tokens`, never an addition to it: every
|
||||
/// provider we speak to reports an inclusive input total, so adding this
|
||||
/// would double-count.
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
/// Output tokens the provider reported for this request, or `None` if the
|
||||
/// response carried no usage. Used to accumulate per-turn output counts
|
||||
/// for NIP-AM metric publishing.
|
||||
pub output_tokens: Option<u64>,
|
||||
/// Provider-reported total tokens for this request, or `None` when the
|
||||
/// provider does not report a genuine total. Present for OpenAI-shaped
|
||||
/// responses (`usage.total_tokens`). Always `None` for Anthropic, which
|
||||
/// reports only category counts; NIP-AM forbids summing categories into a
|
||||
/// total. Callers must not derive this by summing `input_tokens +
|
||||
/// output_tokens` — that is what the UI display approximation is for.
|
||||
pub total_tokens: Option<u64>,
|
||||
/// Reasoning/thinking content emitted by the model before its answer, if
|
||||
/// any. Non-empty when the provider returns extended-thinking tokens:
|
||||
///
|
||||
/// - Responses API: concatenated `summary[].text` from `type == "reasoning"` output items.
|
||||
/// - Anthropic: concatenated `thinking` from `type == "thinking"` content blocks.
|
||||
/// - OpenAI chat/completions: not exposed; always empty.
|
||||
///
|
||||
/// Empty string when the provider returned no reasoning content.
|
||||
pub reasoning: String,
|
||||
/// Raw `reasoning_details` array from an OpenRouter response, if present.
|
||||
/// Replayed on subsequent turns so the model can continue its chain-of-thought.
|
||||
/// `None` for all non-OpenRouter providers.
|
||||
pub reasoning_details: Option<Value>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Tri-state accumulator for provider-reported total tokens within one ACP turn.
|
||||
///
|
||||
/// Tracks whether every usage-bearing LLM response in the turn supplied a genuine
|
||||
/// provider total. Used to accumulate a reliable per-turn total and contribute to
|
||||
/// the session-cumulative total.
|
||||
///
|
||||
/// - `Unseen`: no usage-bearing response observed yet (initial state for each turn).
|
||||
/// - `Exact(n)`: every response so far reported a total; `n` is their sum.
|
||||
/// - `Unknown`: at least one response lacked a total — permanently poisoned for
|
||||
/// this turn. The session-cumulative also transitions to Unknown when any turn
|
||||
/// lands Unknown, and stays there until a new session resets it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TurnTotalState {
|
||||
#[default]
|
||||
Unseen,
|
||||
Exact(u64),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TurnTotalState {
|
||||
/// Add two exact token counts with overflow protection.
|
||||
///
|
||||
/// Returns `Exact(acc + n)` on success or `Unknown` on overflow.
|
||||
/// This is the single implementation of the checked-add / overflow-poisons
|
||||
/// contract; both `fold()` and `merge_session()` call this helper so a
|
||||
/// change to overflow semantics needs to be made in exactly one place.
|
||||
fn checked_exact_sum(acc: u64, n: u64) -> TurnTotalState {
|
||||
match acc.checked_add(n) {
|
||||
Some(sum) => TurnTotalState::Exact(sum),
|
||||
None => TurnTotalState::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one provider-reported total into the current state.
|
||||
///
|
||||
/// `total`: `Some(n)` when the provider included a genuine total on this
|
||||
/// response; `None` when it was absent (e.g. Anthropic, or an OpenAI
|
||||
/// response that omits usage). Absence of a total on any usage-bearing
|
||||
/// response poisons the whole turn.
|
||||
///
|
||||
/// Overflow is handled by `checked_exact_sum`: a saturated value would
|
||||
/// not be a genuine provider-reported total, so overflow → `Unknown`.
|
||||
pub fn fold(self, total: Option<u64>) -> TurnTotalState {
|
||||
match (self, total) {
|
||||
// Already poisoned — stays Unknown regardless.
|
||||
(TurnTotalState::Unknown, _) => TurnTotalState::Unknown,
|
||||
// No total from this response — poison the accumulator.
|
||||
(_, None) => TurnTotalState::Unknown,
|
||||
// First response with a total.
|
||||
(TurnTotalState::Unseen, Some(n)) => TurnTotalState::Exact(n),
|
||||
// Subsequent response — delegate to the shared checked-sum helper.
|
||||
(TurnTotalState::Exact(acc), Some(n)) => Self::checked_exact_sum(acc, n),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge a completed turn's total state into the session-cumulative state.
|
||||
///
|
||||
/// This is the turn→session boundary accumulation:
|
||||
/// - An `Unseen` turn (no usage-bearing responses) leaves the cumulative unchanged.
|
||||
/// - Any `Unknown` side poisons the session permanently.
|
||||
/// - Two `Exact` values are summed via `checked_exact_sum`; overflow → `Unknown`.
|
||||
///
|
||||
/// The checked-add logic lives in `checked_exact_sum`; both this function and
|
||||
/// `fold()` call that helper so overflow semantics are defined once.
|
||||
pub fn merge_session(self, turn: TurnTotalState) -> TurnTotalState {
|
||||
match (self, turn) {
|
||||
// Either side poisoned → session is poisoned.
|
||||
(TurnTotalState::Unknown, _) | (_, TurnTotalState::Unknown) => TurnTotalState::Unknown,
|
||||
// Turn had no usage-bearing responses → no change to cumulative.
|
||||
(acc, TurnTotalState::Unseen) => acc,
|
||||
// First exact turn — adopt its value.
|
||||
(TurnTotalState::Unseen, TurnTotalState::Exact(n)) => TurnTotalState::Exact(n),
|
||||
// Add to running exact sum — delegate to the shared checked-sum helper.
|
||||
(TurnTotalState::Exact(acc), TurnTotalState::Exact(n)) => {
|
||||
Self::checked_exact_sum(acc, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the exact value if present; `None` for `Unseen` or `Unknown`.
|
||||
pub fn exact_value(self) -> Option<u64> {
|
||||
match self {
|
||||
TurnTotalState::Exact(n) => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The session-cumulative usage counters as of the START of a turn.
|
||||
///
|
||||
/// Copied out of the session under the lock when a turn begins and handed to
|
||||
/// `RunCtx` by value, so the run loop can emit a cumulative `usage_update`
|
||||
/// after every LLM round without reaching back into `App.sessions` (which it
|
||||
/// holds no handle to, and which is locked by the turn's own bookkeeping at
|
||||
/// both ends).
|
||||
///
|
||||
/// This exists so that usage is durable *during* a turn rather than only after
|
||||
/// it. The counters a turn accrues live in the prompt task's stack frame until
|
||||
/// the turn returns; a process killed mid-turn takes them with it and the
|
||||
/// tokens are billed by the provider but recorded nowhere. That is not
|
||||
/// hypothetical — it silently under-reported a long-horizon benchmark's cost by
|
||||
/// several-fold, because every phase of a `continue_until_timeout` run is
|
||||
/// terminated mid-turn by design.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SessionUsageBaseline {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
/// The cache-served subset of `input_tokens`, not an addition to it.
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_state: TurnTotalState,
|
||||
}
|
||||
|
||||
#[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),
|
||||
LlmModelNotFound(String),
|
||||
/// The provider rejected the request because the input exceeded the
|
||||
/// model's context window (an HTTP 400 whose body names a context-length
|
||||
/// overflow). Typed rather than folded into [`Self::Llm`] because the
|
||||
/// agent loop treats it as a *recovery* signal, not a terminal error: it
|
||||
/// is the only ground-truth indication that history must shrink, needing
|
||||
/// no window estimate that could itself be miscalibrated.
|
||||
///
|
||||
/// Classified where the HTTP status and body are still separate values, so
|
||||
/// the loop never has to sniff a formatted string — by the time an error
|
||||
/// leaves `Llm::complete` it has already been decorated with the model
|
||||
/// name.
|
||||
LlmContextExceeded(String),
|
||||
/// The provider explicitly rejected image content for the selected model.
|
||||
/// Kept distinct so the agent loop can remove the unsupported image from
|
||||
/// replayed history and give the model a recoverable tool error.
|
||||
UnsupportedImageInput(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::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"),
|
||||
Self::LlmContextExceeded(s) => write!(f, "llm context exceeded: {s}"),
|
||||
Self::UnsupportedImageInput(s) => write!(f, "llm image input unsupported: {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,
|
||||
Self::LlmAuth(_) => -32001,
|
||||
Self::LlmModelNotFound(_) => -32002,
|
||||
_ => -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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn image_item(base64_len: usize) -> HistoryItem {
|
||||
HistoryItem::ToolResult(ToolResult {
|
||||
provider_id: "call_1".into(),
|
||||
content: vec![ToolResultContent::Image {
|
||||
data: "A".repeat(base64_len),
|
||||
mime_type: "image/png".into(),
|
||||
}],
|
||||
is_error: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_estimated_bytes_is_real_wire_size() {
|
||||
// `truncate_history` relies on this to keep the request body under
|
||||
// `max_history_bytes`, so an image must report its full base64 length.
|
||||
let img = ToolResultContent::Image {
|
||||
data: "A".repeat(3_000_000),
|
||||
mime_type: "image/png".into(),
|
||||
};
|
||||
assert_eq!(img.estimated_bytes(), 3_000_000 + "image/png".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_context_pressure_is_token_equivalent_not_base64_len() {
|
||||
// The handoff gate must charge an image its visual-token equivalent,
|
||||
// not its base64 length — otherwise one screenshot trips the gate.
|
||||
let img = ToolResultContent::Image {
|
||||
data: "A".repeat(3_000_000),
|
||||
mime_type: "image/png".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
img.context_pressure_bytes(),
|
||||
IMAGE_CONTEXT_TOKEN_EQUIV + "image/png".len()
|
||||
);
|
||||
// And it must be independent of the (huge) base64 payload length.
|
||||
let bigger = ToolResultContent::Image {
|
||||
data: "A".repeat(10_000_000),
|
||||
mime_type: "image/png".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
img.context_pressure_bytes(),
|
||||
bigger.context_pressure_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_image_does_not_trip_default_handoff_threshold() {
|
||||
// Regression: a single ~3.1M-base64-byte `view_image` result on an
|
||||
// otherwise-empty history must NOT exceed the default pre-usage
|
||||
// handoff cap. The gate's byte-fallback threshold with the shipped
|
||||
// defaults (max_context_tokens=200_000, max_output_tokens=32_768) is
|
||||
// min(200_000*9/10, 200_000-32_768) = 167_232 "bytes". Before the fix
|
||||
// this item counted ~3.1M and tripped instantly.
|
||||
let item = image_item(3_118_884);
|
||||
const DEFAULT_PRE_USAGE_THRESHOLD: usize = 167_232;
|
||||
assert!(
|
||||
item.context_pressure_bytes() <= DEFAULT_PRE_USAGE_THRESHOLD,
|
||||
"one image charged {} bytes of context pressure, over the {} threshold",
|
||||
item.context_pressure_bytes(),
|
||||
DEFAULT_PRE_USAGE_THRESHOLD
|
||||
);
|
||||
// The real wire size, by contrast, is still the full base64 payload.
|
||||
assert!(item.estimated_bytes() >= 3_118_884);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_size_counts_provider_extra() {
|
||||
// A Gemini `thoughtSignature` rides the wire on every replayed call, so
|
||||
// both size measures must see it — otherwise `truncate_history` and the
|
||||
// handoff gate under-count and let the real request exceed the budget.
|
||||
let mut extra = Map::new();
|
||||
extra.insert("thoughtSignature".into(), Value::String("S".repeat(500)));
|
||||
let with_extra = HistoryItem::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![ToolCall {
|
||||
provider_id: "id".into(),
|
||||
name: "t".into(),
|
||||
arguments: Value::Null,
|
||||
provider_extra: extra,
|
||||
}],
|
||||
reasoning_details: None,
|
||||
};
|
||||
let without_extra = HistoryItem::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![ToolCall {
|
||||
provider_id: "id".into(),
|
||||
name: "t".into(),
|
||||
arguments: Value::Null,
|
||||
provider_extra: Map::new(),
|
||||
}],
|
||||
reasoning_details: None,
|
||||
};
|
||||
assert!(with_extra.estimated_bytes() > without_extra.estimated_bytes() + 500);
|
||||
assert_eq!(
|
||||
with_extra.estimated_bytes(),
|
||||
with_extra.context_pressure_bytes(),
|
||||
"provider_extra is text, so both measures must agree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_content_size_is_identical_for_both_measures() {
|
||||
// Only images diverge; text must size the same under both paths.
|
||||
let text = ToolResultContent::Text("hello world".into());
|
||||
assert_eq!(text.estimated_bytes(), text.context_pressure_bytes());
|
||||
let item = HistoryItem::User("a user message".into());
|
||||
assert_eq!(item.estimated_bytes(), item.context_pressure_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod turn_total_state_tests {
|
||||
use super::TurnTotalState;
|
||||
|
||||
// ── TurnTotalState::fold ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fold_first_response_with_total_becomes_exact() {
|
||||
let state = TurnTotalState::Unseen;
|
||||
assert_eq!(state.fold(Some(100)), TurnTotalState::Exact(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_first_response_without_total_becomes_unknown() {
|
||||
// Missing total on any usage-bearing response poisons the turn.
|
||||
let state = TurnTotalState::Unseen;
|
||||
assert_eq!(state.fold(None), TurnTotalState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_provider_rounds_all_with_totals_sum_correctly() {
|
||||
// Multiple rounds all reporting a genuine total → Exact with their sum.
|
||||
let state = TurnTotalState::Unseen;
|
||||
let state = state.fold(Some(100));
|
||||
let state = state.fold(Some(50));
|
||||
let state = state.fold(Some(75));
|
||||
assert_eq!(state, TurnTotalState::Exact(225));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_present_and_missing_totals_within_one_turn_poisons_accumulator() {
|
||||
// First round has a total, second does not → Unknown (permanently poisoned).
|
||||
let state = TurnTotalState::Unseen;
|
||||
let state = state.fold(Some(100)); // Exact(100)
|
||||
let state = state.fold(None); // Missing → Unknown
|
||||
assert_eq!(state, TurnTotalState::Unknown);
|
||||
// Further rounds with totals don't un-poison.
|
||||
let state = state.fold(Some(50));
|
||||
assert_eq!(state, TurnTotalState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stays_unknown_regardless_of_subsequent_totals() {
|
||||
// Once poisoned, no subsequent total can recover the state.
|
||||
let state = TurnTotalState::Unknown;
|
||||
assert_eq!(state.fold(Some(999)), TurnTotalState::Unknown);
|
||||
assert_eq!(state.fold(None), TurnTotalState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_value_returns_some_only_for_exact_variant() {
|
||||
assert_eq!(TurnTotalState::Unseen.exact_value(), None);
|
||||
assert_eq!(TurnTotalState::Unknown.exact_value(), None);
|
||||
assert_eq!(TurnTotalState::Exact(42).exact_value(), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_unseen() {
|
||||
let state: TurnTotalState = Default::default();
|
||||
assert_eq!(state, TurnTotalState::Unseen);
|
||||
}
|
||||
|
||||
// ── overflow: fold ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fold_overflow_poisons_turn_not_saturates() {
|
||||
// u64::MAX + 1 would saturate; checked_add must poison instead.
|
||||
let state = TurnTotalState::Exact(u64::MAX);
|
||||
assert_eq!(
|
||||
state.fold(Some(1)),
|
||||
TurnTotalState::Unknown,
|
||||
"overflow in fold() must produce Unknown, not Exact(u64::MAX)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── TurnTotalState::merge_session ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_session_unseen_turn_leaves_cumulative_unchanged() {
|
||||
// An Unseen turn (no usage-bearing responses) must not alter the cumulative.
|
||||
assert_eq!(
|
||||
TurnTotalState::Exact(100).merge_session(TurnTotalState::Unseen),
|
||||
TurnTotalState::Exact(100),
|
||||
);
|
||||
assert_eq!(
|
||||
TurnTotalState::Unseen.merge_session(TurnTotalState::Unseen),
|
||||
TurnTotalState::Unseen,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_session_exact_turn_adds_to_exact_cumulative() {
|
||||
assert_eq!(
|
||||
TurnTotalState::Exact(100).merge_session(TurnTotalState::Exact(50)),
|
||||
TurnTotalState::Exact(150),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_session_first_exact_turn_from_unseen_adopts_value() {
|
||||
assert_eq!(
|
||||
TurnTotalState::Unseen.merge_session(TurnTotalState::Exact(200)),
|
||||
TurnTotalState::Exact(200),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_session_unknown_turn_poisons_cumulative_permanently() {
|
||||
assert_eq!(
|
||||
TurnTotalState::Exact(100).merge_session(TurnTotalState::Unknown),
|
||||
TurnTotalState::Unknown,
|
||||
);
|
||||
// Poisoned session stays poisoned even with Unseen turn.
|
||||
assert_eq!(
|
||||
TurnTotalState::Unknown.merge_session(TurnTotalState::Unseen),
|
||||
TurnTotalState::Unknown,
|
||||
);
|
||||
// Poisoned session stays poisoned even with another Exact turn.
|
||||
assert_eq!(
|
||||
TurnTotalState::Unknown.merge_session(TurnTotalState::Exact(999)),
|
||||
TurnTotalState::Unknown,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_session_overflow_poisons_not_saturates() {
|
||||
// Overflow at the session boundary must also produce Unknown.
|
||||
assert_eq!(
|
||||
TurnTotalState::Exact(u64::MAX).merge_session(TurnTotalState::Exact(1)),
|
||||
TurnTotalState::Unknown,
|
||||
"overflow in merge_session() must produce Unknown, not Exact(u64::MAX)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
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>,
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Params for goose's non-standard `_goose/unstable/session/steer` request:
|
||||
/// inject user input into the *currently active* prompt without starting a new
|
||||
/// one. `expected_run_id` must match the run id buzz-agent advertised via
|
||||
/// `params.update._meta.goose.activeRunId` on a `session/update`, so a steer
|
||||
/// can't race a turn that already ended or hasn't started.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionSteerParams {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub prompt: Vec<ContentBlock>,
|
||||
pub expected_run_id: String,
|
||||
}
|
||||
|
||||
/// Params for `session/set_model`: override the active model for an existing
|
||||
/// session without respawning. Applied immediately; subsequent prompts on this
|
||||
/// session use `model_id` instead of the configured `BUZZ_AGENT_MODEL`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionSetModelParams {
|
||||
pub session_id: String,
|
||||
pub model_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 — buzz-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 },
|
||||
})
|
||||
}
|
||||
|
||||
/// A `_goose/unstable/session/update` notification — the separate top-level
|
||||
/// method goose uses for custom usage and status events. Used by buzz-agent
|
||||
/// to emit the `usage_update` payload so buzz-acp's `UsageTracker` can treat
|
||||
/// buzz-agent and goose symmetrically.
|
||||
pub fn goose_session_update(sid: &str, update: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "_goose/unstable/session/update",
|
||||
"params": { "sessionId": sid, "update": update },
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the `usage_update` payload for a `_goose/unstable/session/update`.
|
||||
///
|
||||
/// Shared by the two places that report usage — after each LLM round inside a
|
||||
/// turn, and once more when the turn completes — so the wire shape cannot drift
|
||||
/// between them. A consumer takes the high-water mark per session, so the
|
||||
/// mid-turn payloads are supersets of each other and the final one wins; a
|
||||
/// divergence in field names or units between the two call sites would instead
|
||||
/// show up as tokens silently vanishing, which is the failure this reporting
|
||||
/// exists to prevent.
|
||||
///
|
||||
/// All counts are SESSION-cumulative, matching goose, so buzz-acp's
|
||||
/// `UsageTracker` can compute per-turn deltas symmetrically for both agents.
|
||||
pub fn usage_update_payload(
|
||||
accumulated_input_tokens: u64,
|
||||
accumulated_output_tokens: u64,
|
||||
accumulated_cached_input_tokens: u64,
|
||||
accumulated_total: crate::types::TurnTotalState,
|
||||
model: &str,
|
||||
) -> Value {
|
||||
let mut update = json!({
|
||||
"sessionUpdate": "usage_update",
|
||||
// used: total tokens as a context-usage proxy;
|
||||
// contextLimit: 0 (buzz-agent has no context limit tracking).
|
||||
"used": accumulated_input_tokens.saturating_add(accumulated_output_tokens),
|
||||
"contextLimit": 0u64,
|
||||
"accumulatedInputTokens": accumulated_input_tokens,
|
||||
"accumulatedOutputTokens": accumulated_output_tokens,
|
||||
// A subset of accumulatedInputTokens, not an addition to it. Extends
|
||||
// goose's usage_update shape; a consumer that does not know the field
|
||||
// ignores it and prices exactly as it did before.
|
||||
"accumulatedCachedInputTokens": accumulated_cached_input_tokens,
|
||||
"model": model,
|
||||
});
|
||||
// Only when the cumulative is exactly known — never when Unseen (no total
|
||||
// ever observed) or Unknown (at least one turn lacked a total). A goose
|
||||
// consumer that doesn't recognise the field ignores it.
|
||||
if let Some(total) = accumulated_total.exact_value() {
|
||||
update["accumulatedTotalTokens"] = json!(total);
|
||||
}
|
||||
update
|
||||
}
|
||||
|
||||
/// A `session/update` notification carrying a `update._meta.goose.<key>` field.
|
||||
/// Used to advertise `activeRunId` (so steer-capable clients can target the
|
||||
/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer
|
||||
/// with the chunk that later picks it up) — matching goose's wire layout where
|
||||
/// `_meta` is nested inside the `update` object (per the ACP `SessionInfoUpdate`
|
||||
/// schema), not alongside it at the params level.
|
||||
pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Value) -> Value {
|
||||
let mut update = update;
|
||||
update["_meta"] = json!({ "goose": goose_meta });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_new_params_deserializes_system_prompt() {
|
||||
let json = serde_json::json!({
|
||||
"cwd": "/tmp/test",
|
||||
"mcpServers": [],
|
||||
"systemPrompt": "You are a helpful agent."
|
||||
});
|
||||
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(params.cwd, "/tmp/test");
|
||||
assert_eq!(
|
||||
params.system_prompt.as_deref(),
|
||||
Some("You are a helpful agent.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_params_system_prompt_defaults_to_none() {
|
||||
let json = serde_json::json!({
|
||||
"cwd": "/tmp/test",
|
||||
"mcpServers": []
|
||||
});
|
||||
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(params.cwd, "/tmp/test");
|
||||
assert!(params.system_prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_params_ignores_unknown_fields() {
|
||||
// Backward compat: old agents with new harness — unknown fields are ignored.
|
||||
let json = serde_json::json!({
|
||||
"cwd": "/tmp/test",
|
||||
"mcpServers": [],
|
||||
"unknownField": "should be ignored"
|
||||
});
|
||||
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(params.cwd, "/tmp/test");
|
||||
assert!(params.system_prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_params_empty_string_system_prompt() {
|
||||
// An explicit empty string is distinct from absent — deserializes to Some("").
|
||||
let json = serde_json::json!({
|
||||
"cwd": "/tmp/test",
|
||||
"mcpServers": [],
|
||||
"systemPrompt": ""
|
||||
});
|
||||
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(params.system_prompt, Some(String::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! 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_RESULT_SIZE=N — `tools/call` returns an N-byte text result
|
||||
//! (default: the literal "ok"); grows history
|
||||
//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block
|
||||
//! 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: "")
|
||||
//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell`
|
||||
//! (registered as `<server>__shell`), taking a
|
||||
//! `command` string. Lets a test drive the
|
||||
//! reply guard's recognition of a real,
|
||||
//! registered shell tool.
|
||||
|
||||
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,
|
||||
include_shell_tool: 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": {} },
|
||||
}));
|
||||
}
|
||||
if include_shell_tool {
|
||||
tools.push(json!({
|
||||
"name": "shell",
|
||||
"description": "run a shell command",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "command": { "type": "string" } },
|
||||
"required": ["command"],
|
||||
},
|
||||
}));
|
||||
}
|
||||
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);
|
||||
// Tool-call result text size in bytes (default: the literal "ok"). Lets a
|
||||
// test grow session history by a controlled amount via a tool result.
|
||||
let result_size = env_u64("FAKE_MCP_RESULT_SIZE", 0) as usize;
|
||||
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 shell_tool = env_flag("FAKE_MCP_SHELL_TOOL");
|
||||
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,
|
||||
shell_tool,
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
"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));
|
||||
}
|
||||
let result_text = if result_size > 0 {
|
||||
"x".repeat(result_size)
|
||||
} else {
|
||||
"ok".to_owned()
|
||||
};
|
||||
let content = if env_flag("FAKE_MCP_IMAGE_RESULT") {
|
||||
json!([
|
||||
{ "type": "text", "text": result_text },
|
||||
{ "type": "image", "data": "aW1n", "mimeType": "image/png" },
|
||||
])
|
||||
} else {
|
||||
json!([{ "type": "text", "text": result_text }])
|
||||
};
|
||||
write_response(
|
||||
id,
|
||||
json!({
|
||||
"content": content,
|
||||
"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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,944 @@
|
||||
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_buzz-agent");
|
||||
let mut cmd = tokio::process::Command::new(bin);
|
||||
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
||||
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_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 buzz-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": 2, "clientCapabilities": {} }),
|
||||
)
|
||||
.await;
|
||||
let init = h.recv_for_id(init_id).await;
|
||||
assert_eq!(init["result"]["protocolVersion"], 2);
|
||||
assert_eq!(init["result"]["agentInfo"]["name"], "buzz-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"], 2);
|
||||
|
||||
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_buzz-agent");
|
||||
let mut cmd = tokio::process::Command::new(bin);
|
||||
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
||||
.env("OPENAI_COMPAT_BASE_URL", &url)
|
||||
.env("BUZZ_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");
|
||||
}
|
||||
|
||||
/// Build an Anthropic Messages API response with an optional `thinking` block
|
||||
/// followed by a `text` block. The `thinking` field is omitted when `None`.
|
||||
fn anthropic_thinking_response(thinking: Option<&str>, text: &str) -> Value {
|
||||
let mut content: Vec<Value> = Vec::new();
|
||||
if let Some(t) = thinking {
|
||||
content.push(json!({ "type": "thinking", "thinking": t }));
|
||||
}
|
||||
content.push(json!({ "type": "text", "text": text }));
|
||||
json!({
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-fake",
|
||||
"stop_reason": "end_turn",
|
||||
"content": content,
|
||||
"usage": { "input_tokens": 10, "output_tokens": 5 },
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an OpenAI Responses API response with a `reasoning` output item
|
||||
/// (containing a single `summary_text` entry) followed by a message item.
|
||||
fn responses_reasoning_response(reasoning: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"id": "resp_1",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [{ "type": "summary_text", "text": reasoning }],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"content": [{ "type": "output_text", "text": text }],
|
||||
},
|
||||
],
|
||||
"usage": { "input_tokens": 10 },
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain all `session/update` notifications until the `session/prompt` reply
|
||||
/// arrives for `prompt_id`, collecting notification payloads in order.
|
||||
async fn collect_updates_until_done(h: &mut Harness, prompt_id: i64) -> Vec<Value> {
|
||||
let mut updates = Vec::new();
|
||||
loop {
|
||||
let v = h.recv().await;
|
||||
if v.get("id") == Some(&json!(prompt_id)) {
|
||||
return updates;
|
||||
}
|
||||
if v.get("method") == Some(&json!("session/update")) {
|
||||
if let Some(u) = v["params"].get("update") {
|
||||
updates.push(u.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that `agent_thought_chunk` appears in `updates` BEFORE
|
||||
/// `agent_message_chunk`, and that both are present.
|
||||
fn assert_thought_before_message(updates: &[Value]) {
|
||||
let thought_pos = updates
|
||||
.iter()
|
||||
.position(|u| u["sessionUpdate"] == "agent_thought_chunk");
|
||||
let message_pos = updates
|
||||
.iter()
|
||||
.position(|u| u["sessionUpdate"] == "agent_message_chunk");
|
||||
assert!(
|
||||
thought_pos.is_some(),
|
||||
"expected agent_thought_chunk in updates: {updates:?}"
|
||||
);
|
||||
assert!(
|
||||
message_pos.is_some(),
|
||||
"expected agent_message_chunk in updates: {updates:?}"
|
||||
);
|
||||
assert!(
|
||||
thought_pos.unwrap() < message_pos.unwrap(),
|
||||
"agent_thought_chunk must precede agent_message_chunk, got updates: {updates:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_thought_chunk_emitted_before_message_chunk_anthropic() {
|
||||
// Anthropic extended-thinking: the response contains a `thinking` block
|
||||
// followed by a `text` block. We expect agent_thought_chunk to be emitted
|
||||
// before agent_message_chunk on the wire.
|
||||
let url = spawn_fake_llm(vec![anthropic_thinking_response(
|
||||
Some("Let me reason about this carefully."),
|
||||
"Here is my answer.",
|
||||
)])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[
|
||||
("BUZZ_AGENT_PROVIDER", "anthropic"),
|
||||
("ANTHROPIC_API_KEY", "test"),
|
||||
("ANTHROPIC_MODEL", "claude-fake"),
|
||||
("ANTHROPIC_BASE_URL", &url),
|
||||
("OPENAI_COMPAT_BASE_URL", ""),
|
||||
])
|
||||
.await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "think hard" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
assert_thought_before_message(&updates);
|
||||
|
||||
let thought = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
thought["content"]["text"],
|
||||
"Let me reason about this carefully."
|
||||
);
|
||||
|
||||
let message = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(message["content"]["text"], "Here is my answer.");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_thought_chunk_emitted_before_message_chunk_responses_api() {
|
||||
// OpenAI Responses API: reasoning item followed by message item.
|
||||
// Setting OPENAI_COMPAT_API=responses forces the Responses API parse path.
|
||||
let url = spawn_fake_llm(vec![responses_reasoning_response(
|
||||
"Thinking step by step.",
|
||||
"Final answer.",
|
||||
)])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[
|
||||
("BUZZ_AGENT_PROVIDER", "openai"),
|
||||
("OPENAI_COMPAT_API_KEY", "test"),
|
||||
("OPENAI_COMPAT_MODEL", "fake-model"),
|
||||
("OPENAI_COMPAT_API", "responses"),
|
||||
("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": "reason it out" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
assert_thought_before_message(&updates);
|
||||
|
||||
let thought = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(thought["content"]["text"], "Thinking step by step.");
|
||||
|
||||
let message = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(message["content"]["text"], "Final answer.");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_thought_chunk_emitted_before_message_chunk_chat_completions_reasoning_content() {
|
||||
// OpenAI chat/completions path with DeepSeek-style `reasoning_content` field
|
||||
// on the message object. OPENAI_COMPAT_API defaults to Auto, which routes
|
||||
// non-openai.com hosts to chat/completions — this is the live path for
|
||||
// self-hosted reasoning models (DeepSeek, vLLM, etc.).
|
||||
let response = json!({
|
||||
"id": "cc-r1", "object": "chat.completion", "model": "fake-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Here is the answer.",
|
||||
"reasoning_content": "Let me think through this step by step.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
});
|
||||
let url = spawn_fake_llm(vec![response]).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": "solve it" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
assert_thought_before_message(&updates);
|
||||
|
||||
let thought = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
thought["content"]["text"],
|
||||
"Let me think through this step by step."
|
||||
);
|
||||
|
||||
let message = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(message["content"]["text"], "Here is the answer.");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_reasoning_no_thought_chunk() {
|
||||
// Plain text response with no reasoning content — no agent_thought_chunk
|
||||
// should appear on the wire. This guards against empty thought emissions.
|
||||
let url = spawn_fake_llm(vec![openai_text("just text, no thinking")]).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 updates = collect_updates_until_done(&mut h, p).await;
|
||||
|
||||
let has_thought = updates
|
||||
.iter()
|
||||
.any(|u| u["sessionUpdate"] == "agent_thought_chunk");
|
||||
assert!(
|
||||
!has_thought,
|
||||
"expected no agent_thought_chunk for a plain text response, got: {updates:?}"
|
||||
);
|
||||
|
||||
let has_message = updates
|
||||
.iter()
|
||||
.any(|u| u["sessionUpdate"] == "agent_message_chunk");
|
||||
assert!(has_message, "expected agent_message_chunk in updates");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
/// ACP v2 ContentChunk compliance: both `agent_thought_chunk` and
|
||||
/// `agent_message_chunk` must carry `messageId` and `content` when the
|
||||
/// client negotiates protocol version 2.
|
||||
///
|
||||
/// ACP v2 requires `ContentChunk.messageId` (required in v2 schema at
|
||||
/// agentclientprotocol/agent-client-protocol schema/v2/schema.json @d13d1baa).
|
||||
/// ACP v1 allows the field, so adding it is backwards-safe.
|
||||
///
|
||||
/// Additional invariants verified here:
|
||||
/// - The thought and assistant message IDs are **distinct** (two logical messages).
|
||||
/// - IDs do **not** recur across two consecutive `session/prompt` calls in the same
|
||||
/// ACP session (`run_id` is fresh per prompt, so no cross-turn collision).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_acp_v2_chunks_carry_message_id() {
|
||||
// OpenAI Responses API: reasoning item + text item. Both emitted chunks
|
||||
// must have messageId + content on a v2 connection.
|
||||
// Two responses so we can send two session/prompt calls and verify no ID reuse.
|
||||
let url = spawn_fake_llm(vec![
|
||||
responses_reasoning_response("Thinking about it.", "Here is my response."),
|
||||
responses_reasoning_response("Thinking again.", "Second response."),
|
||||
])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[
|
||||
("BUZZ_AGENT_PROVIDER", "openai"),
|
||||
("OPENAI_COMPAT_API_KEY", "test"),
|
||||
("OPENAI_COMPAT_MODEL", "fake-model"),
|
||||
("OPENAI_COMPAT_API", "responses"),
|
||||
("OPENAI_COMPAT_BASE_URL", &url),
|
||||
])
|
||||
.await;
|
||||
|
||||
let sid = handshake(&mut h).await; // negotiates protocolVersion: 2
|
||||
|
||||
// ── First prompt ──────────────────────────────────────────────────────────
|
||||
let p1 = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "think and respond" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let updates1 = collect_updates_until_done(&mut h, p1).await;
|
||||
|
||||
let thought1 = updates1
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.expect("agent_thought_chunk must be emitted on prompt 1");
|
||||
let message1 = updates1
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.expect("agent_message_chunk must be emitted on prompt 1");
|
||||
|
||||
// ACP v2 ContentChunk compliance: messageId must be present and non-empty.
|
||||
let thought_id1 = thought1["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_thought_chunk must carry messageId (ACP v2 required field)");
|
||||
assert!(
|
||||
!thought_id1.is_empty(),
|
||||
"agent_thought_chunk messageId must not be empty"
|
||||
);
|
||||
|
||||
let message_id1 = message1["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_message_chunk must carry messageId (ACP v2 required field)");
|
||||
assert!(
|
||||
!message_id1.is_empty(),
|
||||
"agent_message_chunk messageId must not be empty"
|
||||
);
|
||||
|
||||
// Thought and assistant message are two distinct logical messages — their IDs must differ.
|
||||
assert_ne!(
|
||||
thought_id1, message_id1,
|
||||
"agent_thought_chunk and agent_message_chunk are distinct logical messages; their messageIds must differ"
|
||||
);
|
||||
|
||||
// content must be present and correct.
|
||||
assert_eq!(
|
||||
thought1["content"]["text"], "Thinking about it.",
|
||||
"thought content mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
message1["content"]["text"], "Here is my response.",
|
||||
"message content mismatch"
|
||||
);
|
||||
|
||||
// ── Second prompt (same ACP session) ─────────────────────────────────────
|
||||
let p2 = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "think again" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let updates2 = collect_updates_until_done(&mut h, p2).await;
|
||||
|
||||
let thought2 = updates2
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.expect("agent_thought_chunk must be emitted on prompt 2");
|
||||
let message2 = updates2
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.expect("agent_message_chunk must be emitted on prompt 2");
|
||||
|
||||
let thought_id2 = thought2["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_thought_chunk must carry messageId on prompt 2");
|
||||
let message_id2 = message2["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_message_chunk must carry messageId on prompt 2");
|
||||
|
||||
// IDs from prompt 2 must be distinct from each other.
|
||||
assert_ne!(
|
||||
thought_id2, message_id2,
|
||||
"prompt 2: thought and message IDs must differ"
|
||||
);
|
||||
|
||||
// IDs must NOT recur across prompts — ACP requires session-unique messageIds.
|
||||
assert_ne!(
|
||||
thought_id1, thought_id2,
|
||||
"thought messageId must not recur across session/prompt calls (run_id must differ)"
|
||||
);
|
||||
assert_ne!(
|
||||
message_id1, message_id2,
|
||||
"message messageId must not recur across session/prompt calls (run_id must differ)"
|
||||
);
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Integration tests for AGENTS.md / SKILL.md hint loading.
|
||||
//!
|
||||
//! Uses the same subprocess + capturing-LLM pattern as `regressions.rs`.
|
||||
|
||||
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 CapturingLlm {
|
||||
url: String,
|
||||
captured: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
|
||||
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)));
|
||||
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let cap2 = captured.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let queue = queue.clone();
|
||||
let captured = cap2.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut tmp = [0u8; 8192];
|
||||
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() > 4_000_000 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
||||
let headers = &buf[..header_end];
|
||||
let mut body_len = 0usize;
|
||||
for line in headers.split(|b| *b == b'\n') {
|
||||
let line = std::str::from_utf8(line).unwrap_or("");
|
||||
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
while buf.len() < header_end + body_len {
|
||||
match sock.read(&mut tmp).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
}
|
||||
if let Ok(req) = serde_json::from_slice::<Value>(&buf[header_end..]) {
|
||||
captured.lock().await.push(req);
|
||||
}
|
||||
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\n\
|
||||
Content-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;
|
||||
});
|
||||
}
|
||||
});
|
||||
CapturingLlm { url, captured }
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: BufReader<tokio::process::ChildStdout>,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self {
|
||||
let bin = env!("CARGO_BIN_EXE_buzz-agent");
|
||||
let mut cmd = tokio::process::Command::new(bin);
|
||||
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
||||
.env("OPENAI_COMPAT_BASE_URL", base_url)
|
||||
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_MAX_ROUNDS", "8")
|
||||
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2");
|
||||
for (k, v) in extra {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().expect("spawn buzz-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(15), 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_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();
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async fn init_session(h: &mut Harness, cwd: &str) -> String {
|
||||
h.send(
|
||||
"initialize",
|
||||
json!({"protocolVersion": 1, "clientCapabilities": {}}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv().await;
|
||||
h.send("session/new", json!({"cwd": cwd, "mcpServers": []}))
|
||||
.await;
|
||||
let r = h
|
||||
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
|
||||
.await;
|
||||
r["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("sessionId")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// AGENTS.md in cwd is loaded into the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_loaded_from_cwd_agents_md() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let marker = "BUZZ_HINTS_MARKER_42";
|
||||
std::fs::write(cwd.join("AGENTS.md"), marker).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains(marker),
|
||||
"system prompt does not contain AGENTS.md marker: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// BUZZ_AGENT_NO_HINTS=1 suppresses hint loading.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_suppressed_with_env_var() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let marker = "SUPPRESS_CHECK_MARKER_99";
|
||||
std::fs::write(cwd.join("AGENTS.md"), marker).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_NO_HINTS", "1")]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!system.contains(marker),
|
||||
"system prompt should NOT contain marker when hints disabled: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// SKILL.md files in .agents/skills/ are loaded into the system prompt as metadata only.
|
||||
/// The body is NOT inlined; the agent uses `load_skill` to fetch it on demand.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn skills_loaded_from_agents_skills_dir() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skill_dir = cwd.join(".agents/skills/test-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nSKILL_BODY_MARKER_77\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
// Skill name must appear in the metadata listing.
|
||||
assert!(
|
||||
system.contains("test-skill"),
|
||||
"system prompt missing skill name: {system}"
|
||||
);
|
||||
// Body must NOT be inlined — lazy loading only.
|
||||
assert!(
|
||||
!system.contains("SKILL_BODY_MARKER_77"),
|
||||
"skill body must not be inlined in system prompt: {system}"
|
||||
);
|
||||
// The load_skill instruction must be present.
|
||||
assert!(
|
||||
system.contains("load_skill"),
|
||||
"system prompt missing load_skill instruction: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// AGENTS.md files at git root and subdirectory are both loaded, root first.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn git_root_hints_included() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join("AGENTS.md"), "ROOT_HINT_MARKER_11").unwrap();
|
||||
let sub = root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("AGENTS.md"), "SUB_HINT_MARKER_22").unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, sub.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("ROOT_HINT_MARKER_11"),
|
||||
"system prompt missing root hint: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("SUB_HINT_MARKER_22"),
|
||||
"system prompt missing sub hint: {system}"
|
||||
);
|
||||
let root_pos = system.find("ROOT_HINT_MARKER_11").unwrap();
|
||||
let sub_pos = system.find("SUB_HINT_MARKER_22").unwrap();
|
||||
assert!(
|
||||
root_pos < sub_pos,
|
||||
"root hint should appear before sub hint in system prompt"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// ~/AGENTS.md (global) is loaded before CWD AGENTS.md when HOME is set.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn global_agents_md_loaded() {
|
||||
let home_tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd_tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(home_tmp.path().join("AGENTS.md"), "GLOBAL_HINT_MARKER_55").unwrap();
|
||||
std::fs::write(cwd_tmp.path().join("AGENTS.md"), "LOCAL_HINT_MARKER_66").unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
|
||||
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("GLOBAL_HINT_MARKER_55"),
|
||||
"system prompt missing global hint: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("LOCAL_HINT_MARKER_66"),
|
||||
"system prompt missing local hint: {system}"
|
||||
);
|
||||
let global_pos = system.find("GLOBAL_HINT_MARKER_55").unwrap();
|
||||
let local_pos = system.find("LOCAL_HINT_MARKER_66").unwrap();
|
||||
assert!(
|
||||
global_pos < local_pos,
|
||||
"global hint should appear before local hint in system prompt"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// Global skills from ~/.agents/skills/ are loaded; project-level wins on name conflict.
|
||||
/// Bodies are NOT inlined — only metadata (name + description) appears in the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn global_skills_loaded_and_project_wins() {
|
||||
let home_tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd_tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let global_only_dir = home_tmp.path().join(".agents/skills/global-only");
|
||||
std::fs::create_dir_all(&global_only_dir).unwrap();
|
||||
std::fs::write(
|
||||
global_only_dir.join("SKILL.md"),
|
||||
"---\nname: global-only\ndescription: A global skill\n---\nGLOBAL_SKILL_BODY_88\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_shared_dir = home_tmp.path().join(".agents/skills/shared-name");
|
||||
std::fs::create_dir_all(&global_shared_dir).unwrap();
|
||||
std::fs::write(
|
||||
global_shared_dir.join("SKILL.md"),
|
||||
"---\nname: shared-name\ndescription: Global version\n---\nGLOBAL_SHARED_BODY_LOSE\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project_shared_dir = cwd_tmp.path().join(".agents/skills/shared-name");
|
||||
std::fs::create_dir_all(&project_shared_dir).unwrap();
|
||||
std::fs::write(
|
||||
project_shared_dir.join("SKILL.md"),
|
||||
"---\nname: shared-name\ndescription: Project version\n---\nPROJECT_SHARED_BODY_WIN\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
|
||||
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
// Both skill names must appear in the metadata listing.
|
||||
assert!(
|
||||
system.contains("global-only"),
|
||||
"system prompt missing global-only skill name: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("shared-name"),
|
||||
"system prompt missing shared-name skill: {system}"
|
||||
);
|
||||
// Project description wins over global for the shared name.
|
||||
assert!(
|
||||
system.contains("Project version"),
|
||||
"system prompt should show project description for shared-name: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("Global version"),
|
||||
"system prompt should NOT show global description for shared-name: {system}"
|
||||
);
|
||||
// Bodies must NOT be inlined.
|
||||
assert!(
|
||||
!system.contains("GLOBAL_SKILL_BODY_88"),
|
||||
"skill body must not be inlined: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("PROJECT_SHARED_BODY_WIN"),
|
||||
"skill body must not be inlined: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("GLOBAL_SHARED_BODY_LOSE"),
|
||||
"shadowed skill body must not be inlined: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// Skill directories that are symlinks (e.g. managed by ai-rules) are discovered
|
||||
/// correctly — `DirEntry::file_type()` returns `FileType::Symlink` for symlinks,
|
||||
/// so the old `is_dir()` check silently dropped them. We now use
|
||||
/// `std::fs::metadata()` which follows the symlink.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn symlinked_skill_dir_is_discovered() {
|
||||
let real_skill_root = tempfile::TempDir::new().unwrap();
|
||||
let real_skill_dir = real_skill_root.path().join("symlinked-skill");
|
||||
std::fs::create_dir_all(&real_skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
real_skill_dir.join("SKILL.md"),
|
||||
"---\nname: symlinked-skill\ndescription: A symlinked skill\n---\nSYMLINK_SKILL_BODY_42\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skills_dir = cwd.join(".agents/skills");
|
||||
std::fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
// Create a symlink: .agents/skills/symlinked-skill -> real_skill_dir
|
||||
std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
// The symlinked skill name must appear in the metadata listing.
|
||||
assert!(
|
||||
system.contains("symlinked-skill"),
|
||||
"system prompt missing symlinked skill name: {system}"
|
||||
);
|
||||
// Body must NOT be inlined.
|
||||
assert!(
|
||||
!system.contains("SYMLINK_SKILL_BODY_42"),
|
||||
"symlinked skill body must not be inlined in system prompt: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// `load_skill` tool is advertised when skills exist, and returns the skill body.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn load_skill_tool_returns_body() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skill_dir = cwd.join(".agents/skills/my-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: my-skill\ndescription: A skill\n---\nSKILL_BODY_CONTENT_99\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Round 1: LLM calls load_skill("my-skill").
|
||||
// Round 2: LLM returns end_turn after seeing the body.
|
||||
let load_skill_call = json!({
|
||||
"id": "cc-ls", "object": "chat.completion", "model": "fake-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant", "content": null,
|
||||
"tool_calls": [{
|
||||
"id": "tc-1", "type": "function",
|
||||
"function": {
|
||||
"name": "load_skill",
|
||||
"arguments": "{\"name\":\"my-skill\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
let end_turn = openai_text("done");
|
||||
|
||||
let llm = spawn_capturing_llm(vec![load_skill_call, end_turn]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"use my-skill"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
// The second LLM request (round 2) should contain the skill body in tool results.
|
||||
let reqs = llm.captured.lock().await;
|
||||
assert!(
|
||||
reqs.len() >= 2,
|
||||
"expected at least 2 LLM requests, got {}",
|
||||
reqs.len()
|
||||
);
|
||||
let round2_str = serde_json::to_string(&reqs[1]).unwrap();
|
||||
assert!(
|
||||
round2_str.contains("SKILL_BODY_CONTENT_99"),
|
||||
"load_skill result must contain skill body in round 2 request.\nGot: {round2_str}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Integration test for OpenAI auto-upgrade chat→responses.
|
||||
//!
|
||||
//! Starts a tiny HTTP server that:
|
||||
//! 1. accepts a POST to /chat/completions, replies 400 with a body that
|
||||
//! mentions `/v1/responses` (mirrors the Databricks GPT-5.5 signal);
|
||||
//! 2. accepts a POST to /responses, replies 200 with a Responses-shaped
|
||||
//! JSON envelope.
|
||||
//!
|
||||
//! Spawns `buzz-agent` with `provider=openai` + `OPENAI_COMPAT_API=auto`
|
||||
//! pointed at the fake server, drives one prompt through the ACP wire
|
||||
//! protocol, and verifies the prompt completes with `stopReason=end_turn`
|
||||
//! — which can only happen if the second (Responses) request succeeded.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Spawns a single-shot fake provider. Returns the base URL (e.g.
|
||||
/// `http://127.0.0.1:54321`). The server stays up for the lifetime of
|
||||
/// the process — we don't need to clean it up explicitly.
|
||||
fn spawn_fake_provider() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(false).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let chat_hits = Arc::new(AtomicUsize::new(0));
|
||||
let responses_hits = Arc::new(AtomicUsize::new(0));
|
||||
let chat = chat_hits.clone();
|
||||
let resp = responses_hits.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let chat = chat.clone();
|
||||
let resp = resp.clone();
|
||||
std::thread::spawn(move || {
|
||||
sock.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
||||
// Read request head + body. Naive: read until we have the
|
||||
// request line + headers, then read Content-Length bytes.
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut tmp = [0u8; 4096];
|
||||
loop {
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
match sock.read(&mut tmp) {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
if buf.len() > 256 * 1024 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let head_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
||||
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
|
||||
// Drain the body to satisfy keep-alive; we don't actually
|
||||
// need it.
|
||||
let cl = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.strip_prefix("content-length:")
|
||||
.or_else(|| l.strip_prefix("Content-Length:"))
|
||||
})
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
while buf.len() < head_end + cl {
|
||||
match sock.read(&mut tmp) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
}
|
||||
|
||||
let (status, body) = if head.contains("POST /chat/completions") {
|
||||
chat.fetch_add(1, Ordering::SeqCst);
|
||||
let body = json!({
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead."
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
(400u16, body)
|
||||
} else if head.contains("POST /responses") {
|
||||
resp.fetch_add(1, Ordering::SeqCst);
|
||||
let body = json!({
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "ok from responses"}]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
(200u16, body)
|
||||
} else {
|
||||
(404u16, "{}".to_string())
|
||||
};
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
_ => "Not Found",
|
||||
};
|
||||
let resp_text = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
);
|
||||
let _ = sock.write_all(resp_text.as_bytes());
|
||||
let _ = sock.shutdown(std::net::Shutdown::Write);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(url, chat_hits, responses_hits)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_auto_upgrades_chat_to_responses_on_databricks_signal() {
|
||||
let (base_url, chat_hits, resp_hits) = spawn_fake_provider();
|
||||
|
||||
let bin = env!("CARGO_BIN_EXE_buzz-agent");
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "gpt-5.5")
|
||||
.env("OPENAI_COMPAT_BASE_URL", &base_url)
|
||||
// No OPENAI_COMPAT_API — must default to "auto" so the upgrade
|
||||
// path is enabled.
|
||||
.env_remove("OPENAI_COMPAT_API")
|
||||
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_MAX_ROUNDS", "4")
|
||||
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd.spawn().expect("spawn buzz-agent");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
|
||||
async fn send(stdin: &mut tokio::process::ChildStdin, v: serde_json::Value) {
|
||||
let line = format!("{v}\n");
|
||||
stdin.write_all(line.as_bytes()).await.unwrap();
|
||||
stdin.flush().await.unwrap();
|
||||
}
|
||||
async fn recv(stdout: &mut BufReader<tokio::process::ChildStdout>) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
timeout(Duration::from_secs(8), stdout.read_line(&mut line))
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("recv io");
|
||||
serde_json::from_str(&line).expect("recv json")
|
||||
}
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": 1, "clientCapabilities": {},
|
||||
"clientInfo": {"name": "auto-upgrade-test"}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let init = recv(&mut stdout).await;
|
||||
assert!(init.get("result").is_some(), "initialize: {init}");
|
||||
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "session/new",
|
||||
"params": {"cwd": cwd.to_string_lossy(), "mcpServers": []}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let sess = recv(&mut stdout).await;
|
||||
let sid = sess["result"]["sessionId"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("session/new failed: {sess}"))
|
||||
.to_string();
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 3, "method": "session/prompt",
|
||||
"params": {"sessionId": sid,
|
||||
"prompt": [{"type": "text", "text": "hi"}]}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Drain notifications until we see the response for id=3.
|
||||
let mut stop_reason: Option<String> = None;
|
||||
for _ in 0..40 {
|
||||
let msg = recv(&mut stdout).await;
|
||||
if msg.get("id") == Some(&json!(3)) {
|
||||
if let Some(r) = msg.get("result") {
|
||||
stop_reason = r
|
||||
.get("stopReason")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(stop_reason.as_deref(), Some("end_turn"));
|
||||
assert_eq!(
|
||||
chat_hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"must have tried chat first"
|
||||
);
|
||||
assert!(
|
||||
resp_hits.load(Ordering::SeqCst) >= 1,
|
||||
"must have upgraded to responses"
|
||||
);
|
||||
|
||||
drop(stdin);
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "buzz-audit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Hash-chain audit log for Buzz"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -0,0 +1,100 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Audit action recorded for each event in the log.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditAction {
|
||||
/// A Nostr event was created.
|
||||
EventCreated,
|
||||
/// A Nostr event was deleted.
|
||||
EventDeleted,
|
||||
/// A channel was created.
|
||||
ChannelCreated,
|
||||
/// A channel's metadata was updated.
|
||||
ChannelUpdated,
|
||||
/// A channel was deleted.
|
||||
ChannelDeleted,
|
||||
/// A member was added to a channel.
|
||||
MemberAdded,
|
||||
/// A member was removed from a channel.
|
||||
MemberRemoved,
|
||||
/// A client successfully authenticated.
|
||||
AuthSuccess,
|
||||
/// A client authentication attempt failed.
|
||||
AuthFailure,
|
||||
/// A client exceeded the rate limit.
|
||||
RateLimitExceeded,
|
||||
/// A media file was uploaded via the Blossom endpoint.
|
||||
MediaUploaded,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
/// Stable string representation used in hash computation and DB storage.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::EventCreated => "event_created",
|
||||
Self::EventDeleted => "event_deleted",
|
||||
Self::ChannelCreated => "channel_created",
|
||||
Self::ChannelUpdated => "channel_updated",
|
||||
Self::ChannelDeleted => "channel_deleted",
|
||||
Self::MemberAdded => "member_added",
|
||||
Self::MemberRemoved => "member_removed",
|
||||
Self::AuthSuccess => "auth_success",
|
||||
Self::AuthFailure => "auth_failure",
|
||||
Self::RateLimitExceeded => "rate_limit_exceeded",
|
||||
Self::MediaUploaded => "media_uploaded",
|
||||
}
|
||||
}
|
||||
|
||||
const ALL: &'static [Self] = &[
|
||||
Self::EventCreated,
|
||||
Self::EventDeleted,
|
||||
Self::ChannelCreated,
|
||||
Self::ChannelUpdated,
|
||||
Self::ChannelDeleted,
|
||||
Self::MemberAdded,
|
||||
Self::MemberRemoved,
|
||||
Self::AuthSuccess,
|
||||
Self::AuthFailure,
|
||||
Self::RateLimitExceeded,
|
||||
Self::MediaUploaded,
|
||||
];
|
||||
}
|
||||
|
||||
impl fmt::Display for AuditAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AuditAction {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::ALL
|
||||
.iter()
|
||||
.find(|a| a.as_str() == s)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("unknown audit action: {s:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_all_variants() {
|
||||
for action in AuditAction::ALL {
|
||||
let parsed: AuditAction = action.to_string().parse().unwrap();
|
||||
assert_eq!(&parsed, action);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_action_returns_err() {
|
||||
assert!("totally_bogus".parse::<AuditAction>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use buzz_core::CommunityId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::action::AuditAction;
|
||||
|
||||
/// A materialised audit log entry as stored in `audit_log`.
|
||||
///
|
||||
/// Rows are keyed `(community_id, seq)`: `seq` is monotonic *within one
|
||||
/// community*, and `prev_hash` chains to the previous entry *of the same
|
||||
/// community*. The chain is independent per tenant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditEntry {
|
||||
/// Server-resolved community this entry belongs to. Leads the primary key.
|
||||
pub community_id: Uuid,
|
||||
/// Sequence number, monotonic within `community_id` (starts at 1).
|
||||
pub seq: i64,
|
||||
/// SHA-256 of this entry's fields including `community_id` and `prev_hash`.
|
||||
pub hash: Vec<u8>,
|
||||
/// SHA-256 of the previous entry in *this community's* chain, or `None` for
|
||||
/// the community's first entry (hashed as [`crate::hash::GENESIS_HASH`]).
|
||||
pub prev_hash: Option<Vec<u8>>,
|
||||
/// Action that was performed.
|
||||
pub action: AuditAction,
|
||||
/// Raw bytes of the actor's Nostr pubkey, if the action has one.
|
||||
pub actor_pubkey: Option<Vec<u8>>,
|
||||
/// Generic identifier of the object acted upon (event id hex, channel UUID,
|
||||
/// media sha256, …), if any. The relay resolves it under `community_id`;
|
||||
/// it never names an object in another community.
|
||||
pub object_id: Option<String>,
|
||||
/// Arbitrary JSON context. **Included in the hash** (serialized with sorted
|
||||
/// keys for determinism) so tampering with it is detectable.
|
||||
pub detail: serde_json::Value,
|
||||
/// When the entry was recorded.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Input for appending a new audit entry. `seq`, `prev_hash`, `hash`, and
|
||||
/// `created_at` are assigned by [`crate::service::AuditService::log`].
|
||||
///
|
||||
/// `community_id` is the **server-resolved** tenant (from the request's
|
||||
/// `TenantContext`), never a client-supplied value — the same provenance rule
|
||||
/// the whole multi-tenant model rests on.
|
||||
///
|
||||
/// Not `Serialize`/`Deserialize`: this is an in-process input struct (consumed
|
||||
/// by `AuditService::log`, threaded through the in-memory audit sink), never
|
||||
/// crossing a wire or DB boundary as a whole. Keeping it non-deserializable
|
||||
/// reinforces the fence — there is no path by which a client-supplied blob
|
||||
/// becomes a `NewAuditEntry` (and thus a `CommunityId`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAuditEntry {
|
||||
/// Server-resolved community this entry belongs to. Typed as [`CommunityId`]
|
||||
/// (not a raw `Uuid`) so the provenance rule is visible in the signature:
|
||||
/// the only ways to obtain one are host resolution or a server-scoped DB
|
||||
/// row — never a value parsed from client input.
|
||||
pub community_id: CommunityId,
|
||||
/// Action that was performed.
|
||||
pub action: AuditAction,
|
||||
/// Raw bytes of the actor's Nostr pubkey, if the action has one.
|
||||
pub actor_pubkey: Option<Vec<u8>>,
|
||||
/// Generic identifier of the object acted upon, if any.
|
||||
pub object_id: Option<String>,
|
||||
/// Arbitrary JSON context included in the hash.
|
||||
///
|
||||
/// **Never bearer-token material.** This field is opaque to the audit
|
||||
/// crate and persisted verbatim; callers must not write tokens, passwords,
|
||||
/// or other secrets here. `AuthSuccess`/`AuthFailure` entries carry only
|
||||
/// outcome metadata — the token has no slot in this type, and `detail` must
|
||||
/// not become one.
|
||||
pub detail: serde_json::Value,
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during audit log operations.
|
||||
///
|
||||
/// These are **operator-internal** diagnostics (logged by the audit worker, or
|
||||
/// returned to an operator-scoped verification call) — they are never relayed to
|
||||
/// a client on the wire. Even so, no variant embeds a `community_id` or any
|
||||
/// cross-community object identifier: a `seq` is per-community and meaningless
|
||||
/// without its chain, and hashes are opaque. An error raised while verifying
|
||||
/// community A's chain therefore cannot reveal a fact about community B.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuditError {
|
||||
/// A database operation failed.
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
/// The `prev_hash` of an entry does not match the hash of the preceding
|
||||
/// entry in the same community's chain.
|
||||
#[error(
|
||||
"hash chain integrity violation at seq {seq}: prev_hash does not match preceding entry"
|
||||
)]
|
||||
ChainViolation {
|
||||
/// Per-community sequence number of the offending entry.
|
||||
seq: i64,
|
||||
},
|
||||
|
||||
/// The stored hash of an entry does not match the recomputed hash.
|
||||
#[error("hash mismatch at seq {seq}: stored hash does not match recomputed hash")]
|
||||
HashMismatch {
|
||||
/// Per-community sequence number of the offending entry.
|
||||
seq: i64,
|
||||
},
|
||||
|
||||
/// An unrecognised action string was found in the database.
|
||||
#[error("unknown audit action in database")]
|
||||
UnknownAction,
|
||||
|
||||
/// A JSON serialization error occurred (e.g. while canonicalising `detail`).
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sanitization obligation for the conformance `audit_log` row: an error
|
||||
/// raised while verifying or appending to one community's chain must not let
|
||||
/// its rendered text become a cross-community identifier — no `community_id`,
|
||||
/// no constraint name. Only `seq` may appear, and `seq` is per-community and
|
||||
/// meaningless without the chain it indexes.
|
||||
///
|
||||
/// This is the *complement* to the structural fence in the variant
|
||||
/// definitions above: those variants simply have no `community_id` field, so
|
||||
/// there is no slot to leak one from. This test pins the observable form —
|
||||
/// if anyone adds a `community_id` to a variant and threads it into the
|
||||
/// `#[error(...)]` format string, the assertion below reds.
|
||||
#[test]
|
||||
fn audit_error_text_carries_no_community_id_or_constraint() {
|
||||
// A concrete community whose chain is "being verified" when these errors
|
||||
// fire. If its id leaked into any error text, the error would identify a
|
||||
// specific tenant.
|
||||
let community = uuid::Uuid::new_v4();
|
||||
let community_str = community.to_string();
|
||||
let community_simple = community.simple().to_string();
|
||||
|
||||
// The variants the audit crate constructs itself with chain-derived data.
|
||||
let domain_errors = [
|
||||
AuditError::ChainViolation { seq: 7 },
|
||||
AuditError::HashMismatch { seq: 42 },
|
||||
AuditError::UnknownAction,
|
||||
];
|
||||
|
||||
for err in &domain_errors {
|
||||
let text = err.to_string();
|
||||
|
||||
// No form of the community id may appear.
|
||||
assert!(
|
||||
!text.contains(&community_str) && !text.contains(&community_simple),
|
||||
"audit error text leaked a community_id: {text:?}"
|
||||
);
|
||||
|
||||
// No Postgres constraint/PK names that would reveal schema shape or
|
||||
// the existence of a cross-community key.
|
||||
for needle in [
|
||||
"community_id",
|
||||
"audit_log_pkey",
|
||||
"constraint",
|
||||
"communities",
|
||||
] {
|
||||
assert!(
|
||||
!text.to_ascii_lowercase().contains(needle),
|
||||
"audit error text leaked a constraint/identifier '{needle}': {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The two chain-integrity variants must still carry their per-community
|
||||
// `seq` (the diagnostic is useless without it) — proves the assertion
|
||||
// above isn't vacuously passing on empty strings.
|
||||
assert!(AuditError::ChainViolation { seq: 7 }
|
||||
.to_string()
|
||||
.contains('7'));
|
||||
assert!(AuditError::HashMismatch { seq: 42 }
|
||||
.to_string()
|
||||
.contains("42"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use chrono::{DateTime, SubsecRound, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::entry::AuditEntry;
|
||||
use crate::error::AuditError;
|
||||
|
||||
/// The 32-byte sentinel hashed in place of `prev_hash` for a community's first
|
||||
/// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes.
|
||||
pub const GENESIS_HASH: [u8; 32] = [0u8; 32];
|
||||
|
||||
/// Reduce a timestamp to the precision the audit store round-trips.
|
||||
///
|
||||
/// `audit_log.created_at` is `TIMESTAMPTZ`, which Postgres keeps at microsecond
|
||||
/// resolution. [`compute_hash`] covers `created_at.to_rfc3339()`, and that
|
||||
/// string's sub-second digit count follows the value (chrono emits 0, 3, 6 or 9
|
||||
/// digits), so a timestamp carrying nanoseconds hashes to a digest that can
|
||||
/// never be recomputed from the stored row — the entry is written with one
|
||||
/// preimage and verified against another.
|
||||
///
|
||||
/// Every `created_at` must therefore pass through here *before* it is hashed
|
||||
/// and stored, so the in-memory entry and the row are byte-identical.
|
||||
pub fn to_storage_precision(created_at: DateTime<Utc>) -> DateTime<Utc> {
|
||||
created_at.trunc_subsecs(6)
|
||||
}
|
||||
|
||||
/// SHA-256 over the entry's identity, chain, and context fields.
|
||||
///
|
||||
/// Field order is fixed — changing it invalidates all existing chains. The
|
||||
/// `community_id` is hashed first so chain identity carries the tenant: an entry
|
||||
/// cannot be lifted out of one community's chain and re-verified inside another.
|
||||
///
|
||||
/// `created_at` is normalized through [`to_storage_precision`] here rather than
|
||||
/// hashed as given. Write paths truncate before storing so the row matches the
|
||||
/// in-memory entry, but normalizing again at the single point that consumes the
|
||||
/// value means no future caller can reintroduce the write/read preimage split
|
||||
/// by forgetting to. Values already at storage precision are unaffected —
|
||||
/// truncation is idempotent — so this does not change any digest.
|
||||
///
|
||||
/// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is
|
||||
/// stable across machines and Rust versions. A serialization failure is a hard
|
||||
/// error, never silently hashed as empty.
|
||||
pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> {
|
||||
let mut hasher = Sha256::new();
|
||||
// Tenant binding: community_id leads the hash.
|
||||
hasher.update(entry.community_id.as_bytes());
|
||||
hasher.update(entry.seq.to_be_bytes());
|
||||
hasher.update(
|
||||
to_storage_precision(entry.created_at)
|
||||
.to_rfc3339()
|
||||
.as_bytes(),
|
||||
);
|
||||
hasher.update(entry.action.as_str().as_bytes());
|
||||
match &entry.actor_pubkey {
|
||||
Some(pk) => {
|
||||
hasher.update([1u8]); // presence tag — distinguishes Some(empty) from None
|
||||
hasher.update(pk);
|
||||
}
|
||||
None => hasher.update([0u8]),
|
||||
}
|
||||
match &entry.object_id {
|
||||
Some(id) => {
|
||||
hasher.update([1u8]);
|
||||
hasher.update(id.as_bytes());
|
||||
}
|
||||
None => hasher.update([0u8]),
|
||||
}
|
||||
hasher.update(canonical_json(&entry.detail)?.as_bytes());
|
||||
match &entry.prev_hash {
|
||||
Some(h) => hasher.update(h),
|
||||
None => hasher.update(GENESIS_HASH),
|
||||
}
|
||||
Ok(hasher.finalize().into())
|
||||
}
|
||||
|
||||
/// Serialize a JSON value with sorted object keys for deterministic output.
|
||||
///
|
||||
/// Propagates any scalar serialization error rather than substituting a
|
||||
/// placeholder — a hash must never silently stand in an empty value for a real
|
||||
/// payload.
|
||||
fn canonical_json(value: &serde_json::Value) -> Result<String, serde_json::Error> {
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sorted: BTreeMap<&str, &Value> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||||
let mut out = String::from("{");
|
||||
let mut first = true;
|
||||
for (k, v) in &sorted {
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
first = false;
|
||||
out.push_str(&serde_json::to_string(k)?);
|
||||
out.push(':');
|
||||
out.push_str(&canonical_json(v)?);
|
||||
}
|
||||
out.push('}');
|
||||
Ok(out)
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut out = String::from("[");
|
||||
let mut first = true;
|
||||
for v in arr {
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
first = false;
|
||||
out.push_str(&canonical_json(v)?);
|
||||
}
|
||||
out.push(']');
|
||||
Ok(out)
|
||||
}
|
||||
other => serde_json::to_string(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{action::AuditAction, entry::AuditEntry};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample_entry() -> AuditEntry {
|
||||
AuditEntry {
|
||||
community_id: Uuid::from_u128(1),
|
||||
seq: 1,
|
||||
hash: Vec::new(),
|
||||
prev_hash: None,
|
||||
action: AuditAction::EventCreated,
|
||||
actor_pubkey: Some(vec![0xab; 32]),
|
||||
object_id: Some("abc123".into()),
|
||||
detail: serde_json::Value::Null,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
}
|
||||
}
|
||||
|
||||
/// A wall-clock instant carrying sub-microsecond digits, like `Utc::now()`
|
||||
/// returns on Linux (`clock_gettime`, nanosecond resolution).
|
||||
fn nanosecond_instant() -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::from_timestamp_nanos(1_700_000_000_123_456_789)
|
||||
}
|
||||
|
||||
/// What Postgres hands back for a `TIMESTAMPTZ`: microsecond resolution.
|
||||
fn after_database_round_trip(ts: chrono::DateTime<Utc>) -> chrono::DateTime<Utc> {
|
||||
ts.trunc_subsecs(6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic() {
|
||||
let entry = sample_entry();
|
||||
assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap());
|
||||
assert_eq!(compute_hash(&entry).unwrap().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_precision_drops_sub_microsecond_digits() {
|
||||
let stored = to_storage_precision(nanosecond_instant());
|
||||
assert_eq!(stored.timestamp_subsec_nanos(), 123_456_000);
|
||||
// Idempotent, so a stored value re-read from Postgres is unchanged.
|
||||
assert_eq!(stored, after_database_round_trip(stored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_sub_second_width_follows_the_value() {
|
||||
// The underlying trap, pinned on the preimage rather than the digest:
|
||||
// chrono emits 0/3/6/9 fractional digits depending on the value, so a
|
||||
// nanosecond timestamp and its microsecond truncation are *different
|
||||
// strings*. Hashing the untruncated value therefore produces a digest
|
||||
// that cannot be recomputed from the stored row — which is what made
|
||||
// every entry fail `verify_chain` with `HashMismatch`.
|
||||
let ns = nanosecond_instant();
|
||||
assert_eq!(ns.to_rfc3339(), "2023-11-14T22:13:20.123456789+00:00");
|
||||
assert_eq!(
|
||||
after_database_round_trip(ns).to_rfc3339(),
|
||||
"2023-11-14T22:13:20.123456+00:00"
|
||||
);
|
||||
assert_ne!(ns.to_rfc3339(), after_database_round_trip(ns).to_rfc3339());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_normalizes_sub_microsecond_timestamps() {
|
||||
// The enforcement point: even handed an untruncated `created_at`,
|
||||
// `compute_hash` digests the storage-precision value, so a write path
|
||||
// that forgot to truncate cannot split the write/read preimage.
|
||||
let ns = nanosecond_instant();
|
||||
let mut written = sample_entry();
|
||||
written.created_at = ns;
|
||||
let mut read_back = sample_entry();
|
||||
read_back.created_at = after_database_round_trip(ns);
|
||||
|
||||
assert_eq!(
|
||||
compute_hash(&written).unwrap(),
|
||||
compute_hash(&read_back).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_precision_timestamps_survive_a_database_round_trip() {
|
||||
// The invariant the write path must hold: hash what will be stored, so
|
||||
// recomputing from the row reproduces the digest.
|
||||
let mut written = sample_entry();
|
||||
written.created_at = to_storage_precision(nanosecond_instant());
|
||||
let mut read_back = written.clone();
|
||||
read_back.created_at = after_database_round_trip(read_back.created_at);
|
||||
|
||||
assert_eq!(
|
||||
compute_hash(&written).unwrap(),
|
||||
compute_hash(&read_back).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn community_id_is_part_of_identity() {
|
||||
// The whole point: the same logical entry in two communities hashes
|
||||
// differently, so a row can't be replayed across chains.
|
||||
let a = sample_entry();
|
||||
let mut b = a.clone();
|
||||
b.community_id = Uuid::from_u128(2);
|
||||
assert_ne!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_to_each_field() {
|
||||
let base = sample_entry();
|
||||
let h0 = compute_hash(&base).unwrap();
|
||||
|
||||
let mut e = base.clone();
|
||||
e.seq = 2;
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.action = AuditAction::EventDeleted;
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.actor_pubkey = Some(vec![0xcd; 32]);
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.object_id = Some("different".into());
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.detail = serde_json::json!({"key": "value"});
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.prev_hash = Some(vec![0xff; 32]);
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_tag_distinguishes_none_from_empty() {
|
||||
// Some(empty) must not collide with None — the presence tag prevents it.
|
||||
let mut none = sample_entry();
|
||||
none.actor_pubkey = None;
|
||||
let mut empty = sample_entry();
|
||||
empty.actor_pubkey = Some(Vec::new());
|
||||
assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_json_key_order_is_stable() {
|
||||
let a = serde_json::json!({"z": 1, "a": 2, "m": 3});
|
||||
let b = serde_json::json!({"a": 2, "m": 3, "z": 1});
|
||||
assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! Tamper-evident, **per-community** hash-chain audit log.
|
||||
//!
|
||||
//! Each community owns an independent chain: rows are keyed `(community_id, seq)`,
|
||||
//! `seq` is monotonic *within a community*, and each entry chains to the previous
|
||||
//! entry *of the same community* via SHA-256. The `community_id` is folded into the
|
||||
//! hash, so a row lifted out of one community's chain can never verify inside
|
||||
//! another's — chain identity carries the tenant. This is the audit half of the
|
||||
//! non-interference floor (`auditHeads[c]` in `MultiTenantRelay.tla`): an audit
|
||||
//! observation reveals only its own community's head.
|
||||
//!
|
||||
//! Writes for a given community are serialized by a **per-community** Postgres
|
||||
//! advisory lock, so the chain stays consistent across relay processes without one
|
||||
//! global lock serializing (and timing-coupling) every tenant.
|
||||
//!
|
||||
//! The `audit_log` table is owned by the consolidated `0001` migration — this crate
|
||||
//! is pure chain logic and ships no DDL.
|
||||
|
||||
/// Audit action types recorded in the log.
|
||||
pub mod action;
|
||||
/// Audit log entry types (stored and input).
|
||||
pub mod entry;
|
||||
/// Error types for audit operations.
|
||||
pub mod error;
|
||||
/// SHA-256 hash computation for audit entries.
|
||||
pub mod hash;
|
||||
/// Audit log service — append and verify entries.
|
||||
pub mod service;
|
||||
|
||||
pub use action::AuditAction;
|
||||
pub use entry::{AuditEntry, NewAuditEntry};
|
||||
pub use error::AuditError;
|
||||
pub use hash::{compute_hash, GENESIS_HASH};
|
||||
pub use service::AuditService;
|
||||
@@ -0,0 +1,527 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::FutureExt as _;
|
||||
use sqlx::{Acquire, PgPool, Row};
|
||||
use tracing::{debug, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_core::CommunityId;
|
||||
|
||||
use crate::{
|
||||
action::AuditAction,
|
||||
entry::{AuditEntry, NewAuditEntry},
|
||||
error::AuditError,
|
||||
hash::{compute_hash, to_storage_precision},
|
||||
};
|
||||
|
||||
/// The `created_at` stamped on a new entry.
|
||||
///
|
||||
/// Reduced to the precision Postgres round-trips before it is hashed — see
|
||||
/// [`to_storage_precision`]. Split out from [`AuditService::log_inner`] so the
|
||||
/// invariant is testable without a database.
|
||||
fn log_timestamp() -> DateTime<Utc> {
|
||||
to_storage_precision(Utc::now())
|
||||
}
|
||||
|
||||
/// Per-community advisory lock key. Derived in Postgres from the community UUID
|
||||
/// so two communities never serialize each other's audit writes (which would be
|
||||
/// both a throughput bottleneck and a cross-tenant timing oracle). The lock is
|
||||
/// taken with `pg_advisory_lock(hashtextextended(...))` — see [`AuditService::log`].
|
||||
const AUDIT_LOCK_NAMESPACE: &str = "buzz_audit:";
|
||||
|
||||
/// Append-only, per-community hash-chain audit log backed by Postgres.
|
||||
///
|
||||
/// Each community has an independent chain keyed `(community_id, seq)`. Writes
|
||||
/// for one community are serialized by a per-community advisory lock so the chain
|
||||
/// stays consistent across relay processes; different communities proceed in
|
||||
/// parallel.
|
||||
pub struct AuditService {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl AuditService {
|
||||
/// Creates a new `AuditService` using the given connection pool.
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Append a new entry to the calling community's chain.
|
||||
///
|
||||
/// Serialized per-community via `pg_advisory_lock`. Postgres advisory locks
|
||||
/// are session-scoped, so we acquire before the transaction and release
|
||||
/// after commit (or on any error path).
|
||||
#[instrument(skip(self, entry), fields(action = %entry.action))]
|
||||
pub async fn log(&self, entry: NewAuditEntry) -> Result<AuditEntry, AuditError> {
|
||||
let mut conn = self.pool.acquire().await?;
|
||||
|
||||
// Per-community advisory lock: hash the namespaced community id to an
|
||||
// i64 lock key inside Postgres. Communities lock independently.
|
||||
let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{}", entry.community_id);
|
||||
sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))")
|
||||
.bind(&lock_key)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
// Run the chain append and release the lock regardless of outcome.
|
||||
// catch_unwind so a panic still releases the lock before the connection
|
||||
// returns to the pool.
|
||||
let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))")
|
||||
.bind(&lock_key)
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(inner_result) => inner_result,
|
||||
Err(panic_payload) => std::panic::resume_unwind(panic_payload),
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_inner(
|
||||
&self,
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
entry: NewAuditEntry,
|
||||
) -> Result<AuditEntry, AuditError> {
|
||||
let mut tx = conn.begin().await?;
|
||||
|
||||
// The stored row keys on the raw UUID; the typed `CommunityId` on the
|
||||
// input is the provenance fence, dereferenced here at the DB boundary.
|
||||
let community_id = *entry.community_id.as_uuid();
|
||||
|
||||
// Head of THIS community's chain — scoped by community_id.
|
||||
let head = sqlx::query(
|
||||
"SELECT seq, hash FROM audit_log
|
||||
WHERE community_id = $1
|
||||
ORDER BY seq DESC LIMIT 1",
|
||||
)
|
||||
.bind(community_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (prev_seq, prev_hash): (i64, Option<Vec<u8>>) = match head {
|
||||
Some(row) => (
|
||||
row.get::<i64, _>("seq"),
|
||||
Some(row.get::<Vec<u8>, _>("hash")),
|
||||
),
|
||||
None => (0, None), // community's first entry
|
||||
};
|
||||
let seq = prev_seq + 1;
|
||||
|
||||
let created_at: DateTime<Utc> = log_timestamp();
|
||||
|
||||
let mut audit_entry = AuditEntry {
|
||||
community_id,
|
||||
seq,
|
||||
hash: Vec::new(),
|
||||
prev_hash,
|
||||
action: entry.action,
|
||||
actor_pubkey: entry.actor_pubkey,
|
||||
object_id: entry.object_id,
|
||||
detail: entry.detail,
|
||||
created_at,
|
||||
};
|
||||
|
||||
audit_entry.hash = compute_hash(&audit_entry)?.to_vec();
|
||||
|
||||
debug!(seq, "writing audit entry");
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_log
|
||||
(community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
)
|
||||
.bind(audit_entry.community_id)
|
||||
.bind(audit_entry.seq)
|
||||
.bind(&audit_entry.hash)
|
||||
.bind(audit_entry.prev_hash.as_deref())
|
||||
.bind(audit_entry.action.as_str())
|
||||
.bind(audit_entry.actor_pubkey.as_deref())
|
||||
.bind(audit_entry.object_id.as_deref())
|
||||
.bind(&audit_entry.detail)
|
||||
.bind(audit_entry.created_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(audit_entry)
|
||||
}
|
||||
|
||||
/// Verify the hash chain for one community over `[from_seq, to_seq]`.
|
||||
///
|
||||
/// Reads exactly that community's chain — it can never observe another
|
||||
/// community's entries or head. Returns `Ok(false)` if the range is empty,
|
||||
/// `Ok(true)` if the segment is internally consistent.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn verify_chain(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
from_seq: i64,
|
||||
to_seq: i64,
|
||||
) -> Result<bool, AuditError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT community_id, seq, hash, prev_hash, action, actor_pubkey,
|
||||
object_id, detail, created_at
|
||||
FROM audit_log
|
||||
WHERE community_id = $1 AND seq BETWEEN $2 AND $3
|
||||
ORDER BY seq ASC
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(from_seq)
|
||||
.bind(to_seq)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut expected_prev: Option<Vec<u8>> = None;
|
||||
|
||||
for row in &rows {
|
||||
let entry = row_to_audit_entry(row)?;
|
||||
|
||||
if let Some(ref expected) = expected_prev {
|
||||
// The previous entry's hash must equal this entry's prev_hash.
|
||||
if entry.prev_hash.as_deref() != Some(expected.as_slice()) {
|
||||
return Err(AuditError::ChainViolation { seq: entry.seq });
|
||||
}
|
||||
}
|
||||
|
||||
let computed = compute_hash(&entry)?;
|
||||
if computed.as_slice() != entry.hash.as_slice() {
|
||||
return Err(AuditError::HashMismatch { seq: entry.seq });
|
||||
}
|
||||
|
||||
expected_prev = Some(entry.hash);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Returns up to `limit` entries from one community's chain starting at
|
||||
/// `from_seq`, ordered by sequence number. Scoped to `community` — never
|
||||
/// returns another community's rows.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_entries(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
from_seq: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<AuditEntry>, AuditError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT community_id, seq, hash, prev_hash, action, actor_pubkey,
|
||||
object_id, detail, created_at
|
||||
FROM audit_log
|
||||
WHERE community_id = $1 AND seq >= $2
|
||||
ORDER BY seq ASC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(from_seq)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(row_to_audit_entry).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result<AuditEntry, AuditError> {
|
||||
let action_str: String = row.get("action");
|
||||
let action: AuditAction = action_str.parse().map_err(|_| {
|
||||
warn!("unknown action in audit log");
|
||||
AuditError::UnknownAction
|
||||
})?;
|
||||
|
||||
Ok(AuditEntry {
|
||||
community_id: row.get::<Uuid, _>("community_id"),
|
||||
seq: row.get("seq"),
|
||||
hash: row.get("hash"),
|
||||
prev_hash: row.get("prev_hash"),
|
||||
action,
|
||||
actor_pubkey: row.get("actor_pubkey"),
|
||||
object_id: row.get("object_id"),
|
||||
detail: row.get("detail"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::AuditAction;
|
||||
use crate::entry::NewAuditEntry;
|
||||
use chrono::SubsecRound;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
// The per-community advisory lock means different communities don't contend,
|
||||
// but tests share one table; serialize them so seq assertions are stable.
|
||||
static DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
fn db_lock() -> &'static Mutex<()> {
|
||||
DB_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into());
|
||||
PgPool::connect(&url).await.ok()
|
||||
}
|
||||
|
||||
/// Runs without Postgres, so a regression here is caught by `just
|
||||
/// test-unit` rather than only by the `#[ignore]` chain tests below.
|
||||
#[test]
|
||||
fn log_timestamp_carries_no_sub_microsecond_digits() {
|
||||
let ts = log_timestamp();
|
||||
assert_eq!(
|
||||
ts,
|
||||
ts.trunc_subsecs(6),
|
||||
"created_at is hashed and then stored in a TIMESTAMPTZ column; \
|
||||
sub-microsecond digits make every entry fail verify_chain"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `community_id` known to exist in `communities` (FK target). Inserts a
|
||||
/// throwaway community row with a unique host and returns its id.
|
||||
async fn make_community(pool: &PgPool) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("test-{id}.example");
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(host)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert test community");
|
||||
id
|
||||
}
|
||||
|
||||
fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry {
|
||||
NewAuditEntry {
|
||||
community_id: CommunityId::from_uuid(community_id),
|
||||
action,
|
||||
actor_pubkey: Some(vec![0xab; 32]),
|
||||
object_id: Some(format!("obj_{}", Uuid::new_v4())),
|
||||
detail: serde_json::json!({"test": true}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn community_chain_starts_at_seq_1_with_null_prev() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
let e = svc
|
||||
.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(e.seq, 1, "first entry in a community starts at seq 1");
|
||||
assert!(e.prev_hash.is_none(), "genesis entry has NULL prev_hash");
|
||||
assert_eq!(e.hash.len(), 32);
|
||||
assert_eq!(e.community_id, c);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn chain_links_within_one_community() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
let e1 = svc
|
||||
.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e2 = svc
|
||||
.log(new_entry(c, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e3 = svc
|
||||
.log(new_entry(c, AuditAction::MemberAdded))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(e1.seq, 1);
|
||||
assert_eq!(e2.seq, 2);
|
||||
assert_eq!(e3.seq, 3);
|
||||
assert!(e1.prev_hash.is_none());
|
||||
assert_eq!(e2.prev_hash.as_deref(), Some(e1.hash.as_slice()));
|
||||
assert_eq!(e3.prev_hash.as_deref(), Some(e2.hash.as_slice()));
|
||||
assert!(svc
|
||||
.verify_chain(CommunityId::from_uuid(c), 1, 3)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// THE isolation property: two communities keep independent chains. Each
|
||||
/// starts at seq 1; interleaving writes does not link them; verifying one
|
||||
/// never traverses the other.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn chains_are_independent_per_community() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let a = make_community(&pool).await;
|
||||
let b = make_community(&pool).await;
|
||||
|
||||
// Interleave A and B writes.
|
||||
let a1 = svc
|
||||
.log(new_entry(a, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let b1 = svc
|
||||
.log(new_entry(b, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let a2 = svc
|
||||
.log(new_entry(a, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let b2 = svc
|
||||
.log(new_entry(b, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Each community's seq is independent and starts at 1.
|
||||
assert_eq!((a1.seq, a2.seq), (1, 2));
|
||||
assert_eq!((b1.seq, b2.seq), (1, 2));
|
||||
|
||||
// A's chain links only within A; B's only within B. A2 must NOT chain to
|
||||
// B1 even though B1 was written between A1 and A2.
|
||||
assert_eq!(a2.prev_hash.as_deref(), Some(a1.hash.as_slice()));
|
||||
assert_eq!(b2.prev_hash.as_deref(), Some(b1.hash.as_slice()));
|
||||
assert_ne!(a2.prev_hash, b1.prev_hash);
|
||||
|
||||
// Verifying A's chain traverses only A; same for B.
|
||||
assert!(svc
|
||||
.verify_chain(CommunityId::from_uuid(a), 1, 2)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(svc
|
||||
.verify_chain(CommunityId::from_uuid(b), 1, 2)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// get_entries scoped to A returns only A's rows.
|
||||
let a_rows = svc
|
||||
.get_entries(CommunityId::from_uuid(a), 1, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
a_rows.iter().all(|e| e.community_id == a),
|
||||
"A read leaked another community"
|
||||
);
|
||||
assert_eq!(a_rows.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn verify_detects_tampering_within_a_community() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
svc.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e2 = svc
|
||||
.log(new_entry(c, AuditAction::EventDeleted))
|
||||
.await
|
||||
.unwrap();
|
||||
svc.log(new_entry(c, AuditAction::ChannelDeleted))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Tamper with e2's stored actor_pubkey.
|
||||
let tampered: Vec<u8> = vec![0xff; 32];
|
||||
sqlx::query("UPDATE audit_log SET actor_pubkey = $1 WHERE community_id = $2 AND seq = $3")
|
||||
.bind(tampered)
|
||||
.bind(c)
|
||||
.bind(e2.seq)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let r = svc.verify_chain(CommunityId::from_uuid(c), 1, 3).await;
|
||||
assert!(matches!(r, Err(AuditError::HashMismatch { seq }) if seq == e2.seq));
|
||||
}
|
||||
|
||||
/// A row forged with another community's id cannot pass verification against
|
||||
/// the chain it was stamped for, because community_id is hashed in. (Models
|
||||
/// "a row can't be replayed across chains and still verify".)
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn cross_community_row_does_not_verify() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let a = make_community(&pool).await;
|
||||
let b = make_community(&pool).await;
|
||||
|
||||
let a1 = svc
|
||||
.log(new_entry(a, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Forge: copy A's seq-1 row's hash into B's chain at seq 1.
|
||||
sqlx::query(
|
||||
"INSERT INTO audit_log (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at)
|
||||
VALUES ($1, 1, $2, NULL, $3, $4, $5, $6, NOW())",
|
||||
)
|
||||
.bind(b)
|
||||
.bind(&a1.hash) // A's hash, which was computed over community_id = A
|
||||
.bind(a1.action.as_str())
|
||||
.bind(a1.actor_pubkey.as_deref())
|
||||
.bind(a1.object_id.as_deref())
|
||||
.bind(&a1.detail)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verifying B's chain recomputes the hash with community_id = B, which
|
||||
// won't match A's stored hash → HashMismatch. The forge is rejected.
|
||||
let r = svc.verify_chain(CommunityId::from_uuid(b), 1, 1).await;
|
||||
assert!(matches!(r, Err(AuditError::HashMismatch { seq: 1 })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn verify_empty_range_is_false() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
// No entries for this fresh community.
|
||||
assert!(!svc
|
||||
.verify_chain(CommunityId::from_uuid(c), 1, 100)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "buzz-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Authentication and authorization for Buzz"
|
||||
|
||||
[features]
|
||||
test-utils = []
|
||||
dev = []
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Channel access enforcement.
|
||||
//!
|
||||
//! Defines [`ChannelAccessChecker`] so `buzz-auth` can enforce access
|
||||
//! without depending on `buzz-db` directly.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::PublicKey;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AuthError;
|
||||
use crate::scope::Scope;
|
||||
|
||||
/// Async trait for checking channel membership.
|
||||
///
|
||||
/// Implemented by the database layer (`buzz-db`) in production. The `buzz-auth`
|
||||
/// crate defines the trait so it can enforce access rules without a direct dependency
|
||||
/// on `buzz-db`.
|
||||
///
|
||||
/// ## Tenant scoping
|
||||
///
|
||||
/// Every method takes `&TenantContext`. Channel UUIDs are not globally unique under
|
||||
/// multi-tenant — the frozen schema's `channels` PK is `(community_id, id)`, so the
|
||||
/// same UUID can legitimately exist in two communities. A bare `WHERE id = $1`
|
||||
/// implementation would be a cross-community existence oracle and could return
|
||||
/// `true` for a B-community membership when the request bound community is A.
|
||||
/// Implementations MUST scope every query by `ctx.community()` (S1 cross-community
|
||||
/// fence at the access layer).
|
||||
pub trait ChannelAccessChecker: Send + Sync {
|
||||
/// Return the set of channel UUIDs in `ctx`'s community accessible to `pubkey`.
|
||||
///
|
||||
/// Channels in other communities, even with the same UUID, MUST NOT appear.
|
||||
fn accessible_channel_ids(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> impl Future<Output = Result<HashSet<Uuid>, AuthError>> + Send;
|
||||
|
||||
/// Returns `true` if `pubkey` is a member of `(ctx.community, channel_id)`.
|
||||
///
|
||||
/// Default implementation calls [`Self::accessible_channel_ids`] and checks
|
||||
/// membership. Implementations may override this with a more efficient
|
||||
/// scoped point-lookup query.
|
||||
fn can_access(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
channel_id: Uuid,
|
||||
) -> impl Future<Output = Result<bool, AuthError>> + Send {
|
||||
async move {
|
||||
let ids = self.accessible_channel_ids(ctx, pubkey).await?;
|
||||
Ok(ids.contains(&channel_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that `scopes` contains the required scope.
|
||||
pub fn require_scope(scopes: &[Scope], required: Scope) -> Result<(), AuthError> {
|
||||
if scopes.contains(&required) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::InsufficientScope {
|
||||
required: required.as_str().to_string(),
|
||||
have: scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify read access: scope + membership in `ctx`'s community.
|
||||
pub async fn check_read_access(
|
||||
checker: &impl ChannelAccessChecker,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
channel_id: Uuid,
|
||||
scopes: &[Scope],
|
||||
) -> Result<(), AuthError> {
|
||||
require_scope(scopes, Scope::MessagesRead)?;
|
||||
if checker.can_access(ctx, pubkey, channel_id).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::ChannelAccessDenied)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify write access: scope + membership in `ctx`'s community.
|
||||
pub async fn check_write_access(
|
||||
checker: &impl ChannelAccessChecker,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
channel_id: Uuid,
|
||||
scopes: &[Scope],
|
||||
) -> Result<(), AuthError> {
|
||||
require_scope(scopes, Scope::MessagesWrite)?;
|
||||
if checker.can_access(ctx, pubkey, channel_id).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthError::ChannelAccessDenied)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory [`ChannelAccessChecker`] for unit tests.
|
||||
///
|
||||
/// Membership is keyed on the full `(community_id, pubkey, channel_id)` tuple
|
||||
/// so the mock can't accidentally model a non-tenant-scoped checker.
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct MockAccessChecker {
|
||||
allowed: HashSet<(uuid::Uuid, String, Uuid)>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl MockAccessChecker {
|
||||
/// Create an empty checker (all access denied by default).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
allowed: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant `pubkey` access to `channel_id` inside `ctx`'s community.
|
||||
pub fn allow(&mut self, ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid) {
|
||||
self.allowed
|
||||
.insert((*ctx.community().as_uuid(), pubkey.to_hex(), channel_id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl Default for MockAccessChecker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl ChannelAccessChecker for MockAccessChecker {
|
||||
async fn accessible_channel_ids(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> Result<HashSet<Uuid>, AuthError> {
|
||||
let community = *ctx.community().as_uuid();
|
||||
let hex = pubkey.to_hex();
|
||||
Ok(self
|
||||
.allowed
|
||||
.iter()
|
||||
.filter(|(c, pk, _)| *c == community && pk == &hex)
|
||||
.map(|(_, _, id)| *id)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::CommunityId;
|
||||
use nostr::Keys;
|
||||
|
||||
fn fixture_ctx() -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_checker_allow_and_deny() {
|
||||
let ctx = fixture_ctx();
|
||||
let keys = Keys::generate();
|
||||
let pk = keys.public_key();
|
||||
let allowed_ch = Uuid::new_v4();
|
||||
let denied_ch = Uuid::new_v4();
|
||||
|
||||
let mut checker = MockAccessChecker::new();
|
||||
checker.allow(&ctx, &pk, allowed_ch);
|
||||
|
||||
assert!(checker.can_access(&ctx, &pk, allowed_ch).await.unwrap());
|
||||
assert!(!checker.can_access(&ctx, &pk, denied_ch).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_access_denied_by_scope() {
|
||||
let ctx = fixture_ctx();
|
||||
let keys = Keys::generate();
|
||||
let pk = keys.public_key();
|
||||
let ch = Uuid::new_v4();
|
||||
|
||||
let mut checker = MockAccessChecker::new();
|
||||
checker.allow(&ctx, &pk, ch);
|
||||
|
||||
assert!(matches!(
|
||||
check_read_access(&checker, &ctx, &pk, ch, &[]).await,
|
||||
Err(AuthError::InsufficientScope { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_access_denied_by_membership() {
|
||||
let ctx = fixture_ctx();
|
||||
let keys = Keys::generate();
|
||||
let pk = keys.public_key();
|
||||
let ch = Uuid::new_v4();
|
||||
let checker = MockAccessChecker::new();
|
||||
|
||||
assert!(matches!(
|
||||
check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]).await,
|
||||
Err(AuthError::ChannelAccessDenied)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_access_granted() {
|
||||
let ctx = fixture_ctx();
|
||||
let keys = Keys::generate();
|
||||
let pk = keys.public_key();
|
||||
let ch = Uuid::new_v4();
|
||||
|
||||
let mut checker = MockAccessChecker::new();
|
||||
checker.allow(&ctx, &pk, ch);
|
||||
|
||||
assert!(
|
||||
check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead])
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_does_not_cross_communities() {
|
||||
// S1 fence at the access layer: same pubkey, same channel UUID, two
|
||||
// communities. A grant in A MUST NOT show up under B's TenantContext.
|
||||
// This bites the existence-oracle direction a bare `WHERE id=$1`
|
||||
// checker would have left open.
|
||||
let ctx_a = fixture_ctx();
|
||||
let ctx_b = fixture_ctx();
|
||||
let keys = Keys::generate();
|
||||
let pk = keys.public_key();
|
||||
let ch = Uuid::new_v4();
|
||||
|
||||
let mut checker = MockAccessChecker::new();
|
||||
checker.allow(&ctx_a, &pk, ch);
|
||||
|
||||
assert!(checker.can_access(&ctx_a, &pk, ch).await.unwrap());
|
||||
assert!(
|
||||
!checker.can_access(&ctx_b, &pk, ch).await.unwrap(),
|
||||
"access in community A must NOT leak into community B for same (pubkey, channel_id)"
|
||||
);
|
||||
assert!(checker
|
||||
.accessible_channel_ids(&ctx_b, &pk)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Error types for buzz-auth.
|
||||
|
||||
/// All errors that can occur during authentication and authorization.
|
||||
///
|
||||
/// Variants are designed to be safe to return to callers without leaking
|
||||
/// internal implementation details. Do **not** include raw token values,
|
||||
/// database contents, or stack traces in error messages.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
/// The NIP-42 event signature is invalid or the event is structurally malformed.
|
||||
#[error("invalid signature or malformed auth event")]
|
||||
InvalidSignature,
|
||||
|
||||
/// The `challenge` tag in the AUTH event does not match the relay's issued challenge.
|
||||
#[error("challenge mismatch")]
|
||||
ChallengeMismatch,
|
||||
|
||||
/// The `relay` tag in the AUTH event does not match this relay's URL.
|
||||
#[error("relay url mismatch")]
|
||||
RelayUrlMismatch,
|
||||
|
||||
/// The AUTH event's `created_at` timestamp is more than ±60 seconds from now.
|
||||
#[error("auth event timestamp outside ±60s window")]
|
||||
EventExpired,
|
||||
|
||||
/// NIP-98 HTTP Auth event (kind:27235) failed verification.
|
||||
///
|
||||
/// The inner string describes the specific failure (signature, timestamp, URL, etc.)
|
||||
/// and is safe to include in server logs. Do **not** forward raw event content to clients.
|
||||
#[error("NIP-98 HTTP Auth verification failed: {0}")]
|
||||
Nip98Invalid(String),
|
||||
|
||||
/// A NIP-98 event with the same id has already been observed within the
|
||||
/// replay-prevention window. The event itself was structurally valid; the
|
||||
/// rejection is on freshness, not validity.
|
||||
#[error("NIP-98 replay: event id already seen within window")]
|
||||
Nip98Replay,
|
||||
|
||||
/// The pubkey in the auth event does not match the expected identity.
|
||||
#[error("pubkey mismatch: event pubkey does not match authenticated identity")]
|
||||
PubkeyMismatch,
|
||||
|
||||
/// The authenticated context does not have the required scope for this operation.
|
||||
#[error("insufficient scope: required {required}, have {have:?}")]
|
||||
InsufficientScope {
|
||||
/// The scope that was required.
|
||||
required: String,
|
||||
/// The scopes the caller actually holds.
|
||||
have: Vec<String>,
|
||||
},
|
||||
|
||||
/// The authenticated user is not a member of the requested channel.
|
||||
#[error("channel access denied")]
|
||||
ChannelAccessDenied,
|
||||
|
||||
/// An unexpected internal error occurred (e.g. a `spawn_blocking` panic).
|
||||
#[error("internal auth error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! `buzz-auth` — Authentication and authorization for the Buzz relay.
|
||||
//!
|
||||
//! ## Auth paths
|
||||
//!
|
||||
//! | Path | Transport | Description |
|
||||
//! |------|-----------|-------------|
|
||||
//! | NIP-42 | WebSocket | Challenge/response; client signs kind:22242 event |
|
||||
//! | 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 connection.
|
||||
//! - No JWT validation, no token management, no IdP runtime dependency.
|
||||
|
||||
/// Channel access checking trait and helpers.
|
||||
pub mod access;
|
||||
/// Authentication error types.
|
||||
pub mod error;
|
||||
/// NIP-42 challenge–response authentication.
|
||||
pub mod nip42;
|
||||
/// NIP-98 HTTP Auth verification (kind:27235).
|
||||
pub mod nip98;
|
||||
/// NIP-98 replay protection — shared, community-scoped, atomic seen-set.
|
||||
pub mod nip98_replay;
|
||||
/// Per-connection rate limiting.
|
||||
pub mod rate_limit;
|
||||
/// OAuth scope parsing and enforcement.
|
||||
pub mod scope;
|
||||
|
||||
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 nip98_replay::{
|
||||
nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS,
|
||||
MAX_REPLAY_TTL_SECS,
|
||||
};
|
||||
pub use rate_limit::{
|
||||
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
|
||||
};
|
||||
pub use scope::{parse_scopes, Scope};
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub use access::MockAccessChecker;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub use nip98_replay::AlwaysFreshReplayGuard;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub use rate_limit::AlwaysAllowRateLimiter;
|
||||
|
||||
/// How the connection was authenticated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthMethod {
|
||||
/// 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 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>,
|
||||
/// Channel restriction (reserved for future per-channel access control).
|
||||
///
|
||||
/// `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 {
|
||||
/// Returns `true` if this context includes the given [`Scope`].
|
||||
pub fn has_scope(&self, scope: &Scope) -> bool {
|
||||
self.scopes.contains(scope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level authentication configuration, typically loaded from the relay's TOML config file.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
/// Per-user and per-IP rate limit thresholds.
|
||||
#[serde(default)]
|
||||
pub rate_limits: RateLimitConfig,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
/// Create a new `AuthService` with the given configuration.
|
||||
pub fn new(config: AuthConfig) -> Self {
|
||||
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`].
|
||||
///
|
||||
/// 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();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
verify_nip42_event(&event_clone, &challenge_owned, &relay_owned)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::Internal("spawn_blocking panicked".into()))??;
|
||||
|
||||
// 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: auth_event.pubkey,
|
||||
scopes: Scope::all_known(),
|
||||
channel_ids: None,
|
||||
auth_method: AuthMethod::Nip42,
|
||||
agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a deterministic Nostr pubkey from a username string.
|
||||
///
|
||||
/// Uses `SHA-256("buzz-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 usernames to Nostr pubkeys in dev mode.
|
||||
///
|
||||
/// # ⚠️ SECURITY — Dev/test only
|
||||
///
|
||||
/// This function is gated behind `#[cfg(any(test, feature = "dev"))]`
|
||||
/// and **must never be compiled into a production release build**.
|
||||
///
|
||||
/// - The derived keys are deterministic and predictable from the username alone.
|
||||
/// - Any attacker who knows a username can compute the corresponding private key.
|
||||
#[cfg(any(test, feature = "dev"))]
|
||||
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let seed = format!("buzz-test-key:{username}");
|
||||
let hash: [u8; 32] = Sha256::digest(seed.as_bytes()).into();
|
||||
let secret_key = nostr::SecretKey::from_slice(&hash)
|
||||
.map_err(|e| AuthError::Internal(format!("key derivation failed: {e}")))?;
|
||||
Ok(nostr::Keys::new(secret_key).public_key())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, RelayUrl};
|
||||
|
||||
fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> nostr::Event {
|
||||
let url = RelayUrl::parse(relay_url).expect("valid url");
|
||||
EventBuilder::auth(challenge, url)
|
||||
.sign_with_keys(keys)
|
||||
.expect("signing failed")
|
||||
}
|
||||
|
||||
fn test_service() -> AuthService {
|
||||
AuthService::new(AuthConfig::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_context_scope_check() {
|
||||
let keys = Keys::generate();
|
||||
let ctx = AuthContext {
|
||||
pubkey: keys.public_key(),
|
||||
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
|
||||
channel_ids: None,
|
||||
auth_method: AuthMethod::Nip42,
|
||||
agent_owner_pubkey: None,
|
||||
};
|
||||
assert!(ctx.has_scope(&Scope::MessagesRead));
|
||||
assert!(!ctx.has_scope(&Scope::MessagesWrite));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 = test_service()
|
||||
.verify_auth_event(event, &challenge, relay)
|
||||
.await
|
||||
.expect("NIP-42 auth should succeed");
|
||||
|
||||
assert_eq!(ctx.pubkey, keys.public_key());
|
||||
assert_eq!(ctx.auth_method, AuthMethod::Nip42);
|
||||
assert!(ctx.has_scope(&Scope::MessagesRead));
|
||||
assert!(ctx.has_scope(&Scope::MessagesWrite));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_challenge_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = generate_challenge();
|
||||
let relay = "wss://relay.example.com";
|
||||
let event = make_auth_event(&keys, &challenge, relay);
|
||||
|
||||
let result = test_service()
|
||||
.verify_auth_event(event, "wrong-challenge", relay)
|
||||
.await;
|
||||
assert!(matches!(result, Err(AuthError::ChallengeMismatch)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_kind_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "not auth")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
|
||||
let result = test_service()
|
||||
.verify_auth_event(event, &generate_challenge(), "wss://relay.example.com")
|
||||
.await;
|
||||
assert!(matches!(result, Err(AuthError::InvalidSignature)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! NIP-42 challenge/response authentication.
|
||||
//!
|
||||
//! 1. Relay sends `["AUTH", "<challenge>"]` via [`generate_challenge`].
|
||||
//! 2. Client signs a kind:22242 event with challenge + relay tags.
|
||||
//! 3. Relay validates via [`verify_nip42_event`].
|
||||
//!
|
||||
//! AUTH events are **never** stored or logged (may contain bearer tokens).
|
||||
|
||||
use nostr::{Event, Kind, TagKind, Timestamp};
|
||||
use url::Url;
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
/// Normalize a relay URL for comparison.
|
||||
///
|
||||
/// Uses the `url` crate for proper parsing rather than string manipulation.
|
||||
/// Normalizes localhost variants to 127.0.0.1 and strips trailing slashes
|
||||
/// (the `url` crate handles the latter automatically via path normalization).
|
||||
fn normalize_relay_url(raw: &str) -> String {
|
||||
let mut parsed = match Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return raw.to_string(),
|
||||
};
|
||||
// Treat localhost variants as equivalent by normalizing to 127.0.0.1.
|
||||
if let Some(host) = parsed.host_str() {
|
||||
if host == "localhost" || host == "::1" {
|
||||
let _ = parsed.set_host(Some("127.0.0.1"));
|
||||
}
|
||||
}
|
||||
let path = parsed.path().trim_end_matches('/').to_string();
|
||||
parsed.set_path(&path);
|
||||
parsed.to_string()
|
||||
}
|
||||
|
||||
const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
|
||||
|
||||
/// Generate a random NIP-42 challenge (32 CSPRNG bytes, hex-encoded).
|
||||
pub fn generate_challenge() -> String {
|
||||
let bytes: [u8; 32] = rand::random();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Verify a NIP-42 AUTH event.
|
||||
///
|
||||
/// Checks kind, signature, challenge, relay URL, and timestamp (±60s).
|
||||
/// CPU-bound (Schnorr verify) — call via `spawn_blocking` in async contexts.
|
||||
pub fn verify_nip42_event(
|
||||
event: &Event,
|
||||
expected_challenge: &str,
|
||||
relay_url: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
if event.kind != Kind::Authentication {
|
||||
return Err(AuthError::InvalidSignature);
|
||||
}
|
||||
|
||||
buzz_core::verify_event(event).map_err(|_| AuthError::InvalidSignature)?;
|
||||
|
||||
let challenge = event
|
||||
.tags
|
||||
.find(TagKind::Challenge)
|
||||
.and_then(|t| t.content())
|
||||
.ok_or(AuthError::ChallengeMismatch)?;
|
||||
|
||||
if challenge != expected_challenge {
|
||||
return Err(AuthError::ChallengeMismatch);
|
||||
}
|
||||
|
||||
let relay = event
|
||||
.tags
|
||||
.find(TagKind::Relay)
|
||||
.and_then(|t| t.content())
|
||||
.ok_or(AuthError::RelayUrlMismatch)?;
|
||||
|
||||
if normalize_relay_url(relay) != normalize_relay_url(relay_url) {
|
||||
return Err(AuthError::RelayUrlMismatch);
|
||||
}
|
||||
|
||||
let now = Timestamp::now().as_secs();
|
||||
let event_ts = event.created_at.as_secs();
|
||||
let delta = now.abs_diff(event_ts);
|
||||
if delta > TIMESTAMP_TOLERANCE_SECS {
|
||||
return Err(AuthError::EventExpired);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, RelayUrl, Timestamp};
|
||||
|
||||
const TEST_RELAY: &str = "wss://relay.example.com";
|
||||
|
||||
fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> Event {
|
||||
let url = RelayUrl::parse(relay_url).expect("valid relay url");
|
||||
EventBuilder::auth(challenge, url)
|
||||
.sign_with_keys(keys)
|
||||
.expect("signing failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_is_64_hex_chars_and_unique() {
|
||||
let c1 = generate_challenge();
|
||||
let c2 = generate_challenge();
|
||||
assert_eq!(c1.len(), 64);
|
||||
assert!(c1.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_ne!(c1, c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_event_passes() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = generate_challenge();
|
||||
let event = make_auth_event(&keys, &challenge, TEST_RELAY);
|
||||
assert!(verify_nip42_event(&event, &challenge, TEST_RELAY).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_challenge_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = generate_challenge();
|
||||
let event = make_auth_event(&keys, &challenge, TEST_RELAY);
|
||||
assert!(matches!(
|
||||
verify_nip42_event(&event, "wrong", TEST_RELAY),
|
||||
Err(AuthError::ChallengeMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_kind_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "not auth")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
assert!(matches!(
|
||||
verify_nip42_event(&event, "x", TEST_RELAY),
|
||||
Err(AuthError::InvalidSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_event_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = generate_challenge();
|
||||
let url = RelayUrl::parse(TEST_RELAY).unwrap();
|
||||
let old_ts = Timestamp::from(Timestamp::now().as_secs().saturating_sub(120));
|
||||
let event = EventBuilder::auth(&challenge, url)
|
||||
.custom_created_at(old_ts)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
assert!(matches!(
|
||||
verify_nip42_event(&event, &challenge, TEST_RELAY),
|
||||
Err(AuthError::EventExpired)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_relay_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = generate_challenge();
|
||||
let event = make_auth_event(&keys, &challenge, "wss://other.example.com");
|
||||
assert!(matches!(
|
||||
verify_nip42_event(&event, &challenge, TEST_RELAY),
|
||||
Err(AuthError::RelayUrlMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localhost_and_127_are_equivalent() {
|
||||
let a = normalize_relay_url("ws://localhost:3030");
|
||||
let b = normalize_relay_url("ws://127.0.0.1:3030");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_normalized() {
|
||||
let a = normalize_relay_url("wss://relay.example.com/");
|
||||
let b = normalize_relay_url("wss://relay.example.com");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! NIP-98 HTTP Auth verification (kind:27235).
|
||||
//!
|
||||
//! NIP-98 is the standard Nostr HTTP Auth pattern used by Nostr.build, Blossom, and
|
||||
//! other Nostr HTTP services. It is **stateless** — no WebSocket session required.
|
||||
//!
|
||||
//! The client signs a short-lived kind:27235 event containing the target URL, HTTP method,
|
||||
//! and an optional SHA-256 hash of the request body, then sends it as:
|
||||
//!
|
||||
//! ```text
|
||||
//! Authorization: Nostr <base64(JSON-serialized-event)>
|
||||
//! ```
|
||||
//!
|
||||
//! ## Verification steps
|
||||
//!
|
||||
//! 1. Parse JSON into a `nostr::Event`
|
||||
//! 2. Verify `kind == 27235` (`Kind::HttpAuth`)
|
||||
//! 3. Verify Schnorr signature via `buzz_core::verify_event`
|
||||
//! 4. Verify `created_at` within ±60 seconds of server time
|
||||
//! 5. Verify `["u", <url>]` tag matches `expected_url` (normalised: case-insensitive
|
||||
//! scheme/host, trailing slash stripped)
|
||||
//! 6. Verify `["method", <method>]` tag matches `expected_method` (case-insensitive)
|
||||
//! 7. If `["payload", <hash>]` tag is present **and** `body` is `Some`: verify
|
||||
//! `SHA-256(body) == hex(payload_tag)`. This prevents body-substitution attacks.
|
||||
//! 8. Return `event.pubkey` on success.
|
||||
|
||||
use nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, Timestamp};
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
|
||||
|
||||
/// Verify a NIP-98 HTTP Auth event (kind:27235).
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `event_json` — the raw JSON string of the Nostr event (decoded from base64 by the caller).
|
||||
/// - `expected_url` — the canonical URL of the request being authenticated.
|
||||
/// For reverse-proxy deployments, reconstruct from `X-Forwarded-Proto` / `X-Forwarded-Host`
|
||||
/// before passing here.
|
||||
/// - `expected_method` — the HTTP method (e.g. `"POST"`). Compared case-insensitively.
|
||||
/// - `body` — raw request body bytes. If `Some` and a `payload` tag is present in the event,
|
||||
/// the SHA-256 hash of `body` must match the tag value. If `None`, the `payload` tag is
|
||||
/// ignored (clients SHOULD include it for POST requests, but it is not required).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The authenticated `nostr::PublicKey` on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AuthError::Nip98Invalid`] with a descriptive message for any verification failure.
|
||||
/// The message is safe for server logs but should not be forwarded verbatim to clients.
|
||||
pub fn verify_nip98_event(
|
||||
event_json: &str,
|
||||
expected_url: &str,
|
||||
expected_method: &str,
|
||||
body: Option<&[u8]>,
|
||||
) -> Result<nostr::PublicKey, AuthError> {
|
||||
// 1. Parse JSON.
|
||||
let event: Event = serde_json::from_str(event_json)
|
||||
.map_err(|e| AuthError::Nip98Invalid(format!("event JSON parse error: {e}")))?;
|
||||
|
||||
// 2. Verify kind == 27235.
|
||||
if event.kind != Kind::HttpAuth {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"expected kind 27235, got {}",
|
||||
event.kind.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
// 3. Verify Schnorr signature (also verifies event ID hash).
|
||||
buzz_core::verify_event(&event)
|
||||
.map_err(|_| AuthError::Nip98Invalid("invalid Schnorr signature".to_string()))?;
|
||||
|
||||
// 4. Verify created_at within ±60 seconds of now.
|
||||
let now = Timestamp::now().as_secs();
|
||||
let event_ts = event.created_at.as_secs();
|
||||
let delta = now.abs_diff(event_ts);
|
||||
if delta > TIMESTAMP_TOLERANCE_SECS {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"event timestamp outside ±{TIMESTAMP_TOLERANCE_SECS}s window (delta: {delta}s)"
|
||||
)));
|
||||
}
|
||||
|
||||
// 5. Verify `u` tag matches expected_url (normalised).
|
||||
// NIP-98 uses the single-letter "u" tag, not the multi-letter "url" tag.
|
||||
let u_tag = event
|
||||
.tags
|
||||
.find(TagKind::SingleLetter(SingleLetterTag::lowercase(
|
||||
Alphabet::U,
|
||||
)))
|
||||
.and_then(|t| t.content())
|
||||
.ok_or_else(|| AuthError::Nip98Invalid("missing `u` tag".to_string()))?;
|
||||
|
||||
if normalize_url(u_tag) != normalize_url(expected_url) {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"URL mismatch: event has `{u_tag}`, expected `{expected_url}`"
|
||||
)));
|
||||
}
|
||||
|
||||
// 6. Verify `method` tag matches expected_method (case-insensitive).
|
||||
let method_tag = event
|
||||
.tags
|
||||
.find(TagKind::Method)
|
||||
.and_then(|t| t.content())
|
||||
.ok_or_else(|| AuthError::Nip98Invalid("missing `method` tag".to_string()))?;
|
||||
|
||||
if !method_tag.eq_ignore_ascii_case(expected_method) {
|
||||
return Err(AuthError::Nip98Invalid(format!(
|
||||
"method mismatch: event has `{method_tag}`, expected `{expected_method}`"
|
||||
)));
|
||||
}
|
||||
|
||||
// 7. If `payload` tag present AND body is Some: verify SHA-256(body) == payload hex.
|
||||
let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content());
|
||||
|
||||
if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
|
||||
let computed: [u8; 32] = Sha256::digest(body_bytes).into();
|
||||
let computed_hex = hex::encode(computed);
|
||||
if computed_hex != payload_hex {
|
||||
return Err(AuthError::Nip98Invalid(
|
||||
"payload tag SHA-256 mismatch: request body does not match signed hash".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Return the authenticated pubkey.
|
||||
Ok(event.pubkey)
|
||||
}
|
||||
|
||||
/// Normalize a URL for comparison.
|
||||
///
|
||||
/// - Lowercases scheme and host (already done by the `url` crate).
|
||||
/// - Strips trailing slash from path.
|
||||
///
|
||||
/// **No loopback aliasing.** `localhost`, `::1`, and `127.0.0.1` are three
|
||||
/// distinct hosts here. Under multi-tenant the `u`-tag host is the row-zero
|
||||
/// community binding (`docs/multi-tenant-conformance.md`, NIP-98 row): if
|
||||
/// `verify_nip98_event` collapses them, an event signed for `localhost`
|
||||
/// would pass against a `127.0.0.1`-resolved community (or vice versa) —
|
||||
/// a host-binding side door. Tests reconstruct `expected_url` from their
|
||||
/// own bound host, the same shape production does.
|
||||
fn normalize_url(raw: &str) -> String {
|
||||
let mut parsed = match Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return raw.to_lowercase(),
|
||||
};
|
||||
let path = parsed.path().trim_end_matches('/').to_string();
|
||||
parsed.set_path(&path);
|
||||
parsed.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Timestamp};
|
||||
|
||||
const TEST_URL: &str = "https://relay.example.com/api/tokens";
|
||||
const TEST_METHOD: &str = "POST";
|
||||
|
||||
fn make_nip98_event(
|
||||
keys: &Keys,
|
||||
url: &str,
|
||||
method: &str,
|
||||
payload_hex: Option<&str>,
|
||||
created_at: Option<Timestamp>,
|
||||
) -> String {
|
||||
use nostr::Tag;
|
||||
|
||||
let mut tags = vec![
|
||||
Tag::parse(["u", url]).unwrap(),
|
||||
Tag::parse(["method", method]).unwrap(),
|
||||
];
|
||||
if let Some(hex) = payload_hex {
|
||||
tags.push(Tag::parse(["payload", hex]).unwrap());
|
||||
}
|
||||
|
||||
let mut builder = EventBuilder::new(Kind::HttpAuth, "").tags(tags);
|
||||
if let Some(ts) = created_at {
|
||||
builder = builder.custom_created_at(ts);
|
||||
}
|
||||
let event = builder.sign_with_keys(keys).expect("sign");
|
||||
serde_json::to_string(&event).expect("serialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_event_returns_pubkey() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(result.is_ok(), "verify failed: {:?}", result.err());
|
||||
assert_eq!(result.unwrap(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_kind_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_timestamp_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let old_ts = Timestamp::from(Timestamp::now().as_secs().saturating_sub(120));
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, Some(old_ts));
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_mismatch_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(
|
||||
&keys,
|
||||
"https://other.example.com/api/tokens",
|
||||
TEST_METHOD,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_mismatch_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, "GET", None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_case_insensitive() {
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, "post", None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, "POST", None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_tag_correct_hash_passes() {
|
||||
let keys = Keys::generate();
|
||||
let body = b"hello world";
|
||||
let hash: [u8; 32] = Sha256::digest(body).into();
|
||||
let hash_hex = hex::encode(hash);
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&hash_hex), None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_tag_wrong_hash_rejected() {
|
||||
let keys = Keys::generate();
|
||||
let body = b"hello world";
|
||||
let wrong_hex = "deadbeef".repeat(8); // 64 hex chars but wrong hash
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&wrong_hex), None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
|
||||
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_tag_absent_with_body_passes() {
|
||||
// payload tag is optional per spec; clients SHOULD include it but it's not required
|
||||
let keys = Keys::generate();
|
||||
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_normalized() {
|
||||
let keys = Keys::generate();
|
||||
let url_with_slash = "https://relay.example.com/api/tokens/";
|
||||
let json = make_nip98_event(&keys, url_with_slash, TEST_METHOD, None, None);
|
||||
// expected_url without trailing slash — should still match
|
||||
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_aliases_are_distinct_hosts() {
|
||||
// Under multi-tenant, the `u`-tag host is the row-zero community
|
||||
// binding. An event signed for `localhost` MUST NOT pass against an
|
||||
// expected URL on `127.0.0.1` (or `::1`) — collapsing the three would
|
||||
// be a host-check side door. Production reconstructs `expected_url`
|
||||
// from the community-bound host; tests do the same.
|
||||
let keys = Keys::generate();
|
||||
let localhost_url = "http://localhost:3000/api/tokens";
|
||||
let loopback_url = "http://127.0.0.1:3000/api/tokens";
|
||||
let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None);
|
||||
let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
|
||||
assert!(
|
||||
matches!(result, Err(AuthError::Nip98Invalid(_))),
|
||||
"localhost u-tag must NOT match a 127.0.0.1 expected_url; got {result:?}"
|
||||
);
|
||||
|
||||
// Symmetric: signed-for-127.0.0.1 against expected localhost — same answer.
|
||||
let json2 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
|
||||
let result2 = verify_nip98_event(&json2, localhost_url, TEST_METHOD, None);
|
||||
assert!(
|
||||
matches!(result2, Err(AuthError::Nip98Invalid(_))),
|
||||
"127.0.0.1 u-tag must NOT match a localhost expected_url; got {result2:?}"
|
||||
);
|
||||
|
||||
// And identity still holds — same host on both sides verifies.
|
||||
let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
|
||||
assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! NIP-98 replay protection — shared, community-scoped, atomic seen-set.
|
||||
//!
|
||||
//! NIP-98 verification ([`crate::nip98::verify_nip98_event`]) is structurally
|
||||
//! complete: it checks signature, kind, timestamp window, URL, method, and
|
||||
//! optional body hash. It does **not** check whether the same event id has
|
||||
//! already been used — that requires shared state. With multiple relay pods
|
||||
//! ("any pod, any connection" per the rewrite §4 architecture), an in-process
|
||||
//! cache (moka, DashMap) does not carry the freshness proof across pods, so
|
||||
//! replay protection is a §5 hard gate.
|
||||
//!
|
||||
//! The required shape (§5):
|
||||
//!
|
||||
//! - shared state (Redis), atomic set-if-absent, TTL ≥ 120s
|
||||
//! - community-scoped key — see [`nip98_replay_key`]
|
||||
//!
|
||||
//! ## Usage shape
|
||||
//!
|
||||
//! Verify first, then mark. Burning a seen-set slot on a forgery would let an
|
||||
//! attacker who knows a future event id of a victim DoS the legitimate event.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let pubkey = buzz_auth::verify_nip98_event(json, url, method, body)?;
|
||||
//! if !replay.try_mark(&ctx, &event_id, buzz_auth::DEFAULT_REPLAY_TTL_SECS).await? {
|
||||
//! return Err(AuthError::Nip98Replay);
|
||||
//! }
|
||||
//! // safe to honor the request as `pubkey`
|
||||
//! ```
|
||||
//!
|
||||
//! The TTL must cover the verifier's clock-skew tolerance (currently ±60s, so
|
||||
//! the window over which a duplicate event id is even plausible is 2×60 = 120s).
|
||||
//! [`DEFAULT_REPLAY_TTL_SECS`] is the floor; deployments may raise it.
|
||||
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::EventId;
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
/// Floor for the replay-prevention window, in seconds.
|
||||
///
|
||||
/// Matches the §5 gate ("TTL ≥ 120s") and the doubled NIP-98 timestamp
|
||||
/// tolerance (±60s window → 120s span). Implementations MAY use a larger TTL
|
||||
/// for safety margin; they MUST NOT use a smaller one.
|
||||
pub const DEFAULT_REPLAY_TTL_SECS: u64 = 120;
|
||||
|
||||
/// Ceiling for the replay-prevention window, in seconds.
|
||||
///
|
||||
/// Any TTL beyond an hour is implausible for NIP-98 replay protection: the
|
||||
/// verifier only accepts events within ±60s, so a same-id replay is only
|
||||
/// physically possible inside that window plus clock skew. A 1-hour cap is
|
||||
/// 30× the natural maximum and still keeps Redis values well inside
|
||||
/// `i64::MAX` seconds (which Redis `EX` requires). Anything larger reaching
|
||||
/// this code is a config/caller bug; implementations MUST clamp down to it
|
||||
/// rather than admit values that risk Redis `EX` parse failures or
|
||||
/// pathologically long-lived seen-set entries.
|
||||
pub const MAX_REPLAY_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Shared seen-set for NIP-98 event ids, scoped per community.
|
||||
///
|
||||
/// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`).
|
||||
/// A test impl is provided behind `cfg(any(test, feature = "test-utils"))`.
|
||||
pub trait Nip98ReplayGuard: Send + Sync {
|
||||
/// Atomically claim `event_id` in an explicit deployment or community scope.
|
||||
fn try_mark_in_scope<'a>(
|
||||
&'a self,
|
||||
scope: &'a str,
|
||||
event_id: &'a EventId,
|
||||
ttl_secs: u64,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>>;
|
||||
|
||||
/// Atomically claim `event_id` for `ctx`'s community.
|
||||
///
|
||||
/// Returns `Ok(true)` when the id is newly inserted (proceed) and
|
||||
/// `Ok(false)` when an entry already exists (the caller MUST reject the
|
||||
/// request as replay).
|
||||
///
|
||||
/// On `Err` (Redis unreachable, etc.) callers MUST fail closed — reject
|
||||
/// the request rather than admitting it. The shared seen-set is a
|
||||
/// correctness fence; degrading to "best effort, allow on error" forfeits
|
||||
/// the freshness proof.
|
||||
///
|
||||
/// Implementations MUST use an atomic set-if-absent operation; a
|
||||
/// read-then-write sequence loses to concurrent inserts and forfeits the
|
||||
/// freshness proof.
|
||||
///
|
||||
/// `ttl_secs` MUST be at least [`DEFAULT_REPLAY_TTL_SECS`]. Implementations
|
||||
/// MAY clamp a smaller value up to the floor rather than reject; they MUST
|
||||
/// NOT honor it as-given.
|
||||
///
|
||||
/// `ttl_secs` MUST be clamped down to [`MAX_REPLAY_TTL_SECS`] if larger.
|
||||
/// The replay window's natural maximum is the verifier's ±60s tolerance;
|
||||
/// values past an hour are implausible and risk Redis `EX` parse failures
|
||||
/// (Redis interprets `EX` as a signed 64-bit integer).
|
||||
fn try_mark<'a>(
|
||||
&'a self,
|
||||
ctx: &'a TenantContext,
|
||||
event_id: &'a EventId,
|
||||
ttl_secs: u64,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
|
||||
let scope = ctx.community().to_string();
|
||||
Box::pin(async move { self.try_mark_in_scope(&scope, event_id, ttl_secs).await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Redis key for a NIP-98 replay marker:
|
||||
/// `buzz:{community}:nip98:{event_id_hex}`.
|
||||
///
|
||||
/// The community prefix is the S1 isolation fence at the replay layer.
|
||||
/// Event ids are content-addressed (SHA-256 of the canonical event tuple) so
|
||||
/// natural cross-community collision is zero, but the gate is fail-closed
|
||||
/// isolation: a same-id replay across communities must consult two distinct
|
||||
/// seen-set rows, not one shared row.
|
||||
pub fn nip98_replay_key(ctx: &TenantContext, event_id: &EventId) -> String {
|
||||
nip98_replay_key_for_scope(&ctx.community().to_string(), event_id)
|
||||
}
|
||||
|
||||
/// Redis key for a NIP-98 replay marker in an explicit trusted scope.
|
||||
pub fn nip98_replay_key_for_scope(scope: &str, event_id: &EventId) -> String {
|
||||
format!("buzz:{scope}:nip98:{}", event_id.to_hex())
|
||||
}
|
||||
|
||||
/// Always-fresh seen-set for unit tests — every `try_mark` returns `Ok(true)`.
|
||||
///
|
||||
/// Use only in test code that does not exercise the replay path itself.
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct AlwaysFreshReplayGuard;
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl Nip98ReplayGuard for AlwaysFreshReplayGuard {
|
||||
fn try_mark_in_scope<'a>(
|
||||
&'a self,
|
||||
_scope: &'a str,
|
||||
_event_id: &'a EventId,
|
||||
_ttl_secs: u64,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
|
||||
Box::pin(async { Ok(true) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::CommunityId;
|
||||
use nostr::{EventBuilder, Keys, Kind};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn fixture_ctx(host: &str) -> TenantContext {
|
||||
let bytes = Sha256::digest(host.as_bytes());
|
||||
let mut uuid_bytes = [0u8; 16];
|
||||
uuid_bytes.copy_from_slice(&bytes[..16]);
|
||||
let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes));
|
||||
TenantContext::resolved(id, host)
|
||||
}
|
||||
|
||||
fn fixture_event_id() -> EventId {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::HttpAuth, "")
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign")
|
||||
.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_includes_community_prefix() {
|
||||
let ctx = fixture_ctx("relay-a.example");
|
||||
let eid = fixture_event_id();
|
||||
let key = nip98_replay_key(&ctx, &eid);
|
||||
let expected_prefix = format!("buzz:{}:nip98:", ctx.community());
|
||||
assert!(
|
||||
key.starts_with(&expected_prefix),
|
||||
"key {key} should start with {expected_prefix}"
|
||||
);
|
||||
assert!(key.ends_with(&eid.to_hex()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_isolates_communities_for_same_event_id() {
|
||||
// Belt-and-suspenders: even if a same-id event surfaces in two
|
||||
// communities (which content-addressing makes implausible), the
|
||||
// seen-set MUST consult two distinct rows.
|
||||
let eid = fixture_event_id();
|
||||
let ctx_a = fixture_ctx("relay-a.example");
|
||||
let ctx_b = fixture_ctx("relay-b.example");
|
||||
let key_a = nip98_replay_key(&ctx_a, &eid);
|
||||
let key_b = nip98_replay_key(&ctx_b, &eid);
|
||||
assert_ne!(
|
||||
key_a, key_b,
|
||||
"same event id in two communities must not share a seen-set key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_components_are_lowercase() {
|
||||
// Stability/idempotence: if event id hex or community Display ever
|
||||
// started emitting uppercase, a same logical claim would produce two
|
||||
// distinct Redis rows → the seen-set would no longer be a seen-set.
|
||||
let ctx = fixture_ctx("relay-a.example");
|
||||
let eid = fixture_event_id();
|
||||
let key = nip98_replay_key(&ctx, &eid);
|
||||
for c in key.chars() {
|
||||
assert!(
|
||||
!c.is_ascii_uppercase(),
|
||||
"nip98 replay key {key} must be all-lowercase ASCII"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_ttl_meets_gate_floor() {
|
||||
// §5 gate: TTL ≥ 120s. Drift this constant down and the gate breaks.
|
||||
// Const-drift tripwire: the assertion is intentionally over a constant.
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
{
|
||||
assert!(DEFAULT_REPLAY_TTL_SECS >= 120);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_floor_below_ceiling() {
|
||||
// Sanity: any caller's clamped TTL must end up in [DEFAULT, MAX].
|
||||
// If these ever cross, the impl can't satisfy both bounds and the
|
||||
// contract is broken.
|
||||
// Const-drift tripwire: the assertion is intentionally over a constant.
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
{
|
||||
assert!(DEFAULT_REPLAY_TTL_SECS < MAX_REPLAY_TTL_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_ttl_fits_in_redis_signed_ex() {
|
||||
// Redis `EX` is parsed as i64. `MAX_REPLAY_TTL_SECS` must fit so the
|
||||
// clamp itself can't push us into a Redis-side parse failure.
|
||||
assert!(MAX_REPLAY_TTL_SECS <= i64::MAX as u64);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_fresh_returns_true() {
|
||||
let guard = AlwaysFreshReplayGuard;
|
||||
let ctx = fixture_ctx("relay-a.example");
|
||||
let eid = fixture_event_id();
|
||||
assert!(guard
|
||||
.try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Rate limiting types and interface.
|
||||
//!
|
||||
//! Defines the [`RateLimiter`] trait. The Redis-backed implementation lives in
|
||||
//! `buzz-relay` / `buzz-pubsub`. Fixed-window counter algorithm.
|
||||
//!
|
||||
//! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding
|
||||
//! window or token bucket for strict limiting.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::PublicKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
/// The outcome of a rate-limit check, including counter state for response headers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RateLimitResult {
|
||||
/// Whether the request is permitted (`true`) or should be rejected (`false`).
|
||||
pub allowed: bool,
|
||||
/// Current counter value after this increment.
|
||||
pub current: u64,
|
||||
/// The configured limit for this window.
|
||||
pub limit: u64,
|
||||
/// Seconds until the current window resets.
|
||||
pub reset_in_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimitResult {
|
||||
/// Construct an **allowed** result.
|
||||
pub fn allowed(current: u64, limit: u64, reset_in_secs: u64) -> Self {
|
||||
Self {
|
||||
allowed: true,
|
||||
current,
|
||||
limit,
|
||||
reset_in_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a **denied** result.
|
||||
pub fn denied(current: u64, limit: u64, reset_in_secs: u64) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
current,
|
||||
limit,
|
||||
reset_in_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The category of operation being rate-limited.
|
||||
///
|
||||
/// Each variant maps to a distinct Redis key suffix so limits are tracked
|
||||
/// independently per operation type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LimitType {
|
||||
/// Nostr message events (kind:1 etc.) sent via WebSocket.
|
||||
Messages,
|
||||
/// HTTP REST API calls.
|
||||
ApiCalls,
|
||||
/// All WebSocket events (broader than `Messages`).
|
||||
WsEvents,
|
||||
/// Concurrent WebSocket connections from a single IP address.
|
||||
IpConnections,
|
||||
}
|
||||
|
||||
impl LimitType {
|
||||
/// Short suffix used in Redis key construction (e.g. `"msg"`, `"api"`).
|
||||
pub fn key_suffix(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Messages => "msg",
|
||||
Self::ApiCalls => "api",
|
||||
Self::WsEvents => "ws",
|
||||
Self::IpConnections => "conn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tier rate limit thresholds.
|
||||
///
|
||||
/// All values are counts per the relevant time window (per-minute or per-second).
|
||||
/// Loaded from the relay config file; sensible defaults are provided for all fields.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Maximum messages per minute for human users. Default: 60.
|
||||
#[serde(default = "default_human_msg")]
|
||||
pub human_messages_per_min: u64,
|
||||
/// Maximum HTTP API calls per minute for human users. Default: 300.
|
||||
#[serde(default = "default_human_api")]
|
||||
pub human_api_calls_per_min: u64,
|
||||
/// Maximum WebSocket events per second for human users. Default: 10.
|
||||
#[serde(default = "default_human_ws")]
|
||||
pub human_ws_events_per_sec: u64,
|
||||
/// Maximum messages per minute for standard-tier agent tokens. Default: 120.
|
||||
#[serde(default = "default_agent_std_msg")]
|
||||
pub agent_standard_messages_per_min: u64,
|
||||
/// Maximum HTTP API calls per minute for standard-tier agent tokens. Default: 600.
|
||||
#[serde(default = "default_agent_std_api")]
|
||||
pub agent_standard_api_calls_per_min: u64,
|
||||
/// Maximum messages per minute for elevated-tier agent tokens. Default: 300.
|
||||
#[serde(default = "default_agent_elev_msg")]
|
||||
pub agent_elevated_messages_per_min: u64,
|
||||
/// Maximum messages per minute for platform-tier agent tokens. Default: 600.
|
||||
#[serde(default = "default_agent_plat_msg")]
|
||||
pub agent_platform_messages_per_min: u64,
|
||||
}
|
||||
|
||||
fn default_human_msg() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_human_api() -> u64 {
|
||||
300
|
||||
}
|
||||
fn default_human_ws() -> u64 {
|
||||
10
|
||||
}
|
||||
fn default_agent_std_msg() -> u64 {
|
||||
120
|
||||
}
|
||||
fn default_agent_std_api() -> u64 {
|
||||
600
|
||||
}
|
||||
fn default_agent_elev_msg() -> u64 {
|
||||
300
|
||||
}
|
||||
fn default_agent_plat_msg() -> u64 {
|
||||
600
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
human_messages_per_min: default_human_msg(),
|
||||
human_api_calls_per_min: default_human_api(),
|
||||
human_ws_events_per_sec: default_human_ws(),
|
||||
agent_standard_messages_per_min: default_agent_std_msg(),
|
||||
agent_standard_api_calls_per_min: default_agent_std_api(),
|
||||
agent_elevated_messages_per_min: default_agent_elev_msg(),
|
||||
agent_platform_messages_per_min: default_agent_plat_msg(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async rate-limiting interface.
|
||||
///
|
||||
/// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`.
|
||||
/// A no-op `AlwaysAllowRateLimiter` is provided for unit tests.
|
||||
///
|
||||
/// ## Tenant scoping
|
||||
///
|
||||
/// Pubkey-keyed limits ([`check_and_increment`]) take `&TenantContext` and the Redis
|
||||
/// key is community-prefixed (`buzz:{community}:ratelimit:{pubkey}:{suffix}`). The
|
||||
/// same pubkey active in two communities consumes two independent quotas — that is
|
||||
/// the correct behavior under multi-tenant isolation (S1 cross-community fence).
|
||||
///
|
||||
/// IP-keyed limits ([`check_ip_connection`]) are **operator-global** by design. They
|
||||
/// gate connection acceptance at the network edge, before host→community resolution
|
||||
/// has completed (or, on resolve failure, instead of it). Threading `&TenantContext`
|
||||
/// through the connection-rate fence would invert the order of operations. If
|
||||
/// per-(community, IP) caps are ever needed as a tenant-fairness signal, that
|
||||
/// belongs in an additive `LimitType` keyed on `(community, ip)`, not in this trait.
|
||||
///
|
||||
/// ⚠️ The fixed-window algorithm used by the Redis implementation allows up to 2×
|
||||
/// burst at window boundaries. Upgrade to a sliding window or token bucket if strict
|
||||
/// per-second limiting is required.
|
||||
pub trait RateLimiter: Send + Sync {
|
||||
/// Increment the per-(community, pubkey) counter for `limit_type` and return
|
||||
/// whether the request is within `limit` for the given `window_secs`.
|
||||
///
|
||||
/// `ctx` scopes the counter to the resolved community; the same pubkey in two
|
||||
/// communities is two independent quotas.
|
||||
fn check_and_increment(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
limit_type: LimitType,
|
||||
window_secs: u64,
|
||||
limit: u64,
|
||||
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
|
||||
|
||||
/// Increment the per-IP connection counter and return whether the connection
|
||||
/// is within `limit` for the given `window_secs`.
|
||||
///
|
||||
/// Operator-global — see trait docs. This fence runs before / outside of host
|
||||
/// resolution and intentionally does not take a `TenantContext`.
|
||||
fn check_ip_connection(
|
||||
&self,
|
||||
ip: &IpAddr,
|
||||
window_secs: u64,
|
||||
limit: u64,
|
||||
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
|
||||
}
|
||||
|
||||
/// Redis key for pubkey-based rate limit:
|
||||
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`.
|
||||
///
|
||||
/// Community-prefixed: the same pubkey in two communities maps to two distinct
|
||||
/// keys, so quotas don't bleed across the tenancy fence.
|
||||
pub fn rate_limit_key(ctx: &TenantContext, pubkey: &PublicKey, limit_type: &LimitType) -> String {
|
||||
format!(
|
||||
"buzz:{}:ratelimit:{}:{}",
|
||||
ctx.community(),
|
||||
pubkey.to_hex(),
|
||||
limit_type.key_suffix()
|
||||
)
|
||||
}
|
||||
|
||||
/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{ip}:conn`.
|
||||
///
|
||||
/// Operator-global by design — see [`RateLimiter`] docs.
|
||||
pub fn ip_rate_limit_key(ip: &IpAddr) -> String {
|
||||
format!("buzz:ratelimit:ip:{}:conn", ip)
|
||||
}
|
||||
|
||||
/// Always-allow rate limiter for unit tests.
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct AlwaysAllowRateLimiter;
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl RateLimiter for AlwaysAllowRateLimiter {
|
||||
async fn check_and_increment(
|
||||
&self,
|
||||
_ctx: &TenantContext,
|
||||
_pubkey: &PublicKey,
|
||||
_limit_type: LimitType,
|
||||
window_secs: u64,
|
||||
limit: u64,
|
||||
) -> Result<RateLimitResult, AuthError> {
|
||||
Ok(RateLimitResult::allowed(1, limit, window_secs))
|
||||
}
|
||||
|
||||
async fn check_ip_connection(
|
||||
&self,
|
||||
_ip: &IpAddr,
|
||||
window_secs: u64,
|
||||
limit: u64,
|
||||
) -> Result<RateLimitResult, AuthError> {
|
||||
Ok(RateLimitResult::allowed(1, limit, window_secs))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::CommunityId;
|
||||
use nostr::Keys;
|
||||
use sha2::Digest;
|
||||
use std::net::Ipv4Addr;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn fixture_ctx(host: &str) -> TenantContext {
|
||||
// Deterministic community id from host so test assertions can name the prefix.
|
||||
let bytes = sha2::Sha256::digest(host.as_bytes());
|
||||
let mut uuid_bytes = [0u8; 16];
|
||||
uuid_bytes.copy_from_slice(&bytes[..16]);
|
||||
let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes));
|
||||
TenantContext::resolved(id, host)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_key_includes_community_prefix() {
|
||||
let ctx = fixture_ctx("relay-a.example");
|
||||
let keys = Keys::generate();
|
||||
let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages);
|
||||
let expected_prefix = format!("buzz:{}:ratelimit:", ctx.community());
|
||||
assert!(
|
||||
key.starts_with(&expected_prefix),
|
||||
"key {key} should start with {expected_prefix}"
|
||||
);
|
||||
assert!(key.ends_with(":msg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_key_isolates_communities_for_same_pubkey() {
|
||||
// The S1 cross-community isolation fence at the rate-limit key layer:
|
||||
// same pubkey, two communities -> two distinct Redis keys -> independent quotas.
|
||||
let keys = Keys::generate();
|
||||
let ctx_a = fixture_ctx("relay-a.example");
|
||||
let ctx_b = fixture_ctx("relay-b.example");
|
||||
let key_a = rate_limit_key(&ctx_a, &keys.public_key(), &LimitType::Messages);
|
||||
let key_b = rate_limit_key(&ctx_b, &keys.public_key(), &LimitType::Messages);
|
||||
assert_ne!(
|
||||
key_a, key_b,
|
||||
"same pubkey in two communities must not share a rate-limit key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_key_components_are_lowercase() {
|
||||
// Stability/idempotence invariant: if pubkey hex or community Display
|
||||
// ever started emitting uppercase, the same (community, pubkey) would
|
||||
// produce two distinct Redis keys → effective 2× quota. Pin the
|
||||
// lowercase property here so the regression surfaces in unit tests,
|
||||
// not in production traffic.
|
||||
let ctx = fixture_ctx("relay-a.example");
|
||||
let keys = Keys::generate();
|
||||
let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages);
|
||||
for c in key.chars() {
|
||||
assert!(
|
||||
!c.is_ascii_uppercase(),
|
||||
"rate-limit key {key} must be all-lowercase ASCII"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_rate_limit_key_format() {
|
||||
// IP fence stays operator-global — no community in the key.
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
|
||||
assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_allow_limiter() {
|
||||
let limiter = AlwaysAllowRateLimiter;
|
||||
let ctx = fixture_ctx("relay-a.example");
|
||||
let keys = Keys::generate();
|
||||
let result = limiter
|
||||
.check_and_increment(&ctx, &keys.public_key(), LimitType::Messages, 60, 60)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Authorization scopes.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// An authorization scope granted to an authenticated connection or API token.
|
||||
///
|
||||
/// Scopes are stored as `TEXT[]` in the database so new variants can be added
|
||||
/// without schema migrations. Unknown scope strings are preserved via [`Scope::Unknown`]
|
||||
/// to allow forward-compatibility with future scope additions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Scope {
|
||||
/// Read messages from channels the user is a member of.
|
||||
MessagesRead,
|
||||
/// Send messages to channels the user is a member of.
|
||||
MessagesWrite,
|
||||
/// List and read channel metadata.
|
||||
ChannelsRead,
|
||||
/// Create and update channels.
|
||||
ChannelsWrite,
|
||||
/// Administrative channel operations (e.g. delete, force-remove members).
|
||||
AdminChannels,
|
||||
/// Read user profile information.
|
||||
UsersRead,
|
||||
/// Update user profile information.
|
||||
UsersWrite,
|
||||
/// Administrative user operations (e.g. suspend, impersonate).
|
||||
AdminUsers,
|
||||
/// Read background job status.
|
||||
JobsRead,
|
||||
/// Submit and cancel background jobs.
|
||||
JobsWrite,
|
||||
/// Read subscription/plan information.
|
||||
SubscriptionsRead,
|
||||
/// Modify subscription/plan information.
|
||||
SubscriptionsWrite,
|
||||
/// Download files and attachments.
|
||||
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,
|
||||
/// A scope string not recognised by this version of the relay.
|
||||
///
|
||||
/// Preserved as-is to allow forward-compatibility with future scope additions.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Return a `Vec` containing every known scope variant.
|
||||
///
|
||||
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
|
||||
/// auth grants unrestricted access — there is no token to derive scopes from.
|
||||
pub fn all_known() -> Vec<Scope> {
|
||||
vec![
|
||||
Self::MessagesRead,
|
||||
Self::MessagesWrite,
|
||||
Self::ChannelsRead,
|
||||
Self::ChannelsWrite,
|
||||
Self::AdminChannels,
|
||||
Self::UsersRead,
|
||||
Self::UsersWrite,
|
||||
Self::AdminUsers,
|
||||
Self::JobsRead,
|
||||
Self::JobsWrite,
|
||||
Self::SubscriptionsRead,
|
||||
Self::SubscriptionsWrite,
|
||||
Self::FilesRead,
|
||||
Self::FilesWrite,
|
||||
Self::ReposRead,
|
||||
Self::ReposWrite,
|
||||
]
|
||||
}
|
||||
|
||||
/// Return a `Vec` containing every known scope variant except admin scopes.
|
||||
///
|
||||
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header auth grants
|
||||
/// access without a real token. Admin operations (`AdminChannels`, `AdminUsers`) require
|
||||
/// a real token even in dev mode, so they are excluded here.
|
||||
pub fn all_non_admin() -> Vec<Scope> {
|
||||
vec![
|
||||
Self::MessagesRead,
|
||||
Self::MessagesWrite,
|
||||
Self::ChannelsRead,
|
||||
Self::ChannelsWrite,
|
||||
Self::UsersRead,
|
||||
Self::UsersWrite,
|
||||
Self::JobsRead,
|
||||
Self::JobsWrite,
|
||||
Self::SubscriptionsRead,
|
||||
Self::SubscriptionsWrite,
|
||||
Self::FilesRead,
|
||||
Self::FilesWrite,
|
||||
Self::ReposRead,
|
||||
Self::ReposWrite,
|
||||
]
|
||||
}
|
||||
|
||||
/// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::MessagesRead => "messages:read",
|
||||
Self::MessagesWrite => "messages:write",
|
||||
Self::ChannelsRead => "channels:read",
|
||||
Self::ChannelsWrite => "channels:write",
|
||||
Self::AdminChannels => "admin:channels",
|
||||
Self::UsersRead => "users:read",
|
||||
Self::UsersWrite => "users:write",
|
||||
Self::AdminUsers => "admin:users",
|
||||
Self::JobsRead => "jobs:read",
|
||||
Self::JobsWrite => "jobs:write",
|
||||
Self::SubscriptionsRead => "subscriptions:read",
|
||||
Self::SubscriptionsWrite => "subscriptions:write",
|
||||
Self::FilesRead => "files:read",
|
||||
Self::FilesWrite => "files:write",
|
||||
Self::ReposRead => "repos:read",
|
||||
Self::ReposWrite => "repos:write",
|
||||
Self::Unknown(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Scope {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Scope {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match s {
|
||||
"messages:read" => Self::MessagesRead,
|
||||
"messages:write" => Self::MessagesWrite,
|
||||
"channels:read" => Self::ChannelsRead,
|
||||
"channels:write" => Self::ChannelsWrite,
|
||||
"admin:channels" => Self::AdminChannels,
|
||||
"users:read" => Self::UsersRead,
|
||||
"users:write" => Self::UsersWrite,
|
||||
"admin:users" => Self::AdminUsers,
|
||||
"jobs:read" => Self::JobsRead,
|
||||
"jobs:write" => Self::JobsWrite,
|
||||
"subscriptions:read" => Self::SubscriptionsRead,
|
||||
"subscriptions:write" => Self::SubscriptionsWrite,
|
||||
"files:read" => Self::FilesRead,
|
||||
"files:write" => Self::FilesWrite,
|
||||
"repos:read" => Self::ReposRead,
|
||||
"repos:write" => Self::ReposWrite,
|
||||
other => Self::Unknown(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a slice of scope strings into `Vec<Scope>`.
|
||||
pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
|
||||
raw.iter()
|
||||
.map(|s| {
|
||||
s.as_ref()
|
||||
.parse::<Scope>()
|
||||
.expect("infallible: Scope::from_str cannot fail")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
for scope in [Scope::MessagesRead, Scope::AdminChannels, Scope::FilesRead] {
|
||||
let parsed: Scope = scope.as_str().parse().unwrap();
|
||||
assert_eq!(parsed.as_str(), scope.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_scope_preserved() {
|
||||
let scope: Scope = "future:capability".parse().unwrap();
|
||||
assert_eq!(scope.as_str(), "future:capability");
|
||||
assert!(matches!(scope, Scope::Unknown(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_slice() {
|
||||
let scopes = parse_scopes(&["messages:read", "channels:write"]);
|
||||
assert_eq!(scopes, vec![Scope::MessagesRead, Scope::ChannelsWrite]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_non_admin_excludes_admin_scopes() {
|
||||
let scopes = Scope::all_non_admin();
|
||||
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(),
|
||||
14,
|
||||
"all_non_admin() must not contain duplicates"
|
||||
);
|
||||
// Verify no Unknown variants
|
||||
for scope in &scopes {
|
||||
assert!(
|
||||
!matches!(scope, Scope::Unknown(_)),
|
||||
"all_non_admin() must not contain Unknown variants"
|
||||
);
|
||||
}
|
||||
// Verify admin scopes are excluded
|
||||
assert!(
|
||||
!scopes.contains(&Scope::AdminChannels),
|
||||
"all_non_admin() must not contain AdminChannels"
|
||||
);
|
||||
assert!(
|
||||
!scopes.contains(&Scope::AdminUsers),
|
||||
"all_non_admin() must not contain AdminUsers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_known_returns_all_known_variants() {
|
||||
let all = Scope::all_known();
|
||||
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(), 16, "all_known() must not contain duplicates");
|
||||
// Verify no Unknown variants
|
||||
for scope in &all {
|
||||
assert!(
|
||||
!matches!(scope, Scope::Unknown(_)),
|
||||
"all_known() must not contain Unknown variants"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "buzz-backend-kubernetes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-backend-kubernetes"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kube = { workspace = true }
|
||||
k8s-openapi = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
|
||||
# Explicit rustls dep with the ring provider — required to install the
|
||||
# process-level CryptoProvider at startup. Without it this binary panics on its
|
||||
# first TLS connection to the apiserver: the release build compiles every
|
||||
# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies
|
||||
# both ring and aws-lc-rs features and leaves rustls unable to auto-select a
|
||||
# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { workspace = true }
|
||||
@@ -0,0 +1,377 @@
|
||||
//! The deploy state machine (spec §Deploy State Machine), as a pure function.
|
||||
//!
|
||||
//! `classify` maps a verified observation plus the desired create intent to
|
||||
//! one [`Action`]. It performs no I/O, so every row of the spec's table is a
|
||||
//! unit test with no cluster. `reconcile` executes actions and re-enters.
|
||||
//!
|
||||
//! Two invariants are structural rather than remembered:
|
||||
//!
|
||||
//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that
|
||||
//! authorized it. There is no way to build a delete without one, so a later
|
||||
//! helper cannot re-read and silently substitute a fresher fence.
|
||||
//! * The pull-failure classifier ([`PullFailure`]) reaches only
|
||||
//! [`Action::Report`] and [`Action::Observe`]. It is absent from
|
||||
//! `Action::Delete`'s type, so "reason strings are never deletion
|
||||
//! authority" is enforced by the compiler.
|
||||
|
||||
use crate::intent::Fingerprint;
|
||||
|
||||
/// The compare-and-delete fence: UID + resourceVersion from the observation
|
||||
/// that authorized the deletion. A failed precondition means the object
|
||||
/// changed since the read — re-enter, never retry the delete.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fence {
|
||||
pub uid: String,
|
||||
pub resource_version: String,
|
||||
}
|
||||
|
||||
/// Why a pod that never started looks permanently broken. Reporting only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PullFailure {
|
||||
/// Registry auth: a 403/401 `ImagePullBackOff` retries forever without
|
||||
/// ever succeeding, so "the pull retries" is false for this case.
|
||||
Unauthorized,
|
||||
/// The digest or repository does not exist at that registry.
|
||||
ManifestUnknown,
|
||||
/// The image has no variant for the node's architecture.
|
||||
ArchMismatch,
|
||||
}
|
||||
|
||||
/// The container's startup state, already decoded from pod status. Decoding
|
||||
/// happens at the edge so this module stays free of API types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Startup {
|
||||
/// `state.running` — the harness process is up. This, not pod phase, is
|
||||
/// what "live" means.
|
||||
Started,
|
||||
/// Started once and reached a terminal phase (Succeeded/Failed).
|
||||
Terminated,
|
||||
/// Never started, and self-healing is plausible: unschedulable during
|
||||
/// scale-from-zero, an image pull in progress, a transient
|
||||
/// `CreateContainerConfigError` whose Secret exists.
|
||||
NeverStartedRecoverable,
|
||||
/// Never started, and the provider *verified* the cause — not a reason
|
||||
/// string. Either the referenced Secret is confirmed absent by a
|
||||
/// most-recent read, or the image reference is structurally invalid.
|
||||
NeverStartedProvablyBroken,
|
||||
/// Never started; the pull is failing in a way that will not self-heal.
|
||||
/// Still recoverable in the *never delete* sense — this only changes what
|
||||
/// we report and how long we wait.
|
||||
NeverStartedPullFailing(PullFailure),
|
||||
}
|
||||
|
||||
/// A pod that passed identity and ownership verification: label-selected,
|
||||
/// full-pubkey annotation equal to the derived pubkey, management marker
|
||||
/// present. Constructing this type is the verification step's output, so an
|
||||
/// unverified object cannot reach `classify` at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VerifiedPod {
|
||||
pub name: String,
|
||||
pub fence: Fence,
|
||||
/// Set once the apiserver accepts a delete. In Kubernetes there is no
|
||||
/// `Terminating` phase — a pod being gracefully deleted stays in phase
|
||||
/// `Running` for its whole grace period — so this must be checked
|
||||
/// *before* startup state or the dying pod reads as the no-op row.
|
||||
pub deletion_marked: bool,
|
||||
pub startup: Startup,
|
||||
/// The `buzz.block.xyz/create-intent` annotation as recorded at create.
|
||||
/// `None` for a pod written before the annotation existed, which counts
|
||||
/// as divergence.
|
||||
pub recorded_intent: Option<Fingerprint>,
|
||||
}
|
||||
|
||||
/// What the reconciler should do next.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Create the pod, then wait for the harness container to start.
|
||||
Create,
|
||||
/// Compare-and-delete, poll for actual disappearance, then re-enter.
|
||||
Delete { name: String, fence: Fence },
|
||||
/// Wait for a deletion already in flight, then re-enter.
|
||||
AwaitDisappearance { name: String },
|
||||
/// Strict no-op: return this `agent_id`, mutate nothing.
|
||||
NoOp { agent_id: String },
|
||||
/// Keep observing until started or the operation deadline expires; on
|
||||
/// expiry report the latest condition. Never deletes, on this call or any
|
||||
/// later one.
|
||||
Observe { name: String },
|
||||
/// Surface an actionable condition immediately rather than burning the
|
||||
/// deadline on a failure that will not self-heal.
|
||||
Report { name: String, failure: PullFailure },
|
||||
}
|
||||
|
||||
/// Apply the spec's ordered rules to one verified observation.
|
||||
///
|
||||
/// `desired` is the freshly computed create intent; comparison is always
|
||||
/// recorded-annotation vs freshly-computed, never a diff against the live pod
|
||||
/// (admission defaulting would make every pod look divergent).
|
||||
pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action {
|
||||
let Some(pod) = observed else {
|
||||
// Row: no instance → create. First deploy, or after GC.
|
||||
return Action::Create;
|
||||
};
|
||||
|
||||
// Row: deletion-marked, ANY phase. Checked before startup state because
|
||||
// there is no `Terminating` phase to match on — a gracefully deleting pod
|
||||
// reports phase `Running` throughout its grace period, so testing startup
|
||||
// first would mistake it for the live no-op row and return an id that
|
||||
// evaporates.
|
||||
if pod.deletion_marked {
|
||||
return Action::AwaitDisappearance {
|
||||
name: pod.name.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
match &pod.startup {
|
||||
// Row: live and started → strict no-op. Start must never kill a live
|
||||
// agent mid-turn, whatever the fingerprint says.
|
||||
Startup::Started => Action::NoOp {
|
||||
agent_id: pod.name.clone(),
|
||||
},
|
||||
|
||||
// Row: terminated → delete residue, then re-enter to create. This is
|
||||
// the normal restart path — how a user revives a reaped agent.
|
||||
Startup::Terminated => Action::Delete {
|
||||
name: pod.name.clone(),
|
||||
fence: pod.fence.clone(),
|
||||
},
|
||||
|
||||
// Row: never started, provably non-recoverable → fenced replace.
|
||||
// "Provably" means a verified absence or a structural defect, never a
|
||||
// reason string.
|
||||
Startup::NeverStartedProvablyBroken => Action::Delete {
|
||||
name: pod.name.clone(),
|
||||
fence: pod.fence.clone(),
|
||||
},
|
||||
|
||||
// Inside the recoverable row: a pull that will not self-heal is
|
||||
// reported immediately instead of consuming the 600s deadline. This
|
||||
// changes reporting and wait behavior only — no delete authority.
|
||||
Startup::NeverStartedPullFailing(failure) => Action::Report {
|
||||
name: pod.name.clone(),
|
||||
failure: *failure,
|
||||
},
|
||||
|
||||
// Row: never started, recoverable — split on create-intent
|
||||
// divergence. Divergence is evidence of a config change the user is
|
||||
// waiting on, and it is the *only* thing that replaces a
|
||||
// never-started pod. Pod age triggers nothing: any finite age
|
||||
// threshold collides with Cluster Autoscaler's own pod-age delays,
|
||||
// and delete-recreate resets exactly the age it keys on.
|
||||
Startup::NeverStartedRecoverable => {
|
||||
if pod.recorded_intent.as_ref() == Some(desired) {
|
||||
Action::Observe {
|
||||
name: pod.name.clone(),
|
||||
}
|
||||
} else {
|
||||
Action::Delete {
|
||||
name: pod.name.clone(),
|
||||
fence: pod.fence.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fp(seed: &str) -> Fingerprint {
|
||||
Fingerprint::for_test(seed)
|
||||
}
|
||||
|
||||
fn pod(startup: Startup, intent: Option<Fingerprint>) -> VerifiedPod {
|
||||
VerifiedPod {
|
||||
name: "buzz-agent-abc123def456".into(),
|
||||
fence: Fence {
|
||||
uid: "uid-1".into(),
|
||||
resource_version: "rv-1".into(),
|
||||
},
|
||||
deletion_marked: false,
|
||||
startup,
|
||||
recorded_intent: intent,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_instance_creates() {
|
||||
assert_eq!(classify(None, &fp("a")), Action::Create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_pod_is_strict_no_op() {
|
||||
let p = pod(Startup::Started, Some(fp("a")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::NoOp {
|
||||
agent_id: p.name.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The asymmetry the spec states plainly: an edit cannot reach a started
|
||||
/// pod until it exits, but it *can* reach a never-started one — the
|
||||
/// never-started pod is the one the user is editing because it did not
|
||||
/// start.
|
||||
#[test]
|
||||
fn started_pod_no_ops_even_when_intent_diverges() {
|
||||
let p = pod(Startup::Started, Some(fp("old")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("new")),
|
||||
Action::NoOp {
|
||||
agent_id: p.name.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// In Kubernetes a gracefully deleting pod stays in phase `Running`. If
|
||||
/// the deletion mark were checked after startup state, this pod would
|
||||
/// take the no-op row and `deploy` would return an id that evaporates.
|
||||
#[test]
|
||||
fn deletion_mark_beats_every_startup_state() {
|
||||
for startup in [
|
||||
Startup::Started,
|
||||
Startup::Terminated,
|
||||
Startup::NeverStartedRecoverable,
|
||||
Startup::NeverStartedProvablyBroken,
|
||||
Startup::NeverStartedPullFailing(PullFailure::Unauthorized),
|
||||
] {
|
||||
let mut p = pod(startup.clone(), Some(fp("a")));
|
||||
p.deletion_marked = true;
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::AwaitDisappearance {
|
||||
name: p.name.clone()
|
||||
},
|
||||
"deletion mark ignored for {startup:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_pod_is_replaced() {
|
||||
let p = pod(Startup::Terminated, Some(fp("a")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A never-started winner is repairable: pod exists, Secret confirmed
|
||||
/// absent, container never started. A later deploy must delete-recreate
|
||||
/// rather than no-op — the test that pins started-not-phase as the no-op
|
||||
/// criterion.
|
||||
#[test]
|
||||
fn provably_broken_never_started_pod_is_replaced() {
|
||||
let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("a")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The anti-livelock rule: identical desired intent means *never* delete,
|
||||
/// however long the pod has been pending. Age is not an input to this
|
||||
/// function at all, which is the strongest way to say so.
|
||||
#[test]
|
||||
fn recoverable_with_matching_intent_only_observes() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("same")),
|
||||
Action::Observe {
|
||||
name: p.name.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Repeated identical Starts can never delete anything — the same
|
||||
/// classification, arbitrarily many times.
|
||||
#[test]
|
||||
fn repeated_identical_starts_never_delete() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, Some(fp("same")));
|
||||
for _ in 0..100 {
|
||||
assert!(!matches!(
|
||||
classify(Some(&p), &fp("same")),
|
||||
Action::Delete { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The wedge escape: the user corrected a resource request or image, so
|
||||
/// the never-started pod is built from configuration they have since
|
||||
/// changed. Without this row the edit could never materialize.
|
||||
#[test]
|
||||
fn recoverable_with_divergent_intent_is_replaced() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, Some(fp("old")));
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("new")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A pod predating the annotation has no recorded intent — that is
|
||||
/// absence, which the spec groups with divergence.
|
||||
#[test]
|
||||
fn missing_recorded_intent_counts_as_divergence() {
|
||||
let p = pod(Startup::NeverStartedRecoverable, None);
|
||||
assert_eq!(
|
||||
classify(Some(&p), &fp("any")),
|
||||
Action::Delete {
|
||||
name: p.name.clone(),
|
||||
fence: p.fence.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanent-looking pull failures report immediately instead of burning
|
||||
/// 600s — and, critically, never delete.
|
||||
#[test]
|
||||
fn pull_failures_report_and_never_delete() {
|
||||
for failure in [
|
||||
PullFailure::Unauthorized,
|
||||
PullFailure::ManifestUnknown,
|
||||
PullFailure::ArchMismatch,
|
||||
] {
|
||||
let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a")));
|
||||
// Divergent intent too — still no delete from this arm.
|
||||
for desired in [fp("a"), fp("different")] {
|
||||
assert_eq!(
|
||||
classify(Some(&p), &desired),
|
||||
Action::Report {
|
||||
name: p.name.clone(),
|
||||
failure
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every delete carries the fence from the observation that authorized
|
||||
/// it. Exhaustive over the delete-producing states, so a future arm that
|
||||
/// forgets is caught here rather than in a cluster.
|
||||
#[test]
|
||||
fn every_delete_carries_the_authorizing_fence() {
|
||||
let states = [
|
||||
(Startup::Terminated, fp("a")),
|
||||
(Startup::NeverStartedProvablyBroken, fp("a")),
|
||||
(Startup::NeverStartedRecoverable, fp("divergent")),
|
||||
];
|
||||
for (startup, desired) in states {
|
||||
let p = pod(startup, Some(fp("a")));
|
||||
match classify(Some(&p), &desired) {
|
||||
Action::Delete { fence, .. } => assert_eq!(fence, p.fence),
|
||||
other => panic!("expected Delete, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Cluster auth and client construction (spec §Cluster auth,
|
||||
//! `docs/remote-agents.md:985-995`).
|
||||
//!
|
||||
//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`).
|
||||
//! `provider_config` carries `context` and `namespace` only — credentials
|
||||
//! never transit config (I2, `:196-198`).
|
||||
|
||||
use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig};
|
||||
use kube::{Client, Config};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directories prepended to `PATH` before the client is built.
|
||||
///
|
||||
/// Kubeconfigs at Block near-universally authenticate through `exec`
|
||||
/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that
|
||||
/// resolve via `PATH` — and this provider inherits a Finder-launched
|
||||
/// desktop's minimal `PATH`, which contains none of the places those plugins
|
||||
/// install to (`:989-994`).
|
||||
const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
/// Compute the new `PATH` value: plugin directories first, inherited entries
|
||||
/// after, in order. Pure so the ordering can be tested without mutating the
|
||||
/// process's environment.
|
||||
fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option<std::ffi::OsString> {
|
||||
let mut dirs: Vec<PathBuf> = PATH_PREPEND.iter().map(PathBuf::from).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.push(home.join(".local/bin"));
|
||||
}
|
||||
// An empty inherited PATH splits into one empty entry, which POSIX
|
||||
// resolves as the current directory — a place a credential plugin should
|
||||
// never be looked up. Drop empties rather than propagate them.
|
||||
dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty()));
|
||||
std::env::join_paths(dirs).ok()
|
||||
}
|
||||
|
||||
/// Prepend the plugin directories to this process's `PATH`.
|
||||
///
|
||||
/// Modifies the provider's own environment, which is sound here: one process
|
||||
/// per operation, called before any client or task exists, and the child
|
||||
/// processes that read it are exactly the credential plugins this exists for.
|
||||
fn prepend_plugin_path() {
|
||||
let home = std::env::var_os("HOME");
|
||||
let existing = std::env::var_os("PATH").unwrap_or_default();
|
||||
if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) {
|
||||
std::env::set_var("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `command` runnable — an executable on `PATH`, or an existing path?
|
||||
fn resolves_on_path(command: &str) -> bool {
|
||||
if command.contains(std::path::MAIN_SEPARATOR) {
|
||||
return Path::new(command).is_file();
|
||||
}
|
||||
std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The exec plugin the selected context authenticates with, if any.
|
||||
///
|
||||
/// Read from the kubeconfig directly rather than from `Config`, which does not
|
||||
/// expose it. A read failure yields `None`: this lookup exists only to improve
|
||||
/// an error message, and must never be the thing that fails a deploy.
|
||||
fn exec_plugin_for(context: Option<&str>) -> Option<ExecConfig> {
|
||||
let kubeconfig = Kubeconfig::read().ok()?;
|
||||
let context_name = context
|
||||
.map(str::to_string)
|
||||
.or_else(|| kubeconfig.current_context.clone())?;
|
||||
let user_name = kubeconfig
|
||||
.contexts
|
||||
.iter()
|
||||
.find(|c| c.name == context_name)
|
||||
.and_then(|c| c.context.as_ref())
|
||||
.and_then(|c| c.user.clone())?;
|
||||
kubeconfig
|
||||
.auth_infos
|
||||
.iter()
|
||||
.find(|a| a.name == user_name)
|
||||
.and_then(|a| a.auth_info.as_ref())
|
||||
.and_then(|a| a.exec.clone())
|
||||
}
|
||||
|
||||
/// Turn a client-construction failure into an error a user can act on.
|
||||
///
|
||||
/// When the context authenticates through an exec plugin that is not on
|
||||
/// `PATH`, that is almost always the cause, and the actionable fact is the
|
||||
/// plugin's name — not a kube-rs error chain (`:994-995`).
|
||||
fn explain(context: Option<&str>, error: &kube::Error) -> String {
|
||||
if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) {
|
||||
if !resolves_on_path(&command) {
|
||||
return format!(
|
||||
"kubeconfig context {} authenticates with the credential plugin \
|
||||
{command:?}, which is not on PATH. Install it or add its \
|
||||
directory to PATH, then try again.",
|
||||
context.unwrap_or("(current)")
|
||||
);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"could not connect to the cluster using kubeconfig context {}: {error}",
|
||||
context.unwrap_or("(current)")
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a client for the selected context.
|
||||
pub async fn connect(context: Option<&str>) -> Result<Client, String> {
|
||||
prepend_plugin_path();
|
||||
|
||||
let options = KubeConfigOptions {
|
||||
context: context.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
let config = Config::from_kubeconfig(&options).await.map_err(|e| {
|
||||
// A named context that does not exist is a user typo, and the
|
||||
// kube-rs message for it is already specific.
|
||||
format!(
|
||||
"could not load kubeconfig for context {}: {e}",
|
||||
context.unwrap_or("(current)")
|
||||
)
|
||||
})?;
|
||||
|
||||
Client::try_from(config).map_err(|e| explain(context, &e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three plugin directories must end up ahead of the inherited PATH,
|
||||
/// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`.
|
||||
/// Tested on the pure computation: mutating the process PATH here would
|
||||
/// race every other test in the binary.
|
||||
#[test]
|
||||
fn plugin_directories_are_prepended_in_order() {
|
||||
let joined = prepended_path(
|
||||
Some(Path::new("/tmp/fake-home")),
|
||||
std::ffi::OsStr::new("/inherited/bin:/usr/bin"),
|
||||
)
|
||||
.unwrap();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
[
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/tmp/fake-home/.local/bin",
|
||||
"/inherited/bin",
|
||||
"/usr/bin",
|
||||
]
|
||||
.map(PathBuf::from)
|
||||
);
|
||||
}
|
||||
|
||||
/// No `HOME` is not a failure — the two absolute directories still apply.
|
||||
#[test]
|
||||
fn missing_home_still_prepends_the_absolute_directories() {
|
||||
let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from)
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty inherited PATH must not produce an empty entry, which the
|
||||
/// shell and `resolves_on_path` would both read as the cwd.
|
||||
#[test]
|
||||
fn empty_inherited_path_yields_no_empty_entry() {
|
||||
let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap();
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&joined).collect();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_absolute_paths_directly() {
|
||||
assert!(resolves_on_path("/bin/sh"));
|
||||
assert!(!resolves_on_path("/nonexistent/plugin-binary"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! The real [`Substrate`]: kube-rs against a live apiserver.
|
||||
//!
|
||||
//! Everything that *decides* lives in `classify`/`gc`; this module only
|
||||
//! performs I/O and maps apiserver responses onto the trait's vocabulary.
|
||||
//! Three mappings here are normative rather than incidental:
|
||||
//!
|
||||
//! * **409 is discriminated on `Status.reason`, never on the code.** A create
|
||||
//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is
|
||||
//! `Conflict`. Branching on `code == 409` conflates a lost create race with
|
||||
//! a stale fence and is the trap the spec names (`:780-794`).
|
||||
//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"`
|
||||
//! is the cache read, and a confirmed absence from a cache is proof of
|
||||
//! nothing (`:761-769`).
|
||||
//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s
|
||||
//! budget applies. Passing `0` is a force-kill that discards the shutdown
|
||||
//! window the pod declares (`:1185-1189`).
|
||||
|
||||
use crate::classify::Fence;
|
||||
use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate};
|
||||
use chrono::{DateTime, Utc};
|
||||
use k8s_openapi::api::core::v1::{Namespace, Pod, Secret};
|
||||
use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions};
|
||||
use kube::core::ErrorResponse;
|
||||
use kube::{Client, Resource};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `Status.reason` values we branch on. Spelled once so the two 409 arms are
|
||||
/// visibly the same discriminator read two ways.
|
||||
///
|
||||
/// These are wire strings `apimachinery` chooses, not names this crate picks:
|
||||
/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`,
|
||||
/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`.
|
||||
/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no
|
||||
/// upstream constant to bind to and the spelling is pinned by test instead.
|
||||
const REASON_ALREADY_EXISTS: &str = "AlreadyExists";
|
||||
const REASON_CONFLICT: &str = "Conflict";
|
||||
const REASON_NOT_FOUND: &str = "NotFound";
|
||||
const REASON_FORBIDDEN: &str = "Forbidden";
|
||||
|
||||
/// The apiserver-backed substrate for one deploy operation.
|
||||
pub struct Cluster {
|
||||
client: Client,
|
||||
namespace: String,
|
||||
/// Start of *this operation*, for the deadline. Monotonic: the 600s budget
|
||||
/// must not move when the wall clock does.
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
/// The typed API error underneath a `kube::Error`, if it is one.
|
||||
fn api_error(error: &kube::Error) -> Option<&ErrorResponse> {
|
||||
match error {
|
||||
kube::Error::Api(response) => Some(response),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this error carry the given `Status.reason`?
|
||||
fn reason_is(error: &kube::Error, reason: &str) -> bool {
|
||||
api_error(error).is_some_and(|e| e.reason == reason)
|
||||
}
|
||||
|
||||
impl Cluster {
|
||||
pub fn new(client: Client, namespace: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
namespace: namespace.to_string(),
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pods(&self) -> Api<Pod> {
|
||||
Api::namespaced(self.client.clone(), &self.namespace)
|
||||
}
|
||||
|
||||
fn secrets(&self) -> Api<Secret> {
|
||||
Api::namespaced(self.client.clone(), &self.namespace)
|
||||
}
|
||||
|
||||
/// List an object kind through the raw client so the response's HTTP
|
||||
/// `Date` header is reachable.
|
||||
///
|
||||
/// `Api::list` returns only the decoded body, and the apiserver's clock is
|
||||
/// the *only* clock the orphan-Secret age gate may use — a desktop's local
|
||||
/// clock running fast computes every in-flight Secret as expired
|
||||
/// (`:1321-1335`). So the list goes through `Client::send`, which hands
|
||||
/// back the whole `http::Response`.
|
||||
async fn list_with_date<K>(
|
||||
&self,
|
||||
selector: &str,
|
||||
) -> Result<(Vec<K>, Option<DateTime<Utc>>), String>
|
||||
where
|
||||
K: Resource<Scope = k8s_openapi::NamespaceResourceScope>
|
||||
+ Clone
|
||||
+ serde::de::DeserializeOwned
|
||||
+ std::fmt::Debug,
|
||||
K::DynamicType: Default,
|
||||
{
|
||||
let dt = K::DynamicType::default();
|
||||
let url = K::url_path(&dt, Some(&self.namespace));
|
||||
// resourceVersion deliberately unset: quorum read.
|
||||
let params = ListParams {
|
||||
label_selector: Some(selector.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let request = kube::core::Request::new(url)
|
||||
.list(¶ms)
|
||||
.map_err(|e| format!("could not build a list request: {e}"))?;
|
||||
let (parts, body) = request.into_parts();
|
||||
let response = self
|
||||
.client
|
||||
.send(http::Request::from_parts(parts, body.into()))
|
||||
.await
|
||||
.map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?;
|
||||
|
||||
// Parsed before the body is consumed, and independently of it: a
|
||||
// missing or malformed header is not a list failure, it just means the
|
||||
// orphan sweep has no clock and skips.
|
||||
let server_now = response
|
||||
.headers()
|
||||
.get(http::header::DATE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| DateTime::parse_from_rfc2822(v).ok())
|
||||
.map(|v| v.with_timezone(&Utc));
|
||||
|
||||
let bytes = http_body_util::BodyExt::collect(response.into_body())
|
||||
.await
|
||||
.map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))?
|
||||
.to_bytes();
|
||||
let list: kube::core::ObjectList<K> = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?;
|
||||
|
||||
Ok((list.items, server_now))
|
||||
}
|
||||
}
|
||||
|
||||
impl Substrate for Cluster {
|
||||
async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> {
|
||||
let api: Api<Namespace> = Api::all(self.client.clone());
|
||||
if api
|
||||
.get_opt(namespace)
|
||||
.await
|
||||
.map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let spec = Namespace {
|
||||
metadata: kube::core::ObjectMeta {
|
||||
name: Some(namespace.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match api.create(&PostParams::default(), &spec).await {
|
||||
Ok(_) => Ok(()),
|
||||
// Someone else created it between our check and our create. That
|
||||
// is the desired end state, not a failure.
|
||||
Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()),
|
||||
// Namespace-create is frequently denied on shared clusters. Name
|
||||
// the exact command an operator runs, and never silently fall back
|
||||
// to `default` — deploying an agent into someone else's namespace
|
||||
// is worse than refusing (`:1002-1005`).
|
||||
Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!(
|
||||
"not authorized to create namespace {namespace}. Ask a cluster \
|
||||
administrator to run `kubectl create namespace {namespace}`, \
|
||||
then try again."
|
||||
)),
|
||||
Err(e) => Err(format!("could not create namespace {namespace}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_pods(&self, selector: &str) -> Result<(Vec<Pod>, Option<DateTime<Utc>>), String> {
|
||||
self.list_with_date::<Pod>(selector).await
|
||||
}
|
||||
|
||||
async fn list_secrets(&self, selector: &str) -> Result<Vec<Secret>, String> {
|
||||
Ok(self.list_with_date::<Secret>(selector).await?.0)
|
||||
}
|
||||
|
||||
async fn secret_exists(&self, name: &str) -> Result<bool, String> {
|
||||
// `GetParams::default()` leaves resourceVersion unset — the quorum
|
||||
// read this check requires to be proof of anything.
|
||||
match self.secrets().get_with(name, &GetParams::default()).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false),
|
||||
Err(e) => Err(format!("could not check whether secret {name} exists: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_secret(&self, secret: &Secret) -> Result<(), String> {
|
||||
let name = secret.metadata.name.clone().unwrap_or_default();
|
||||
self.secrets()
|
||||
.create(&PostParams::default(), secret)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not create secret {name}: {e}"))
|
||||
}
|
||||
|
||||
async fn create_pod(&self, pod: &Pod) -> Result<CreateOutcome, String> {
|
||||
let name = pod.metadata.name.clone().unwrap_or_default();
|
||||
match self.pods().create(&PostParams::default(), pod).await {
|
||||
Ok(_) => Ok(CreateOutcome::Created),
|
||||
// The deterministic name is taken: a concurrent attempt won the
|
||||
// election. Discriminated on the reason — a 409 whose reason is
|
||||
// `Conflict` is a different condition and must not be read as a
|
||||
// lost race.
|
||||
Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists),
|
||||
Err(e) => Err(format!("could not create pod {name}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_pod(&self, name: &str, fence: &Fence) -> Result<DeleteOutcome, String> {
|
||||
let params = DeleteParams {
|
||||
preconditions: Some(Preconditions {
|
||||
uid: Some(fence.uid.clone()),
|
||||
resource_version: Some(fence.resource_version.clone()),
|
||||
}),
|
||||
// grace_period_seconds deliberately unset: the pod's own 60s
|
||||
// budget applies.
|
||||
..Default::default()
|
||||
};
|
||||
match self.pods().delete(name, ¶ms).await {
|
||||
Ok(_) => Ok(DeleteOutcome::Accepted),
|
||||
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound),
|
||||
// The object changed since the observation that authorized this
|
||||
// delete. Same HTTP code as the create race above, different
|
||||
// reason, different meaning.
|
||||
Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed),
|
||||
Err(e) => Err(format!("could not delete pod {name}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_secret(&self, name: &str) -> Result<(), String> {
|
||||
match self.secrets().delete(name, &DeleteParams::default()).await {
|
||||
Ok(_) => Ok(()),
|
||||
// Already gone is the desired end state.
|
||||
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()),
|
||||
Err(e) => Err(format!("could not delete secret {name}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_pod(&self, name: &str) -> Result<Option<Pod>, String> {
|
||||
match self.pods().get_with(name, &GetParams::default()).await {
|
||||
Ok(pod) => Ok(Some(pod)),
|
||||
Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None),
|
||||
Err(e) => Err(format!("could not read pod {name}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(&self, duration: Duration) {
|
||||
tokio::time::sleep(duration).await;
|
||||
}
|
||||
|
||||
fn elapsed(&self) -> Duration {
|
||||
self.started.elapsed()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::Response;
|
||||
use kube::client::Body;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tower::service_fn;
|
||||
|
||||
fn list_response(date: Option<&str>) -> Response<Body> {
|
||||
let mut response = Response::builder().status(200);
|
||||
if let Some(date) = date {
|
||||
response = response.header(http::header::DATE, date);
|
||||
}
|
||||
response
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"apiVersion": "v1",
|
||||
"kind": "PodList",
|
||||
"metadata": {"resourceVersion": "17"},
|
||||
"items": [{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"metadata": {"name": "sprig"},
|
||||
"spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}
|
||||
}]
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn list_through_real_request_path(
|
||||
date: Option<&'static str>,
|
||||
) -> (Vec<Pod>, Option<DateTime<Utc>>, String) {
|
||||
let observed_uri = Arc::new(Mutex::new(None));
|
||||
let service_uri = Arc::clone(&observed_uri);
|
||||
let service = service_fn(move |request: http::Request<Body>| {
|
||||
let service_uri = Arc::clone(&service_uri);
|
||||
async move {
|
||||
*service_uri.lock().unwrap() = Some(request.uri().to_string());
|
||||
Ok::<_, std::convert::Infallible>(list_response(date))
|
||||
}
|
||||
});
|
||||
let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns");
|
||||
let result = cluster
|
||||
.list_with_date::<Pod>("app.kubernetes.io/managed-by=buzz-backend-kubernetes")
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = observed_uri.lock().unwrap().take().unwrap();
|
||||
(result.0, result.1, uri)
|
||||
}
|
||||
|
||||
/// Exercise the shipped `Request` + `Client::send` seam. A fake
|
||||
/// reconciler would not prove that kube-rs emits a quorum list request or
|
||||
/// that the apiserver's clock survives body decoding.
|
||||
#[tokio::test]
|
||||
async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() {
|
||||
let (pods, server_now, uri) =
|
||||
list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await;
|
||||
|
||||
assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
|
||||
assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig"));
|
||||
assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?"));
|
||||
assert!(
|
||||
uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes")
|
||||
);
|
||||
assert!(
|
||||
!uri.contains("resourceVersion"),
|
||||
"cache read leaked into {uri}"
|
||||
);
|
||||
assert_eq!(
|
||||
server_now.unwrap().to_rfc3339(),
|
||||
"2026-08-02T04:00:00+00:00"
|
||||
);
|
||||
}
|
||||
|
||||
/// Header failure is deliberately not list failure: without a trustworthy
|
||||
/// apiserver clock the orphan sweep skips, but normal reconciliation still
|
||||
/// receives the decoded objects.
|
||||
#[tokio::test]
|
||||
async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() {
|
||||
for date in [Some("not a date"), None] {
|
||||
let (pods, server_now, _) = list_through_real_request_path(date).await;
|
||||
assert_eq!(pods.len(), 1, "fixture must contain one decoded pod");
|
||||
assert!(server_now.is_none(), "unexpected clock for {date:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed apiserver error, as kube-rs surfaces it.
|
||||
fn api(reason: &str, code: u16) -> kube::Error {
|
||||
kube::Error::Api(ErrorResponse {
|
||||
status: "Failure".into(),
|
||||
message: String::new(),
|
||||
reason: reason.into(),
|
||||
code,
|
||||
})
|
||||
}
|
||||
|
||||
/// The one discriminator the whole file rests on, and the trap the spec
|
||||
/// predicts: "an implementation that branches on the code alone will
|
||||
/// eventually take the adoption path on a failed delete or vice versa"
|
||||
/// (`:788-790`).
|
||||
///
|
||||
/// Both of these are 409. Reading the *code* makes them identical; reading
|
||||
/// `Status.reason` keeps a lost create race and a stale fence apart. The
|
||||
/// mutation that must fail this test is `e.reason == …` → `e.code == 409`,
|
||||
/// which no other test in the crate would catch — the fakes never produce
|
||||
/// a real `kube::Error`.
|
||||
#[test]
|
||||
fn the_two_409s_are_never_conflated() {
|
||||
let already_exists = api(REASON_ALREADY_EXISTS, 409);
|
||||
let conflict = api(REASON_CONFLICT, 409);
|
||||
|
||||
assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS));
|
||||
assert!(reason_is(&conflict, REASON_CONFLICT));
|
||||
// The cross terms are the whole point.
|
||||
assert!(!reason_is(&already_exists, REASON_CONFLICT));
|
||||
assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS));
|
||||
}
|
||||
|
||||
/// A transport-level failure is not an apiserver verdict. It must fall
|
||||
/// through to the error arm rather than being read as any reason — a
|
||||
/// connection reset silently classified as `NotFound` would report a pod
|
||||
/// as confirmed-absent, which the classifier treats as proof.
|
||||
#[test]
|
||||
fn a_non_api_error_carries_no_reason() {
|
||||
let transport = kube::Error::LinesCodecMaxLineLengthExceeded;
|
||||
assert!(api_error(&transport).is_none());
|
||||
for reason in [
|
||||
REASON_ALREADY_EXISTS,
|
||||
REASON_CONFLICT,
|
||||
REASON_NOT_FOUND,
|
||||
REASON_FORBIDDEN,
|
||||
] {
|
||||
assert!(!reason_is(&transport, reason), "matched {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `reason` is `#[serde(default)]` in kube-core, so an apiserver that
|
||||
/// omits it yields an empty string. That must match nothing rather than
|
||||
/// matching an empty pattern by accident.
|
||||
#[test]
|
||||
fn an_absent_reason_matches_nothing() {
|
||||
let bare = api("", 409);
|
||||
assert!(!reason_is(&bare, REASON_ALREADY_EXISTS));
|
||||
assert!(!reason_is(&bare, REASON_CONFLICT));
|
||||
}
|
||||
|
||||
/// The consts are the apiserver's spelling, asserted against literals
|
||||
/// rather than against themselves.
|
||||
///
|
||||
/// Every other test here references the consts symbolically on both sides
|
||||
/// — fixture *and* assertion — which is true for any pair of distinct
|
||||
/// values. That tests the discriminator is self-consistent, not that it is
|
||||
/// correct: swapping the two 409 values inverts `AlreadyExists` and
|
||||
/// `Conflict` at a real apiserver (`:788-790`'s failure, reached by
|
||||
/// editing a string instead of a branch) with every other test still
|
||||
/// green. These are `apimachinery`'s wire strings and kube-core exposes no
|
||||
/// constant for them, so a literal is the only external anchor available.
|
||||
/// Found by Quinn's mutation matrix; M2/M3/M4 survived without it.
|
||||
#[test]
|
||||
fn the_reason_consts_are_the_apiservers_spelling() {
|
||||
assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists");
|
||||
assert_eq!(REASON_CONFLICT, "Conflict");
|
||||
assert_eq!(REASON_NOT_FOUND, "NotFound");
|
||||
assert_eq!(REASON_FORBIDDEN, "Forbidden");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! `provider_config` parsing and the `info` config schema
|
||||
//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`).
|
||||
//!
|
||||
//! Nine fields, all optional except `image` (required at parse time; the
|
||||
//! schema offers the published sprig image as a prefill default — §Image).
|
||||
//! No credential field exists, by I2: cluster auth comes from ambient
|
||||
//! kubeconfig resolution and nothing else (`:196-198`).
|
||||
|
||||
use crate::image::{self, ImageRef};
|
||||
|
||||
/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four
|
||||
/// configurable — `cargo build` in an agent workspace makes 500m/1Gi
|
||||
/// unrealistic).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resources {
|
||||
pub cpu_request: String,
|
||||
pub memory_request: String,
|
||||
pub cpu_limit: String,
|
||||
pub memory_limit: String,
|
||||
}
|
||||
|
||||
impl Default for Resources {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu_request: "1".into(),
|
||||
memory_request: "2Gi".into(),
|
||||
cpu_limit: "2".into(),
|
||||
memory_limit: "4Gi".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and
|
||||
/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two.
|
||||
pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200;
|
||||
|
||||
/// Default `image` schema prefill: the published sprig image, in tag+digest
|
||||
/// form so the tag stays human-traceable to its git SHA while the digest does
|
||||
/// the pinning (§Image — tag-only refs are rejected; `image::parse` drops the
|
||||
/// tag on normalization). This is a UI prefill, not a baked fallback: `image`
|
||||
/// stays required, an empty value still fails closed, and the value always
|
||||
/// arrives explicitly in `provider_config`, so create-intent fingerprints are
|
||||
/// unaffected by provider upgrades.
|
||||
pub const DEFAULT_IMAGE: &str = "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76";
|
||||
|
||||
/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening).
|
||||
pub const RUN_AS_UID: i64 = 10001;
|
||||
pub const RUN_AS_GID: i64 = 10001;
|
||||
|
||||
/// Writable workspace root; also `HOME` and the harness's cwd
|
||||
/// (§Working directory).
|
||||
pub const WORKSPACE_PATH: &str = "/home/agent";
|
||||
|
||||
/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum
|
||||
/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain.
|
||||
pub const TERMINATION_GRACE_SECONDS: i64 = 60;
|
||||
|
||||
/// The only restart policy v1 ships. `OnFailure` is double-gated on the
|
||||
/// harness exit-code contract *and* a crash-loop classification row the state
|
||||
/// machine does not have (`:1121-1139`); until both land the provider refuses
|
||||
/// the combination rather than shipping against an undefended convention.
|
||||
pub const RESTART_POLICY: &str = "Never";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderConfig {
|
||||
/// kubeconfig context; `None` uses the current context.
|
||||
pub context: Option<String>,
|
||||
pub namespace: String,
|
||||
pub image: ImageRef,
|
||||
pub resources: Resources,
|
||||
/// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`].
|
||||
pub inactivity_seconds: Option<u64>,
|
||||
pub service_account: Option<String>,
|
||||
}
|
||||
|
||||
/// Read an optional non-empty string field. Rejects non-string scalars rather
|
||||
/// than stringifying them, so a mistyped field is named at the boundary.
|
||||
fn optional_string(cfg: &serde_json::Value, field: &str) -> Result<Option<String>, String> {
|
||||
match cfg.get(field) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())),
|
||||
Some(other) => Err(format!(
|
||||
"provider_config.{field} must be a string, got {other}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional unsigned integer. The desktop's form omits blank numeric
|
||||
/// fields rather than sending `""`, but a hand-crafted payload may send a
|
||||
/// numeric string — accept both, refuse anything else.
|
||||
fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result<Option<u64>, String> {
|
||||
match cfg.get(field) {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
|
||||
format!("provider_config.{field} must be a non-negative integer, got {n}")
|
||||
}),
|
||||
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => s.trim().parse::<u64>().map(Some).map_err(|_| {
|
||||
format!("provider_config.{field} must be a non-negative integer, got {s:?}")
|
||||
}),
|
||||
Some(other) => Err(format!(
|
||||
"provider_config.{field} must be a non-negative integer, got {other}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a
|
||||
/// typo fails with a named field instead of an apiserver rejection partway
|
||||
/// through a deploy.
|
||||
fn valid_namespace(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 63
|
||||
&& name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
pub fn parse(cfg: &serde_json::Value) -> Result<ProviderConfig, String> {
|
||||
if !cfg.is_object() && !cfg.is_null() {
|
||||
return Err("provider_config must be a JSON object".to_string());
|
||||
}
|
||||
|
||||
let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| {
|
||||
"provider_config.namespace is required: the info schema supplies a \
|
||||
generated default, so an empty value means the form was cleared"
|
||||
.to_string()
|
||||
})?;
|
||||
if !valid_namespace(&namespace) {
|
||||
return Err(format!(
|
||||
"provider_config.namespace {namespace:?} is not a valid Kubernetes \
|
||||
namespace (lowercase alphanumerics and '-', ≤63 characters)"
|
||||
));
|
||||
}
|
||||
|
||||
let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?;
|
||||
|
||||
let defaults = Resources::default();
|
||||
let resources = Resources {
|
||||
cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request),
|
||||
memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request),
|
||||
cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit),
|
||||
memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit),
|
||||
};
|
||||
|
||||
// `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop)
|
||||
// meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which
|
||||
// §Pod shape forbids until the harness exit-code contract is pinned AND the
|
||||
// state machine gains a crash-loop row. Refusing the *combination* is what
|
||||
// the spec asks for; silently downgrading to `Never` would ship an
|
||||
// indefinite agent that dies on its first crash.
|
||||
let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? {
|
||||
None => Some(DEFAULT_INACTIVITY_SECONDS),
|
||||
Some(0) => {
|
||||
return Err(
|
||||
"provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \
|
||||
supported in this version: it requires restartPolicy OnFailure, \
|
||||
which is gated on the harness exit-code contract. Set a positive \
|
||||
number of seconds."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
Some(n) => Some(n),
|
||||
};
|
||||
|
||||
Ok(ProviderConfig {
|
||||
context: optional_string(cfg, "context")?,
|
||||
namespace,
|
||||
image,
|
||||
resources,
|
||||
inactivity_seconds,
|
||||
service_account: optional_string(cfg, "service_account")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A fresh `buzz-agents-<rand6>` namespace default.
|
||||
///
|
||||
/// Computed per `info` call, which is how "random default" is satisfied with
|
||||
/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace).
|
||||
pub fn generated_namespace() -> String {
|
||||
use rand::RngExt;
|
||||
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut rng = rand::rng();
|
||||
let suffix: String = (0..6)
|
||||
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
|
||||
.collect();
|
||||
format!("buzz-agents-{suffix}")
|
||||
}
|
||||
|
||||
/// The `config_schema` returned by `info`. Drives the UI form:
|
||||
/// `properties[*].default` prefill, scalar coercion, `required` gating
|
||||
/// (`:407-411`).
|
||||
pub fn config_schema() -> serde_json::Value {
|
||||
let defaults = Resources::default();
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "string",
|
||||
"title": "Kubeconfig context",
|
||||
"description": "Context from your kubeconfig. Leave empty to use the current context."
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"title": "Namespace",
|
||||
"description": "Created if it does not exist.",
|
||||
"default": generated_namespace()
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"title": "Agent image",
|
||||
"description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:<digest>. Tags alone are not accepted: this pod holds the agent's private key.",
|
||||
"default": DEFAULT_IMAGE
|
||||
},
|
||||
"cpu_request": {
|
||||
"type": "string", "title": "CPU request", "default": defaults.cpu_request
|
||||
},
|
||||
"memory_request": {
|
||||
"type": "string", "title": "Memory request", "default": defaults.memory_request
|
||||
},
|
||||
"cpu_limit": {
|
||||
"type": "string", "title": "CPU limit", "default": defaults.cpu_limit
|
||||
},
|
||||
"memory_limit": {
|
||||
"type": "string", "title": "Memory limit", "default": defaults.memory_limit
|
||||
},
|
||||
"inactivity_seconds": {
|
||||
"type": "number",
|
||||
"title": "Stop after inactivity (seconds)",
|
||||
"description": "The agent exits after this long with no work, and can be started again at any time.",
|
||||
"default": DEFAULT_INACTIVITY_SECONDS
|
||||
},
|
||||
"service_account": {
|
||||
"type": "string",
|
||||
"title": "Service account",
|
||||
"description": "Scheduling/RBAC identity only. No API token is mounted."
|
||||
}
|
||||
},
|
||||
"required": ["namespace", "image"]
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn digest_ref() -> String {
|
||||
format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64))
|
||||
}
|
||||
|
||||
fn minimal() -> serde_json::Value {
|
||||
serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_spec_defaults() {
|
||||
let c = parse(&minimal()).unwrap();
|
||||
assert_eq!(c.resources, Resources::default());
|
||||
assert_eq!(c.resources.cpu_request, "1");
|
||||
assert_eq!(c.resources.memory_request, "2Gi");
|
||||
assert_eq!(c.resources.cpu_limit, "2");
|
||||
assert_eq!(c.resources.memory_limit, "4Gi");
|
||||
assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS));
|
||||
assert_eq!(c.context, None);
|
||||
assert_eq!(c.service_account, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_resources_are_configurable() {
|
||||
let mut cfg = minimal();
|
||||
cfg["cpu_request"] = "500m".into();
|
||||
cfg["memory_request"] = "1Gi".into();
|
||||
cfg["cpu_limit"] = "4".into();
|
||||
cfg["memory_limit"] = "8Gi".into();
|
||||
let c = parse(&cfg).unwrap();
|
||||
assert_eq!(
|
||||
c.resources,
|
||||
Resources {
|
||||
cpu_request: "500m".into(),
|
||||
memory_request: "1Gi".into(),
|
||||
cpu_limit: "4".into(),
|
||||
memory_limit: "8Gi".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The desktop's form omits blank numeric fields; a hand-crafted payload
|
||||
/// may send a numeric string. Both must mean the same thing.
|
||||
#[test]
|
||||
fn inactivity_accepts_number_string_and_omission() {
|
||||
let mut cfg = minimal();
|
||||
cfg["inactivity_seconds"] = serde_json::json!(300);
|
||||
assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
|
||||
|
||||
cfg["inactivity_seconds"] = serde_json::json!("300");
|
||||
assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300));
|
||||
|
||||
cfg["inactivity_seconds"] = serde_json::json!("");
|
||||
assert_eq!(
|
||||
parse(&cfg).unwrap().inactivity_seconds,
|
||||
Some(DEFAULT_INACTIVITY_SECONDS)
|
||||
);
|
||||
}
|
||||
|
||||
/// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather
|
||||
/// than silently downgrade — a downgraded agent dies on its first crash
|
||||
/// while the user believes they asked for indefinite.
|
||||
#[test]
|
||||
fn refuses_indefinite_lifetime() {
|
||||
let mut cfg = minimal();
|
||||
cfg["inactivity_seconds"] = serde_json::json!(0);
|
||||
let err = parse(&cfg).unwrap_err();
|
||||
assert!(err.contains("inactivity_seconds"), "got: {err}");
|
||||
assert!(
|
||||
err.contains("OnFailure"),
|
||||
"error should name the gate: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_and_non_numeric_inactivity() {
|
||||
for bad in [
|
||||
serde_json::json!(-1),
|
||||
serde_json::json!(1.5),
|
||||
serde_json::json!("soon"),
|
||||
serde_json::json!(true),
|
||||
] {
|
||||
let mut cfg = minimal();
|
||||
cfg["inactivity_seconds"] = bad.clone();
|
||||
assert!(parse(&cfg).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_is_required_and_must_be_digest_pinned() {
|
||||
let mut cfg = minimal();
|
||||
cfg.as_object_mut().unwrap().remove("image");
|
||||
assert!(parse(&cfg).unwrap_err().contains("provider_config.image"));
|
||||
|
||||
cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into();
|
||||
assert!(parse(&cfg).unwrap_err().contains("digest-pinned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_namespace_names() {
|
||||
for bad in [
|
||||
"",
|
||||
"Buzz-Agents",
|
||||
"-leading",
|
||||
"trailing-",
|
||||
"has_underscore",
|
||||
&"n".repeat(64),
|
||||
] {
|
||||
let mut cfg = minimal();
|
||||
cfg["namespace"] = bad.into();
|
||||
assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// I2 corollary: there is no config path for cluster credentials, so a
|
||||
/// caller that tries to supply one gets no effect from it. Asserting the
|
||||
/// parsed struct has no such field is the closest a test can get to
|
||||
/// "the type makes it impossible".
|
||||
#[test]
|
||||
fn credential_fields_have_no_effect() {
|
||||
let mut cfg = minimal();
|
||||
cfg["token"] = "hunter2".into();
|
||||
cfg["client_key"] = "hunter2".into();
|
||||
let c = parse(&cfg).unwrap();
|
||||
let rendered = format!("{c:?}");
|
||||
assert!(
|
||||
!rendered.contains("hunter2"),
|
||||
"config absorbed a credential: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mistyped_string_fields_are_named() {
|
||||
let mut cfg = minimal();
|
||||
cfg["namespace"] = serde_json::json!(42);
|
||||
assert!(parse(&cfg)
|
||||
.unwrap_err()
|
||||
.contains("provider_config.namespace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_namespaces_are_fresh_and_valid() {
|
||||
let a = generated_namespace();
|
||||
let b = generated_namespace();
|
||||
assert_ne!(a, b, "namespace default is not random");
|
||||
assert!(valid_namespace(&a), "{a} is not a valid namespace");
|
||||
assert!(a.starts_with("buzz-agents-"));
|
||||
assert_eq!(a.len(), "buzz-agents-".len() + 6);
|
||||
}
|
||||
|
||||
/// The schema's own namespace default must be a value the parser accepts —
|
||||
/// otherwise the UI prefills a form that fails on submit.
|
||||
#[test]
|
||||
fn schema_default_namespace_round_trips_through_parse() {
|
||||
let schema = config_schema();
|
||||
let default = schema["properties"]["namespace"]["default"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let cfg = serde_json::json!({"namespace": default, "image": digest_ref()});
|
||||
assert_eq!(parse(&cfg).unwrap().namespace, default);
|
||||
}
|
||||
|
||||
/// Same guarantee for the image prefill: the schema's default must be a
|
||||
/// value `image::parse` accepts, or the UI prefills a form that fails on
|
||||
/// submit. Its tag+digest form normalizes to the tagless canonical form.
|
||||
#[test]
|
||||
fn schema_default_image_round_trips_through_parse() {
|
||||
let schema = config_schema();
|
||||
let default = schema["properties"]["image"]["default"].as_str().unwrap();
|
||||
assert_eq!(default, DEFAULT_IMAGE);
|
||||
let cfg = serde_json::json!({"namespace": "buzz-agents-abc123", "image": default});
|
||||
let parsed = parse(&cfg).unwrap();
|
||||
assert_eq!(
|
||||
parsed.image.as_str(),
|
||||
"ghcr.io/block/buzz-sprig@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the
|
||||
/// count is pinned so a field added without a spec change is caught here.
|
||||
#[test]
|
||||
fn schema_declares_exactly_the_nine_v1_fields() {
|
||||
let schema = config_schema();
|
||||
let props = schema["properties"].as_object().unwrap();
|
||||
let mut keys: Vec<&str> = props.keys().map(String::as_str).collect();
|
||||
keys.sort();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"context",
|
||||
"cpu_limit",
|
||||
"cpu_request",
|
||||
"image",
|
||||
"inactivity_seconds",
|
||||
"memory_limit",
|
||||
"memory_request",
|
||||
"namespace",
|
||||
"service_account"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
schema["required"],
|
||||
serde_json::json!(["namespace", "image"])
|
||||
);
|
||||
}
|
||||
|
||||
/// I2's key lint rejects any field whose word-split contains
|
||||
/// secret|password|token|key|credential. A schema field tripping it would
|
||||
/// make every deploy fail validation desktop-side (`:185-198`).
|
||||
#[test]
|
||||
fn no_schema_field_trips_the_i2_key_lint() {
|
||||
const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"];
|
||||
let schema = config_schema();
|
||||
for field in schema["properties"].as_object().unwrap().keys() {
|
||||
for word in field.split(['_', '-']) {
|
||||
assert!(
|
||||
!BANNED.contains(&word),
|
||||
"field {field:?} contains I2-banned word {word:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
//! Building the pod environment (spec §Launch data, §Entrypoint mapping table).
|
||||
//!
|
||||
//! The three tiers are resolved *here*, before serialization, because a
|
||||
//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if
|
||||
//! two tiers supplied the same key, whichever entry landed in the map would
|
||||
//! win silently. Resolving in-provider makes later-wins explicit and testable.
|
||||
|
||||
use crate::wire::{AgentPayload, LaunchBlock};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Keys the authoritative tier owns.
|
||||
///
|
||||
/// Load-bearing, not documentation: tier 3 *clears* every key on this list
|
||||
/// before writing its own values, so a key the authoritative tier has no value
|
||||
/// for is **removed** rather than left holding a lower-tier value. Plain
|
||||
/// overwrite is not enough — most of these are written conditionally
|
||||
/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty,
|
||||
/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier
|
||||
/// could supply the value for exactly the cases the authoritative tier stays
|
||||
/// silent on. Clearing is also what the local spawn does: the desktop strips
|
||||
/// reserved keys from user env before the authoritative layer is written
|
||||
/// (`env_vars.rs:54-57`), so absent-means-absent in both paths.
|
||||
const AUTHORITATIVE_KEYS: &[&str] = &[
|
||||
"BUZZ_RELAY_URL",
|
||||
"BUZZ_PRIVATE_KEY",
|
||||
"NOSTR_PRIVATE_KEY",
|
||||
"BUZZ_AUTH_TAG",
|
||||
"BUZZ_ACP_AGENT_OWNER",
|
||||
"BUZZ_ACP_AGENT_COMMAND",
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
"BUZZ_ACP_MCP_COMMAND",
|
||||
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
|
||||
START_NONCE_KEY,
|
||||
];
|
||||
|
||||
/// The attempt's generation, as the harness sees it. Also the Secret's name
|
||||
/// suffix — one generation, one identity — so the reconciler restamps this on
|
||||
/// every create attempt rather than letting the caller's value persist across
|
||||
/// a retry.
|
||||
pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE";
|
||||
|
||||
/// Presence is the only remote liveness signal (I3), so a launch that
|
||||
/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key
|
||||
/// collision, there is no "authoritative value" to overwrite it with. Refuse.
|
||||
const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE";
|
||||
|
||||
/// Kubernetes' own cap on the summed value bytes of a Secret
|
||||
/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized
|
||||
/// env surfaces as a named provider error rather than an apiserver rejection
|
||||
/// partway through a deploy.
|
||||
const MAX_SECRET_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
///
|
||||
/// Kubernetes validates Secret *keys* as `IsConfigMapKey`
|
||||
/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key.
|
||||
/// What the kubelet then does with such a key **changed between versions**:
|
||||
/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an
|
||||
/// `InvalidEnvironmentVariableNames` warning event
|
||||
/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter
|
||||
/// is gone (KEP-4369) and the key is injected verbatim. The same manifest
|
||||
/// would silently drop a variable on one cluster and set it on another, so we
|
||||
/// fail closed on the provider side and get one deterministic behavior.
|
||||
fn is_posix_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// An identity component (L1 item 1) is present only if it is nonempty after
|
||||
/// trimming — and the **trimmed form is what gets stored**. The validator and
|
||||
/// the writer must never disagree about the value: a guard that accepts
|
||||
/// `" wss://relay "` and then writes it with the padding intact has only
|
||||
/// moved the failure from a loud refusal to a connect error in the harness.
|
||||
fn identity_component(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
/// The harness's `allowlist` gate mode, spelled as the desktop serializes
|
||||
/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it.
|
||||
const RESPOND_TO_ALLOWLIST: &str = "allowlist";
|
||||
|
||||
/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses
|
||||
/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`).
|
||||
///
|
||||
/// Deliberately the **harness's** four and not the desktop's three: the desktop
|
||||
/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the
|
||||
/// harness starts fine with it. This guard exists to cover non-desktop callers,
|
||||
/// so inheriting a desktop-only narrowing would refuse a launch that works.
|
||||
const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"];
|
||||
|
||||
/// Refuse a respond-to gate the harness will reject at config parse.
|
||||
///
|
||||
/// The local spawn path re-validates this before spawning — "doing it here
|
||||
/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy
|
||||
/// path projects the record's fields straight through. Without this, a gate
|
||||
/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy:
|
||||
/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle
|
||||
/// leaves a Secret the in-call path never reaps (only a later deploy's orphan
|
||||
/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is
|
||||
/// "startup not confirmed", indistinguishable from a slow cluster.
|
||||
///
|
||||
/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`),
|
||||
/// deliberately including their asymmetry: the allowlist is validated **only**
|
||||
/// in allowlist mode, and merely warned about otherwise. Validating it in
|
||||
/// every mode would refuse a deploy whose identical local spawn succeeds —
|
||||
/// and a stale list is already harmless here, since
|
||||
/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears.
|
||||
fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> {
|
||||
// Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at
|
||||
// the harness — a parse failure even earlier than the config errors below.
|
||||
if !RESPOND_TO_MODES.contains(&respond_to) {
|
||||
return Err(format!(
|
||||
"deploy refused: respond_to {respond_to:?} is not a mode the \
|
||||
harness accepts (expected one of {}) — the pod would fail to \
|
||||
parse its arguments, be replaced, and leave a Secret behind on \
|
||||
every attempt",
|
||||
RESPOND_TO_MODES.join(", ")
|
||||
));
|
||||
}
|
||||
if respond_to != RESPOND_TO_ALLOWLIST {
|
||||
return Ok(());
|
||||
}
|
||||
let entries = allowlist.unwrap_or_default();
|
||||
if entries.is_empty() {
|
||||
return Err(format!(
|
||||
"deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \
|
||||
allowlist is empty — the harness refuses this at startup, so the \
|
||||
pod would fail, be replaced, and leave a Secret behind on every \
|
||||
attempt"
|
||||
));
|
||||
}
|
||||
for entry in entries {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!(
|
||||
"deploy refused: invalid pubkey in respond_to_allowlist: \
|
||||
{entry:?} (must be exactly 64 hex characters)"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inputs the provider itself supplies to the authoritative tier.
|
||||
pub struct AuthoritativeInputs<'a> {
|
||||
/// The attempt's generation token — also the Secret's name suffix, so the
|
||||
/// lifecycle correlator and the Secret generation are one identity.
|
||||
pub generation: &'a str,
|
||||
/// Resolved from `provider_config.inactivity_seconds`; `None` when the
|
||||
/// indefinite opt-in was chosen (which this version refuses elsewhere).
|
||||
pub inactivity_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// Resolve the full pod environment.
|
||||
///
|
||||
/// Order is the spec's, and the function body is deliberately three writes in
|
||||
/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather
|
||||
/// than argued.
|
||||
pub fn build_env(
|
||||
agent: &AgentPayload,
|
||||
auth: AuthoritativeInputs<'_>,
|
||||
) -> Result<BTreeMap<String, String>, String> {
|
||||
let default_launch = LaunchBlock::default();
|
||||
let launch = agent.launch.as_ref().unwrap_or(&default_launch);
|
||||
|
||||
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
// Tier 1 — overridable behavior defaults.
|
||||
env.extend(launch.policy_env.clone());
|
||||
|
||||
// Tier 2 — user/layered env. The descriptor already merged
|
||||
// global < persona < agent, so `agent.env_vars` is NOT re-merged on top
|
||||
// (§Launch data tier 2) — doing so would resurrect a layer the desktop
|
||||
// already resolved. When the desktop predates the `launch` block we fall
|
||||
// back to the legacy field, which is the only case it is the truth.
|
||||
if agent.launch.is_some() {
|
||||
env.extend(launch.env.clone());
|
||||
} else {
|
||||
env.extend(agent.env_vars.clone());
|
||||
}
|
||||
|
||||
// Validate what the lower tiers contributed, before the authoritative
|
||||
// tier overwrites any of it. A reserved-key collision is NOT fatal: the
|
||||
// spec's precedence is later-wins, so tier 3 simply overwrites it, which
|
||||
// is exactly what a local spawn does. Only a key that has no
|
||||
// authoritative counterpart to overwrite it — presence suppression — is
|
||||
// a refusal.
|
||||
for key in env.keys() {
|
||||
if !is_posix_env_key(key) {
|
||||
return Err(format!(
|
||||
"env key {key:?} is not a POSIX environment variable name \
|
||||
([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \
|
||||
inconsistently across cluster versions"
|
||||
));
|
||||
}
|
||||
if key.eq_ignore_ascii_case(FORBIDDEN_KEY) {
|
||||
return Err(format!(
|
||||
"{FORBIDDEN_KEY} must not be set on a remote agent: presence \
|
||||
is the only signal that a remote agent is alive"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 — authoritative. Every key it owns is cleared first, then the
|
||||
// values it has are written, so it wins at a key whether or not it has a
|
||||
// value there (see [`AUTHORITATIVE_KEYS`]).
|
||||
for key in AUTHORITATIVE_KEYS {
|
||||
env.remove(*key);
|
||||
}
|
||||
// Identity comes from top-level payload fields, never from `env_vars`
|
||||
// (§Reserved-key rule). All three components must be nonempty: an agent
|
||||
// that cannot reach a relay is the identityless launch L1 item 1 exists to
|
||||
// prevent, and a blank field would otherwise sail through into the Secret
|
||||
// and produce a pod that starts, fails to connect, and looks like a
|
||||
// network problem.
|
||||
let Some(relay_url) = identity_component(&agent.relay_url) else {
|
||||
return Err("deploy refused: relay_url is empty — the agent would have \
|
||||
no relay to connect to"
|
||||
.to_string());
|
||||
};
|
||||
env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string());
|
||||
env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
|
||||
// The git credential/signing helpers read NOSTR_PRIVATE_KEY.
|
||||
env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
|
||||
|
||||
// Owner: at least one of these must resolve, or the harness cannot match
|
||||
// `!shutdown` and §Stop describes a mechanism that does not work.
|
||||
let auth_tag = agent.auth_tag.as_deref().and_then(identity_component);
|
||||
let owner = launch.owner_pubkey.as_deref().and_then(identity_component);
|
||||
match (auth_tag, owner) {
|
||||
(None, None) => {
|
||||
return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \
|
||||
resolved — without an owner the agent cannot honor \
|
||||
!shutdown"
|
||||
.to_string())
|
||||
}
|
||||
(tag, own) => {
|
||||
if let Some(t) = tag {
|
||||
env.insert("BUZZ_AUTH_TAG".into(), t.to_string());
|
||||
}
|
||||
if let Some(o) = own {
|
||||
env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The harness and MCP binaries are resolved against the *image's* PATH.
|
||||
// A host path forwarded from the desktop is guaranteed absent in the
|
||||
// container (§Launch data, host-resolved values).
|
||||
if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) {
|
||||
env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string());
|
||||
}
|
||||
if !launch.args.is_empty() {
|
||||
// Comma-joined because that is what the harness's CLI parser decodes,
|
||||
// and what the desktop's local spawn does. An argument containing a
|
||||
// comma is unrepresentable in both paths; inventing an escaping
|
||||
// scheme here would produce args the harness cannot decode.
|
||||
env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(","));
|
||||
}
|
||||
env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into());
|
||||
|
||||
if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) {
|
||||
validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?;
|
||||
env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string());
|
||||
}
|
||||
if let Some(list) = agent
|
||||
.respond_to_allowlist
|
||||
.as_ref()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(","));
|
||||
}
|
||||
|
||||
if let Some(secs) = auth.inactivity_seconds {
|
||||
env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string());
|
||||
}
|
||||
// The generation token doubles as the lifecycle-frame correlator, so pod
|
||||
// logs and observer frames share one identity (§K8s Secrets).
|
||||
env.insert(START_NONCE_KEY.into(), auth.generation.to_string());
|
||||
|
||||
let total: usize = env.values().map(String::len).sum();
|
||||
if total > MAX_SECRET_BYTES {
|
||||
return Err(format!(
|
||||
"agent environment is {total} bytes; Kubernetes caps Secret data \
|
||||
at {MAX_SECRET_BYTES}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload_json(extra_agent: serde_json::Value) -> AgentPayload {
|
||||
let mut agent = serde_json::json!({
|
||||
"name": "a",
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1example",
|
||||
"auth_tag": "tag-1",
|
||||
});
|
||||
let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) =
|
||||
(&mut agent, extra_agent)
|
||||
else {
|
||||
panic!("expected objects")
|
||||
};
|
||||
base.extend(extra);
|
||||
serde_json::from_value(agent).unwrap()
|
||||
}
|
||||
|
||||
fn build(agent: &AgentPayload) -> Result<BTreeMap<String, String>, String> {
|
||||
build_env(
|
||||
agent,
|
||||
AuthoritativeInputs {
|
||||
generation: "gen0001",
|
||||
inactivity_seconds: Some(7200),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_comes_from_top_level_fields() {
|
||||
let env = build(&payload_json(serde_json::json!({}))).unwrap();
|
||||
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
|
||||
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
|
||||
}
|
||||
|
||||
/// Wren's amendment, and the spec's later-wins rule: a lower tier that
|
||||
/// spoofs an authoritative key is *overwritten*, not refused. Refusing
|
||||
/// would diverge from the local spawn, where the same env is written
|
||||
/// before the authoritative layer and simply loses.
|
||||
#[test]
|
||||
fn lower_tiers_cannot_spoof_authoritative_values() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {
|
||||
"command": "goose",
|
||||
"policy_env": {
|
||||
"BUZZ_PRIVATE_KEY": "nsec1attacker",
|
||||
"BUZZ_MANAGED_AGENT_START_NONCE": "forged",
|
||||
},
|
||||
"env": {
|
||||
"BUZZ_RELAY_URL": "wss://attacker.example",
|
||||
"NOSTR_PRIVATE_KEY": "nsec1attacker",
|
||||
"BUZZ_AUTH_TAG": "forged-tag",
|
||||
"BUZZ_ACP_AGENT_OWNER": "cafe",
|
||||
"BUZZ_ACP_AGENT_COMMAND": "/bin/sh",
|
||||
"BUZZ_ACP_MCP_COMMAND": "/bin/sh",
|
||||
"BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0",
|
||||
},
|
||||
"owner_pubkey": "beef"
|
||||
}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
|
||||
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
|
||||
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef");
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose");
|
||||
assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp");
|
||||
assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200");
|
||||
assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001");
|
||||
}
|
||||
|
||||
/// Tier 1 is *overridable* — user env beats policy defaults, matching the
|
||||
/// local spawn, where the user layer is written after them. Getting this
|
||||
/// backwards would make remote agents ignore overrides local agents honor.
|
||||
#[test]
|
||||
fn user_env_overrides_policy_defaults() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {
|
||||
"policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"},
|
||||
"env": {"GOOSE_MODE": "chat"},
|
||||
"owner_pubkey": "beef"
|
||||
}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["GOOSE_MODE"], "chat");
|
||||
assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet");
|
||||
}
|
||||
|
||||
/// `launch.env` already contains the merged user env, so re-merging the
|
||||
/// legacy field would undo a layering the desktop already resolved.
|
||||
#[test]
|
||||
fn legacy_env_vars_are_not_remerged_when_launch_present() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"env_vars": {"STALE": "yes", "SHARED": "legacy"},
|
||||
"launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["SHARED"], "resolved");
|
||||
assert!(!env.contains_key("STALE"), "legacy env_vars re-merged");
|
||||
}
|
||||
|
||||
/// ...but a desktop predating the `launch` block has nothing else to
|
||||
/// offer, so the legacy field is the truth in exactly that case.
|
||||
#[test]
|
||||
fn legacy_env_vars_used_when_launch_absent() {
|
||||
let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["API"], "v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_when_no_owner_resolves() {
|
||||
let agent = payload_json(serde_json::json!({"auth_tag": null}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(err.contains("!shutdown"), "unhelpful error: {err}");
|
||||
}
|
||||
|
||||
/// An empty string is not an owner. Without this the refusal is
|
||||
/// bypassable by a blank field and the pod launches unable to be stopped.
|
||||
#[test]
|
||||
fn empty_owner_fields_count_as_absent() {
|
||||
for blank in ["", " "] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"auth_tag": blank,
|
||||
"launch": {"owner_pubkey": blank}
|
||||
}));
|
||||
assert!(
|
||||
build(&agent).is_err(),
|
||||
"whitespace resolved as an owner: {blank:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half of every identity guard: what is *stored*. A validator
|
||||
/// that trims and a writer that doesn't disagree about the value, and the
|
||||
/// padding reaches the harness inside the Secret. Assert on the stored
|
||||
/// string — asserting only that the deploy was accepted passes either way.
|
||||
#[test]
|
||||
fn identity_components_are_stored_trimmed() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"relay_url": " wss://relay.example ",
|
||||
"auth_tag": " tag-1 ",
|
||||
"launch": {"owner_pubkey": " beefcafe "}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
|
||||
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
|
||||
}
|
||||
|
||||
/// L1 item 1's third identity component. The nsec arm is enforced in
|
||||
/// `naming.rs` and the owner arm above; without this one an agent
|
||||
/// deploys with nothing to connect to — a pod that starts, fails at the
|
||||
/// relay, and reads as a network fault rather than a refused launch.
|
||||
#[test]
|
||||
fn refuses_empty_relay_url() {
|
||||
for blank in ["", " "] {
|
||||
let agent = payload_json(serde_json::json!({"relay_url": blank}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(err.contains("relay_url"), "unhelpful error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_pubkey_alone_is_sufficient() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"auth_tag": null,
|
||||
"launch": {"owner_pubkey": "beefcafe"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
|
||||
assert!(!env.contains_key("BUZZ_AUTH_TAG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_presence_suppression() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"}
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}");
|
||||
}
|
||||
|
||||
/// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30
|
||||
/// kubelets drop it, 1.30+ inject it. Refuse rather than behave
|
||||
/// differently depending on the cluster.
|
||||
#[test]
|
||||
fn refuses_non_posix_env_keys() {
|
||||
for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"env": {bad: "v"}, "owner_pubkey": "beef"}
|
||||
}));
|
||||
assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_are_comma_joined_and_omitted_when_empty() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session");
|
||||
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {"command": "goose", "args": [], "owner_pubkey": "b"}
|
||||
}));
|
||||
assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS"));
|
||||
}
|
||||
|
||||
/// The top-level `model`/`provider` fields are display inputs; their
|
||||
/// environment consequence is per-runtime and arrives already resolved
|
||||
/// inside `launch`. A provider-side mapping is wrong for three of the
|
||||
/// four built-in runtimes.
|
||||
#[test]
|
||||
fn provider_never_maps_model_or_provider_itself() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"model": "claude-opus", "provider": "anthropic",
|
||||
"launch": {"owner_pubkey": "beef"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
for key in [
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
"BUZZ_AGENT_MODEL",
|
||||
"GOOSE_PROVIDER",
|
||||
"GOOSE_MODEL",
|
||||
] {
|
||||
assert!(!env.contains_key(key), "provider mapped {key} itself");
|
||||
}
|
||||
}
|
||||
|
||||
/// `turn_timeout_seconds` is deprecated and ignored upstream; the local
|
||||
/// spawn does not emit it either.
|
||||
#[test]
|
||||
fn turn_timeout_is_not_mapped() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactivity_omitted_when_unset() {
|
||||
let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}}));
|
||||
let env = build_env(
|
||||
&agent,
|
||||
AuthoritativeInputs {
|
||||
generation: "g",
|
||||
inactivity_seconds: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY"));
|
||||
}
|
||||
|
||||
/// Structural guard over the whole authoritative list at once: whatever
|
||||
/// the lower tiers contain, no authoritative key holds a lower-tier value
|
||||
/// — including the conditionally-written ones the authoritative tier has
|
||||
/// nothing to say about, which must be **absent** rather than spoofed.
|
||||
/// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written
|
||||
/// when `launch.args` is non-empty, so plain later-wins overwrite left the
|
||||
/// spoofed value in place.
|
||||
#[test]
|
||||
fn no_authoritative_key_retains_a_lower_tier_value() {
|
||||
let spoofed: serde_json::Map<String, serde_json::Value> = AUTHORITATIVE_KEYS
|
||||
.iter()
|
||||
.map(|k| ((*k).to_string(), serde_json::json!("SPOOFED")))
|
||||
.collect();
|
||||
// Split across both lower tiers: policy_env and env are separate
|
||||
// insertion points, and a fix that only cleared one would pass a
|
||||
// single-tier test.
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"launch": {
|
||||
"command": "goose",
|
||||
"policy_env": spoofed.clone(),
|
||||
"env": spoofed,
|
||||
"owner_pubkey": "beef"
|
||||
}
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
for key in AUTHORITATIVE_KEYS {
|
||||
assert_ne!(
|
||||
env.get(*key).map(String::as_str),
|
||||
Some("SPOOFED"),
|
||||
"{key} kept its lower-tier value"
|
||||
);
|
||||
}
|
||||
// The keys the authoritative tier had no value for are gone, not
|
||||
// merely different.
|
||||
for absent in [
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
] {
|
||||
assert!(!env.contains_key(absent), "{absent} survived the clear");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-hex pubkey, the only allowlist entry shape the harness accepts.
|
||||
fn pubkey(fill: char) -> String {
|
||||
std::iter::repeat_n(fill, 64).collect()
|
||||
}
|
||||
|
||||
/// The gate the harness refuses first (`config.rs:996-1004`). Refusing it
|
||||
/// here is the difference between one error message and an unbounded
|
||||
/// fail-replace loop that leaves a Secret per attempt.
|
||||
#[test]
|
||||
fn allowlist_mode_with_an_empty_list_is_refused() {
|
||||
for empty in [serde_json::json!([]), serde_json::Value::Null] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": empty,
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("the allowlist is empty"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `config.rs:629-641` — each entry must be exactly 64 hex characters.
|
||||
/// The rejects are the distinct ways to miss that: too short, right length
|
||||
/// but not hex, empty, and one character short of valid.
|
||||
#[test]
|
||||
fn an_allowlist_entry_that_is_not_64_hex_is_refused() {
|
||||
for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": [pubkey('a'), bad],
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("must be exactly 64 hex characters"),
|
||||
"{bad:?} was accepted; error was: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The positive control: the guard refuses bad gates, not every gate.
|
||||
/// Without this, a validator that refused unconditionally would pass both
|
||||
/// tests above.
|
||||
#[test]
|
||||
fn a_valid_allowlist_gate_is_accepted_and_comma_joined() {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": [pubkey('a'), pubkey('b')],
|
||||
}));
|
||||
let env = build(&agent).unwrap();
|
||||
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist");
|
||||
assert_eq!(
|
||||
env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"],
|
||||
format!("{},{}", pubkey('a'), pubkey('b'))
|
||||
);
|
||||
}
|
||||
|
||||
/// The harness validates the allowlist **only** in allowlist mode and
|
||||
/// merely warns otherwise (`config.rs:1005-1010`). A stricter provider
|
||||
/// would refuse a deploy whose identical local spawn succeeds, so this
|
||||
/// pins the asymmetry rather than leaving it to look like an oversight.
|
||||
#[test]
|
||||
fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() {
|
||||
for mode in ["owner-only", "anyone"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": mode,
|
||||
"respond_to_allowlist": ["not-a-pubkey"],
|
||||
}));
|
||||
let env = build(&agent)
|
||||
.unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}"));
|
||||
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum`
|
||||
/// at the harness, so an unrecognized mode dies at `rc=2` — before config
|
||||
/// parsing runs at all, earlier than either refusal above. Measured
|
||||
/// against the built binary: `invalid value 'npub1abc' for '--respond-to'`.
|
||||
/// This is the shape our own fixture carried until it was corrected.
|
||||
#[test]
|
||||
fn a_mode_the_harness_cannot_parse_is_refused() {
|
||||
for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] {
|
||||
let agent = payload_json(serde_json::json!({ "respond_to": bad }));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("is not a mode the harness accepts"),
|
||||
"{bad:?} was accepted; error was: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `clap` does not trim its value-enum input, so a padded mode is `rc=2`
|
||||
/// even though the same string trimmed is valid. Measured: `invalid value
|
||||
/// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy
|
||||
/// the harness refuses — the exact direction this guard exists to prevent.
|
||||
#[test]
|
||||
fn a_padded_mode_is_refused_because_clap_does_not_trim() {
|
||||
for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": padded,
|
||||
"respond_to_allowlist": [pubkey('a')],
|
||||
}));
|
||||
let err = build(&agent).unwrap_err();
|
||||
assert!(
|
||||
err.contains("is not a mode the harness accepts"),
|
||||
"{padded:?} was accepted; error was: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Positive control for the mode check, and the reason it validates the
|
||||
/// harness's four rather than the desktop's three: `nobody` is rejected by
|
||||
/// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts
|
||||
/// fine at the harness. A guard mirroring the desktop enum would refuse a
|
||||
/// working launch from a non-desktop caller — the callers this guard is
|
||||
/// for. Without this test, refusing `nobody` would pass everything above.
|
||||
#[test]
|
||||
fn every_mode_the_harness_accepts_is_deployable() {
|
||||
for mode in ["owner-only", "allowlist", "anyone", "nobody"] {
|
||||
let agent = payload_json(serde_json::json!({
|
||||
"respond_to": mode,
|
||||
"respond_to_allowlist": [pubkey('a')],
|
||||
}));
|
||||
let env = build(&agent)
|
||||
.unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}"));
|
||||
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`).
|
||||
//!
|
||||
//! GC runs on every deploy, after identity derivation and before the state
|
||||
//! transition. It deletes terminated pods (and their referenced Secrets) and
|
||||
//! age-eligible orphan Secrets — every one of which must pass the full-pubkey
|
||||
//! annotation check *and* carry the management marker. An unmarked object is
|
||||
//! never GC'd regardless of its labels.
|
||||
//!
|
||||
//! The decision layer here is pure. The effectful caller supplies the observed
|
||||
//! objects and the apiserver's clock; this module decides what may be deleted.
|
||||
|
||||
use crate::naming::AgentIdentity;
|
||||
use crate::observe::{referenced_secret, secret_is_ours};
|
||||
use chrono::{DateTime, Utc};
|
||||
use k8s_openapi::api::core::v1::{Pod, Secret};
|
||||
|
||||
/// The deploy operation deadline (spec §Deploy: `timeout: 600s`).
|
||||
pub const OPERATION_DEADLINE_SECS: i64 = 600;
|
||||
|
||||
/// An unreferenced Secret is GC-eligible only once it is older than **twice**
|
||||
/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic
|
||||
/// against an independent GC pass, so without the gate a concurrent attempt's
|
||||
/// preflight GC can delete a Secret whose pod has not been created yet and
|
||||
/// strand that deploy. The age bound makes "unreferenced" mean "provably
|
||||
/// abandoned" — any attempt that could still reference it has exceeded its own
|
||||
/// deadline (`:1301-1319`).
|
||||
pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS;
|
||||
|
||||
/// What a GC pass decided to delete. Names only: the caller re-reads each
|
||||
/// object's own fence at delete time.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct GcPlan {
|
||||
/// Terminated, verified, marker-bearing pods.
|
||||
pub pods: Vec<String>,
|
||||
/// Age-eligible, verified, marker-bearing orphan Secrets.
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Plan a GC pass.
|
||||
///
|
||||
/// `now` is the apiserver's clock — the HTTP `Date` header from the very list
|
||||
/// call that produced `secrets`. `None` means the header was absent or
|
||||
/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather
|
||||
/// than falling back to local time: this provider runs on a user's desktop,
|
||||
/// and a local clock fast by more than the margin does not race — it
|
||||
/// deterministically computes every in-flight Secret as expired, on every
|
||||
/// pass, reopening exactly the interleaving the gate exists to close
|
||||
/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not.
|
||||
///
|
||||
/// Terminated-pod GC does not use the clock and is unaffected.
|
||||
pub fn plan(
|
||||
identity: &AgentIdentity,
|
||||
pods: &[Pod],
|
||||
secrets: &[Secret],
|
||||
terminated: impl Fn(&Pod) -> bool,
|
||||
now: Option<DateTime<Utc>>,
|
||||
) -> GcPlan {
|
||||
// Only pods that pass the full fence participate — in either direction.
|
||||
// An unverified pod is neither deleted nor allowed to protect a Secret:
|
||||
// it cannot be ours, so its `envFrom` cannot reference our generation.
|
||||
let ours: Vec<&Pod> = pods
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect();
|
||||
|
||||
// A Secret referenced by ANY existing pod is protected — deliberately
|
||||
// including not-yet-started pods, whose `envFrom` is exactly as
|
||||
// load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this
|
||||
// same pass are excluded, so their Secrets go with them.
|
||||
let doomed_names: Vec<&str> = doomed_pods
|
||||
.iter()
|
||||
.filter_map(|p| p.metadata.name.as_deref())
|
||||
.collect();
|
||||
let protected: Vec<String> = ours
|
||||
.iter()
|
||||
.filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default()))
|
||||
.filter_map(|p| referenced_secret(p))
|
||||
.collect();
|
||||
|
||||
let mut plan = GcPlan {
|
||||
pods: doomed_names.iter().map(|n| n.to_string()).collect(),
|
||||
secrets: doomed_pods
|
||||
.iter()
|
||||
.filter_map(|p| referenced_secret(p))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Orphan sweep: only with a server clock.
|
||||
if let Some(now) = now {
|
||||
for secret in secrets {
|
||||
if !secret_is_ours(secret, identity) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = secret.metadata.name.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) {
|
||||
continue;
|
||||
}
|
||||
let Some(created) = secret.metadata.creation_timestamp.as_ref() else {
|
||||
// No server-assigned timestamp means no age proof. Skip.
|
||||
continue;
|
||||
};
|
||||
if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS {
|
||||
plan.secrets.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY};
|
||||
use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource};
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn identity() -> AgentIdentity {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let keys = nostr::Keys::generate();
|
||||
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod {
|
||||
Pod {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(name.into()),
|
||||
uid: Some(format!("uid-{name}")),
|
||||
resource_version: Some("1".into()),
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
spec: secret.map(|s| PodSpec {
|
||||
containers: vec![Container {
|
||||
name: "agent".into(),
|
||||
env_from: Some(vec![EnvFromSource {
|
||||
secret_ref: Some(SecretEnvSource {
|
||||
name: s.into(),
|
||||
optional: Some(false),
|
||||
}),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime<Utc>) -> Secret {
|
||||
Secret {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(name.into()),
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn never(_: &Pod) -> bool {
|
||||
false
|
||||
}
|
||||
fn always(_: &Pod) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_pods_and_their_secrets_are_collected_together() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
|
||||
let plan = plan(&id, &[pod], &[], always, Some(now));
|
||||
assert_eq!(plan.pods, ["buzz-agent-dead"]);
|
||||
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_pods_are_never_collected() {
|
||||
let id = identity();
|
||||
let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1"));
|
||||
let plan = plan(&id, &[pod], &[], never, Some(Utc::now()));
|
||||
assert_eq!(plan, GcPlan::default());
|
||||
}
|
||||
|
||||
/// The auto-repair fence applies to GC identically: an object that lacks
|
||||
/// the marker, or carries a different pubkey, is never touched — however
|
||||
/// well its labels match.
|
||||
#[test]
|
||||
fn unmarked_and_mismatched_objects_are_never_collected() {
|
||||
let id = identity();
|
||||
let other = identity();
|
||||
let now = Utc::now();
|
||||
|
||||
let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1"));
|
||||
let mut labels = id.labels();
|
||||
labels.remove(LABEL_MANAGED_BY);
|
||||
unmarked.metadata.labels = Some(labels);
|
||||
|
||||
let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1"));
|
||||
foreign.metadata.annotations = Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
other.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now);
|
||||
unmarked_secret.metadata.labels = Some(BTreeMap::new());
|
||||
let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now);
|
||||
foreign_secret.metadata.annotations = Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
other.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let plan = plan(
|
||||
&id,
|
||||
&[unmarked, foreign],
|
||||
&[unmarked_secret, foreign_secret],
|
||||
always,
|
||||
Some(now),
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
GcPlan::default(),
|
||||
"GC touched an object it does not own"
|
||||
);
|
||||
}
|
||||
|
||||
/// The interleaving the age gate exists to close: attempt A creates its
|
||||
/// Secret; concurrent attempt B's preflight GC runs before A creates its
|
||||
/// pod. Without the gate B deletes A's Secret and strands A.
|
||||
#[test]
|
||||
fn young_unreferenced_secrets_are_protected() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now);
|
||||
assert_eq!(
|
||||
plan(&id, &[], &[fresh], never, Some(now)),
|
||||
GcPlan::default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Past twice the deadline, any attempt that could still reference the
|
||||
/// Secret has exceeded its own deadline — so it is provably abandoned.
|
||||
#[test]
|
||||
fn secrets_older_than_twice_the_deadline_are_collected() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let old = secret_named(
|
||||
&id,
|
||||
"buzz-agent-x-gen-abandoned",
|
||||
ORPHAN_SECRET_MIN_AGE_SECS + 1,
|
||||
now,
|
||||
);
|
||||
let plan = plan(&id, &[], &[old], never, Some(now));
|
||||
assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]);
|
||||
}
|
||||
|
||||
/// The boundary itself, both sides. `>= 1200s` is eligible.
|
||||
#[test]
|
||||
fn age_gate_boundary_is_exact() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now);
|
||||
let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now);
|
||||
assert!(plan(&id, &[], &[just_under], never, Some(now))
|
||||
.secrets
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
plan(&id, &[], &[exactly], never, Some(now)).secrets,
|
||||
["exact"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The same-clock rule. No apiserver `Date` header → skip the orphan
|
||||
/// sweep entirely. A local clock fast by more than the margin would
|
||||
/// silently delete every in-flight Secret on every pass.
|
||||
#[test]
|
||||
fn without_a_server_clock_the_orphan_sweep_is_skipped() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now);
|
||||
let plan = plan(&id, &[], &[ancient], never, None);
|
||||
assert!(
|
||||
plan.secrets.is_empty(),
|
||||
"orphan swept without a server clock — a fast local clock would delete live Secrets"
|
||||
);
|
||||
}
|
||||
|
||||
/// ...but terminated-pod GC does not consult the clock, so it still runs.
|
||||
#[test]
|
||||
fn terminated_pod_gc_runs_without_a_server_clock() {
|
||||
let id = identity();
|
||||
let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
|
||||
let plan = plan(&id, &[pod], &[], always, None);
|
||||
assert_eq!(plan.pods, ["buzz-agent-dead"]);
|
||||
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
|
||||
}
|
||||
|
||||
/// "Existing" includes not-yet-started pods: a Secret referenced by a pod
|
||||
/// still pulling its image must not be swept, however old it is.
|
||||
#[test]
|
||||
fn secrets_referenced_by_a_pending_pod_are_protected() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1"));
|
||||
let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now);
|
||||
let plan = plan(&id, &[pending], &[old], never, Some(now));
|
||||
assert!(plan.secrets.is_empty(), "swept a referenced Secret");
|
||||
}
|
||||
|
||||
/// A Secret with no server-assigned creationTimestamp has no age proof,
|
||||
/// so it is skipped rather than assumed old.
|
||||
#[test]
|
||||
fn secrets_without_a_creation_timestamp_are_skipped() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now);
|
||||
no_timestamp.metadata.creation_timestamp = None;
|
||||
assert!(plan(&id, &[], &[no_timestamp], never, Some(now))
|
||||
.secrets
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// A Secret belonging to a pod being collected in this same pass goes with
|
||||
/// it, and must not be listed twice.
|
||||
#[test]
|
||||
fn a_collected_pods_secret_is_listed_once() {
|
||||
let id = identity();
|
||||
let now = Utc::now();
|
||||
let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1"));
|
||||
let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now);
|
||||
let plan = plan(&id, &[dead], &[its_secret], always, Some(now));
|
||||
assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Image reference validation (spec §Image).
|
||||
//!
|
||||
//! The object holding this reference runs with an nsec, so the reference must
|
||||
//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself
|
||||
//! distinguishes them from digests for exactly this reason — so a tag-only
|
||||
//! reference is rejected, not just `:latest`.
|
||||
//!
|
||||
//! There is no parse-time fallback: `image` is required, and its absence
|
||||
//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig`
|
||||
//! digest is offered only as a schema `default` (a UI prefill the desktop
|
||||
//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent
|
||||
//! fingerprint never depends on compiled-in provider state.
|
||||
|
||||
/// A validated, digest-qualified image reference.
|
||||
///
|
||||
/// The inner string is always in canonical tagless form `name@sha256:<hex>`:
|
||||
/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is
|
||||
/// decorative once a digest pins the content, and leaving it in would make
|
||||
/// two references to identical bytes produce different create-intent
|
||||
/// fingerprints.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageRef(String);
|
||||
|
||||
impl ImageRef {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImageRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and normalize a user-supplied image reference.
|
||||
pub fn parse(raw: &str) -> Result<ImageRef, String> {
|
||||
let reference = raw.trim();
|
||||
if reference.is_empty() {
|
||||
return Err("provider_config.image is required: no image is assumed \
|
||||
at parse time, so the digest-pinned image to run must be \
|
||||
given explicitly"
|
||||
.to_string());
|
||||
}
|
||||
|
||||
let mut parts = reference.split('@');
|
||||
let name_and_tag = parts.next().unwrap_or_default();
|
||||
let digest = match (parts.next(), parts.next()) {
|
||||
(Some(d), None) => d,
|
||||
(None, _) => {
|
||||
return Err(format!(
|
||||
"provider_config.image {reference:?} is not digest-pinned: a \
|
||||
tag is a mutable pointer, and this object runs with the \
|
||||
agent's private key. Use name@sha256:<64 hex chars>"
|
||||
))
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(format!(
|
||||
"provider_config.image {reference:?} contains more than one '@'"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
|
||||
format!("provider_config.image digest {digest:?} must start with 'sha256:'")
|
||||
})?;
|
||||
// Lowercase only: OCI canonicalizes digest hex, and accepting uppercase
|
||||
// would let two spellings of one digest produce two fingerprints.
|
||||
if hex.len() != 64
|
||||
|| !hex
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
|
||||
{
|
||||
return Err(format!(
|
||||
"provider_config.image digest {digest:?} must be exactly 64 \
|
||||
lowercase hex characters"
|
||||
));
|
||||
}
|
||||
|
||||
// Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same
|
||||
// bytes and must fingerprint identically. Only a *final* colon segment
|
||||
// that isn't a port counts as a tag — `host:5000/name` has no tag.
|
||||
let name = match name_and_tag.rfind(':') {
|
||||
Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon],
|
||||
_ => name_and_tag,
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(format!(
|
||||
"provider_config.image {reference:?} has no repository name"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ImageRef(format!("{name}@sha256:{hex}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_digest_pinned_reference() {
|
||||
let d = "a".repeat(64);
|
||||
let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
|
||||
assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}"));
|
||||
}
|
||||
|
||||
/// The normalization that keeps the fingerprint stable: two spellings of
|
||||
/// the same bytes must produce one reference.
|
||||
#[test]
|
||||
fn strips_tag_from_tag_plus_digest_form() {
|
||||
let d = "b".repeat(64);
|
||||
let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap();
|
||||
let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
|
||||
assert_eq!(tagged, plain);
|
||||
}
|
||||
|
||||
/// A registry port is not a tag. `host:5000/name` must keep its port.
|
||||
#[test]
|
||||
fn registry_port_is_not_mistaken_for_a_tag() {
|
||||
let d = "c".repeat(64);
|
||||
let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap();
|
||||
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_and_tag_together_drops_only_the_tag() {
|
||||
let d = "d".repeat(64);
|
||||
let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap();
|
||||
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
|
||||
}
|
||||
|
||||
/// Wren's amendment: *every* tag-only reference is rejected, not just
|
||||
/// `:latest`. A `sha-<gitsha>` tag is traceable but still movable.
|
||||
#[test]
|
||||
fn rejects_every_tag_only_reference() {
|
||||
for bad in [
|
||||
"ghcr.io/block/buzz-sprig:latest",
|
||||
"ghcr.io/block/buzz-sprig:v1.2.3",
|
||||
"ghcr.io/block/buzz-sprig:sha-abc1234",
|
||||
"ghcr.io/block/buzz-sprig",
|
||||
"localhost:5000/buzz-sprig",
|
||||
] {
|
||||
let err = parse(bad).unwrap_err();
|
||||
assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase hex is a second spelling of one digest; accepting it would
|
||||
/// let the same image fingerprint two ways.
|
||||
#[test]
|
||||
fn rejects_uppercase_digest_hex() {
|
||||
let d = "A".repeat(64);
|
||||
assert!(parse(&format!("img@sha256:{d}")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_digests() {
|
||||
let short = "a".repeat(63);
|
||||
let long = "a".repeat(65);
|
||||
let ok = "a".repeat(64);
|
||||
for bad in [
|
||||
format!("img@sha256:{short}"),
|
||||
format!("img@sha256:{long}"),
|
||||
format!("img@sha512:{ok}"),
|
||||
format!("img@{ok}"),
|
||||
format!("img@sha256:{}", "g".repeat(64)),
|
||||
format!("img@sha256:{ok}@sha256:{ok}"),
|
||||
format!("@sha256:{ok}"),
|
||||
] {
|
||||
assert!(parse(&bad).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsing has no fallback (the schema default is a UI prefill, not a
|
||||
/// parse-time substitute), so an absent image is an error that names the
|
||||
/// field rather than a silent fallback.
|
||||
#[test]
|
||||
fn empty_reference_names_the_field() {
|
||||
for empty in ["", " "] {
|
||||
let err = parse(empty).unwrap_err();
|
||||
assert!(err.contains("provider_config.image"), "got: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! The create-intent fingerprint (spec §Deploy State Machine, create-intent
|
||||
//! fingerprint, `docs/remote-agents.md:796-828`).
|
||||
//!
|
||||
//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the
|
||||
//! provider's non-secret create-intent template. A plain hash is safe *only*
|
||||
//! because of the scope rule: the input covers exactly the provider-controlled
|
||||
//! fields that can affect scheduling or container creation, and **never Secret
|
||||
//! data or attempt identity**. Hashing low-entropy secrets into a
|
||||
//! world-readable annotation would be a dictionary oracle.
|
||||
//!
|
||||
//! That rule is enforced structurally rather than remembered. [`IntentTemplate`]
|
||||
//! is a *pre-binding* type: it has no field that can hold Secret material or a
|
||||
//! generation token, so there is no expression that hashes one. The
|
||||
//! per-attempt Secret name never appears — the pod's `envFrom` is represented
|
||||
//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would
|
||||
//! diverge from every other by construction.
|
||||
//!
|
||||
//! Server- and admission-produced output (UID, `resourceVersion`, timestamps,
|
||||
//! defaulted fields, the annotation itself) is excluded the same way: the
|
||||
//! serializer is only ever handed this template, never a live `Pod`, so the
|
||||
//! exclusion is checkable by inspection.
|
||||
|
||||
use crate::image::ImageRef;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Stands in for the per-attempt Secret name in the `envFrom` position.
|
||||
/// A real generation token here would make every attempt diverge.
|
||||
const SECRET_PLACEHOLDER: &str = "<per-attempt-secret>";
|
||||
|
||||
/// The recorded/computed create intent: a hex SHA-256 digest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Fingerprint(String);
|
||||
|
||||
impl Fingerprint {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Read a fingerprint off a pod annotation. Any recorded string is
|
||||
/// accepted verbatim: comparison is equality against a freshly computed
|
||||
/// value, so a malformed annotation simply reads as divergence — which is
|
||||
/// the correct outcome for a pod this provider version did not write.
|
||||
pub fn from_annotation(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn for_test(seed: &str) -> Self {
|
||||
Self(format!("test-{seed}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Fingerprint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-secret, pre-binding description of the pod this deploy would
|
||||
/// create. Every field is provider-controlled and scheduling-relevant; there
|
||||
/// is deliberately no field for env values, Secret data, or the generation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct IntentTemplate {
|
||||
/// Schema version of the template itself. Bumping it re-fingerprints every
|
||||
/// pod, which is the intended way to roll out a pod-shape change.
|
||||
pub template_version: u32,
|
||||
pub namespace: String,
|
||||
/// Normalized, digest-qualified image reference.
|
||||
pub image: String,
|
||||
pub cpu_request: String,
|
||||
pub memory_request: String,
|
||||
pub cpu_limit: String,
|
||||
pub memory_limit: String,
|
||||
pub service_account: Option<String>,
|
||||
pub restart_policy: &'static str,
|
||||
pub termination_grace_period_seconds: i64,
|
||||
/// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the
|
||||
/// container's contract); values are Secret material and must not be here.
|
||||
pub env_keys: Vec<String>,
|
||||
/// Fixed placeholder for the per-attempt Secret in `envFrom`.
|
||||
pub env_from_secret: &'static str,
|
||||
pub workspace_mount_path: String,
|
||||
pub run_as_user: i64,
|
||||
pub run_as_group: i64,
|
||||
}
|
||||
|
||||
/// Current template schema version.
|
||||
pub const TEMPLATE_VERSION: u32 = 1;
|
||||
|
||||
impl IntentTemplate {
|
||||
/// Compute the fingerprint. `serde_json` on a struct with declared field
|
||||
/// order plus pre-sorted `env_keys` is a canonical serialization: the same
|
||||
/// template always produces the same bytes.
|
||||
pub fn fingerprint(&self) -> Fingerprint {
|
||||
let canonical = serde_json::to_vec(self).expect("intent template is plain data");
|
||||
Fingerprint(hex::encode(Sha256::digest(&canonical)))
|
||||
}
|
||||
|
||||
/// Build from resolved pod-shape inputs. `env_keys` is sorted here rather
|
||||
/// than at the call site so key ordering can never leak into the digest.
|
||||
///
|
||||
/// The fixed pod-shape constants are read from [`crate::config`] rather
|
||||
/// than passed in: `pod::build_pod` stamps the pod from those same
|
||||
/// constants, so the fingerprint cannot describe a pod shape different
|
||||
/// from the one actually created. Threading them through as arguments
|
||||
/// would make that agreement a thing to test instead of a thing that holds.
|
||||
pub fn new(
|
||||
namespace: &str,
|
||||
image: &ImageRef,
|
||||
resources: &crate::config::Resources,
|
||||
service_account: Option<&str>,
|
||||
env_keys: impl IntoIterator<Item = String>,
|
||||
) -> Self {
|
||||
let mut env_keys: Vec<String> = env_keys.into_iter().collect();
|
||||
env_keys.sort();
|
||||
Self {
|
||||
template_version: TEMPLATE_VERSION,
|
||||
namespace: namespace.to_string(),
|
||||
image: image.as_str().to_string(),
|
||||
cpu_request: resources.cpu_request.clone(),
|
||||
memory_request: resources.memory_request.clone(),
|
||||
cpu_limit: resources.cpu_limit.clone(),
|
||||
memory_limit: resources.memory_limit.clone(),
|
||||
service_account: service_account.map(str::to_string),
|
||||
restart_policy: crate::config::RESTART_POLICY,
|
||||
termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS,
|
||||
env_keys,
|
||||
env_from_secret: SECRET_PLACEHOLDER,
|
||||
workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(),
|
||||
run_as_user: crate::config::RUN_AS_UID,
|
||||
run_as_group: crate::config::RUN_AS_GID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Resources;
|
||||
|
||||
fn image(byte: char) -> ImageRef {
|
||||
crate::image::parse(&format!(
|
||||
"ghcr.io/block/buzz-sprig@sha256:{}",
|
||||
byte.to_string().repeat(64)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn template() -> IntentTemplate {
|
||||
IntentTemplate::new(
|
||||
"buzz-agents",
|
||||
&image('a'),
|
||||
&Resources::default(),
|
||||
None,
|
||||
["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_deterministic() {
|
||||
assert_eq!(template().fingerprint(), template().fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_hex_sha256() {
|
||||
let fp = template().fingerprint();
|
||||
assert_eq!(fp.as_str().len(), 64);
|
||||
assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
/// Key *order* must not reach the digest, or two identical environments
|
||||
/// built in different orders would look like a config change.
|
||||
#[test]
|
||||
fn env_key_order_does_not_affect_the_digest() {
|
||||
let a = IntentTemplate::new(
|
||||
"ns",
|
||||
&image('a'),
|
||||
&Resources::default(),
|
||||
None,
|
||||
["A".to_string(), "B".to_string(), "C".to_string()],
|
||||
);
|
||||
let b = IntentTemplate::new(
|
||||
"ns",
|
||||
&image('a'),
|
||||
&Resources::default(),
|
||||
None,
|
||||
["C".to_string(), "A".to_string(), "B".to_string()],
|
||||
);
|
||||
assert_eq!(a.fingerprint(), b.fingerprint());
|
||||
}
|
||||
|
||||
/// A mutation applied to a fresh template clone, named for its assertion
|
||||
/// message.
|
||||
type Mutation = (&'static str, Box<dyn Fn(&mut IntentTemplate)>);
|
||||
|
||||
/// Every scheduling-relevant knob must move the digest — this is the
|
||||
/// wedge escape (§Deploy State Machine never-started recoverable row).
|
||||
/// Exhaustive by construction: each mutation is applied to a fresh clone.
|
||||
#[test]
|
||||
fn every_scheduling_field_changes_the_digest() {
|
||||
let base = template();
|
||||
let baseline = base.fingerprint();
|
||||
|
||||
let mutations: Vec<Mutation> = vec![
|
||||
(
|
||||
"template_version",
|
||||
Box::new(|t: &mut IntentTemplate| t.template_version += 1),
|
||||
),
|
||||
(
|
||||
"namespace",
|
||||
Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()),
|
||||
),
|
||||
(
|
||||
"image",
|
||||
Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()),
|
||||
),
|
||||
(
|
||||
"cpu_request",
|
||||
Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()),
|
||||
),
|
||||
(
|
||||
"memory_request",
|
||||
Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()),
|
||||
),
|
||||
(
|
||||
"cpu_limit",
|
||||
Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()),
|
||||
),
|
||||
(
|
||||
"memory_limit",
|
||||
Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()),
|
||||
),
|
||||
(
|
||||
"service_account",
|
||||
Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())),
|
||||
),
|
||||
(
|
||||
"restart_policy",
|
||||
Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"),
|
||||
),
|
||||
(
|
||||
"grace_period",
|
||||
Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30),
|
||||
),
|
||||
(
|
||||
"env_keys",
|
||||
Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())),
|
||||
),
|
||||
(
|
||||
"workspace_mount_path",
|
||||
Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()),
|
||||
),
|
||||
(
|
||||
"run_as_user",
|
||||
Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000),
|
||||
),
|
||||
(
|
||||
"run_as_group",
|
||||
Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, mutate) in mutations {
|
||||
let mut t = base.clone();
|
||||
mutate(&mut t);
|
||||
assert_ne!(
|
||||
t.fingerprint(),
|
||||
baseline,
|
||||
"{name} did not affect the digest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope rule, asserted on the bytes: no Secret value and no
|
||||
/// generation token can appear in the serialization, because the type has
|
||||
/// nowhere to put them. The placeholder is what `envFrom` contributes.
|
||||
#[test]
|
||||
fn serialization_contains_no_secret_material_or_attempt_identity() {
|
||||
let json = serde_json::to_string(&template()).unwrap();
|
||||
for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] {
|
||||
assert!(
|
||||
!json.contains(forbidden),
|
||||
"template leaked {forbidden}: {json}"
|
||||
);
|
||||
}
|
||||
assert!(json.contains(SECRET_PLACEHOLDER));
|
||||
}
|
||||
|
||||
/// Two attempts for the same agent differ only in generation, which is
|
||||
/// absent from the template — so their fingerprints must be equal, or the
|
||||
/// divergence discriminator would fire on every single deploy.
|
||||
#[test]
|
||||
fn attempts_differing_only_by_generation_do_not_diverge() {
|
||||
// There is no generation input to pass; that *is* the property. The
|
||||
// test states it explicitly so a future field addition breaks here.
|
||||
assert_eq!(template().fingerprint(), template().fingerprint());
|
||||
let json = serde_json::to_string(&template()).unwrap();
|
||||
assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1);
|
||||
}
|
||||
|
||||
/// A recorded annotation this provider version did not write reads as
|
||||
/// divergence rather than an error.
|
||||
#[test]
|
||||
fn unrecognized_annotation_reads_as_divergence() {
|
||||
let recorded = Fingerprint::from_annotation("not-a-digest");
|
||||
assert_ne!(recorded, template().fingerprint());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Kubernetes backend provider for Buzz remote agents
|
||||
//! (spec `docs/remote-agents.md`).
|
||||
//!
|
||||
//! One process per operation: read exactly one JSON request from stdin, write
|
||||
//! exactly one JSON response to stdout, exit. The exit code carries exactly
|
||||
//! one bit — 0 for a response that was produced, 1 for a failure to produce
|
||||
//! one. Everything a caller needs to distinguish is *inside* the response's
|
||||
//! `ok` field, because a provider that encoded outcomes in exit codes would
|
||||
//! have a second, redundant error channel to keep in sync (§Provider Protocol).
|
||||
|
||||
mod classify;
|
||||
mod client;
|
||||
mod cluster;
|
||||
mod config;
|
||||
mod env;
|
||||
mod gc;
|
||||
mod image;
|
||||
mod intent;
|
||||
mod naming;
|
||||
mod observe;
|
||||
mod pod;
|
||||
mod reconcile;
|
||||
mod wire;
|
||||
|
||||
use std::io::Read;
|
||||
use wire::{Request, Response};
|
||||
|
||||
/// The provider a shared-compute agent resolves to. Refused here as the
|
||||
/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it
|
||||
/// as a pod would create a second, contending consumer of the same agent
|
||||
/// identity (`:214-219`).
|
||||
const RELAY_MESH_PROVIDER: &str = "relay-mesh";
|
||||
|
||||
fn main() {
|
||||
// rustls needs a process-level provider before the first TLS connection.
|
||||
// The release build compiles every sidecar in one cargo invocation, which
|
||||
// unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to
|
||||
// auto-select — so this is an explicit install, not a default.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let mut input = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
|
||||
// No request means no request_id and no response contract to honor.
|
||||
// This is the one path that exits nonzero.
|
||||
eprintln!("could not read the request from stdin: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let response = respond(&input);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&response).unwrap_or_else(|e| {
|
||||
// The response types are plain data; this cannot fail in practice,
|
||||
// and a hand-built object is still a conforming response.
|
||||
format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Produce the single response for one request. Separated from `main` so the
|
||||
/// whole dispatch is testable without a process.
|
||||
fn respond(input: &str) -> Response {
|
||||
// Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire
|
||||
// value, and `AgentPayload` deliberately does not carry `provider`.
|
||||
let raw: serde_json::Value = match serde_json::from_str(input) {
|
||||
Ok(value) => value,
|
||||
Err(e) => return Response::error(format!("request is not valid JSON: {e}")),
|
||||
};
|
||||
|
||||
if let Some(refusal) = refuse_relay_mesh(&raw) {
|
||||
return Response::error(refusal);
|
||||
}
|
||||
|
||||
let request: Request = match serde_json::from_value(raw) {
|
||||
Ok(request) => request,
|
||||
Err(e) => return Response::error(format!("could not understand the request: {e}")),
|
||||
};
|
||||
|
||||
match request {
|
||||
Request::Info => Response::info(),
|
||||
Request::Deploy(deploy) => {
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => return Response::error(format!("could not start the runtime: {e}")),
|
||||
};
|
||||
match runtime.block_on(deploy_agent(&deploy)) {
|
||||
Ok(agent_id) => Response::deployed(agent_id),
|
||||
Err(e) => Response::error(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a shared-compute agent, reading the **raw wire value**.
|
||||
///
|
||||
/// Trimmed before comparing: the desktop's own layers disagree about padding
|
||||
/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard
|
||||
/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding
|
||||
/// whitespace on a non-blank value. A backstop that shares its bypass with the
|
||||
/// layer it backs is not a backstop.
|
||||
fn refuse_relay_mesh(raw: &serde_json::Value) -> Option<String> {
|
||||
let provider = raw.get("agent")?.get("provider")?.as_str()?;
|
||||
(provider.trim() == RELAY_MESH_PROVIDER).then(|| {
|
||||
"deploy refused: this agent is configured for shared compute \
|
||||
(relay-mesh), which runs on the relay rather than in a pod. \
|
||||
Switch the agent to a local runtime before deploying it to \
|
||||
Kubernetes."
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Run one deploy to a terminal outcome.
|
||||
async fn deploy_agent(request: &wire::DeployRequest) -> Result<String, String> {
|
||||
let cfg = config::parse(&request.provider_config)?;
|
||||
// Identity before any cluster contact: a malformed nsec is a refusal, not
|
||||
// a failed connection (§Deploy State Machine step 0).
|
||||
let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?;
|
||||
|
||||
// One generation for this operation's first attempt; the reconciler mints
|
||||
// its own per attempt and restamps the correlator to match.
|
||||
let env = env::build_env(
|
||||
&request.agent,
|
||||
env::AuthoritativeInputs {
|
||||
generation: &naming::new_generation(),
|
||||
inactivity_seconds: cfg.inactivity_seconds,
|
||||
},
|
||||
)?;
|
||||
|
||||
let client = client::connect(cfg.context.as_deref()).await?;
|
||||
let substrate = cluster::Cluster::new(client, &cfg.namespace);
|
||||
reconcile::deploy(&substrate, &identity, &cfg, env).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn error_of(response: &Response) -> String {
|
||||
let json = serde_json::to_value(response).unwrap();
|
||||
assert_eq!(json["ok"], false, "expected a refusal: {json}");
|
||||
json["error"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The spec's backstop for the relay-mesh MUST. The desktop refuses first
|
||||
/// (`agents_deploy.rs:116`); this is the layer that owes the obligation.
|
||||
#[test]
|
||||
fn refuses_a_relay_mesh_agent() {
|
||||
let request = r#"{"op":"deploy","agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"},
|
||||
"provider_config":{"namespace":"ns"}}"#;
|
||||
assert!(error_of(&respond(request)).contains("relay-mesh"));
|
||||
}
|
||||
|
||||
/// Padding must not bypass the backstop. Reachable by construction:
|
||||
/// `GlobalConfig.provider` is a bare `Option<String>` with no trim on
|
||||
/// write, and `non_blank` rejects whitespace-only while preserving
|
||||
/// surrounding whitespace on everything else.
|
||||
#[test]
|
||||
fn refuses_a_padded_relay_mesh_agent() {
|
||||
let request = r#"{"op":"deploy","agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "},
|
||||
"provider_config":{"namespace":"ns"}}"#;
|
||||
assert!(error_of(&respond(request)).contains("relay-mesh"));
|
||||
}
|
||||
|
||||
/// The refusal must not fire on a normal agent — a guard that refuses
|
||||
/// everything passes its own test and ships a provider that deploys
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn does_not_refuse_a_normal_provider() {
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap();
|
||||
assert!(refuse_relay_mesh(&raw).is_none());
|
||||
// …nor when the field is absent entirely, which is the common case:
|
||||
// `AgentPayload` does not carry `provider`.
|
||||
let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap();
|
||||
assert!(refuse_relay_mesh(&bare).is_none());
|
||||
}
|
||||
|
||||
/// Malformed input still produces exactly one conforming response.
|
||||
#[test]
|
||||
fn malformed_input_is_an_in_band_error() {
|
||||
assert!(error_of(&respond("not json")).contains("valid JSON"));
|
||||
assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand"));
|
||||
}
|
||||
|
||||
/// `info` answers without touching a cluster — it is what the desktop
|
||||
/// calls to render the config form, before any kubeconfig exists.
|
||||
#[test]
|
||||
fn info_answers_with_the_protocol_version_and_schema() {
|
||||
let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap();
|
||||
assert_eq!(json["ok"], true);
|
||||
assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION);
|
||||
assert!(json["config_schema"]["properties"]["namespace"].is_object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Identity derivation and the object-naming contract (spec §Pod shape).
|
||||
//!
|
||||
//! Every name, label, and annotation below is derived from the pubkey the
|
||||
//! provider decoded itself from `private_key_nsec` — never from a
|
||||
//! caller-supplied pubkey (§Deploy State Machine step 0).
|
||||
|
||||
use nostr::nips::nip19::FromBech32;
|
||||
|
||||
/// `app.kubernetes.io/managed-by` value: the management marker's identity half.
|
||||
pub const MANAGED_BY: &str = "buzz-backend-kubernetes";
|
||||
|
||||
/// Label key carrying [`MANAGED_BY`].
|
||||
pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
|
||||
|
||||
/// Label key carrying [`BINDING_VERSION`] — the marker's schema half.
|
||||
pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version";
|
||||
|
||||
/// Schema version of the object layout this provider writes. Bumped when the
|
||||
/// pod/Secret shape changes in a way a older provider would mis-handle.
|
||||
pub const BINDING_VERSION: &str = "1";
|
||||
|
||||
/// Label key: truncated pubkey, the reconciliation and GC selector.
|
||||
pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey";
|
||||
|
||||
/// Annotation key: full pubkey. Load-bearing — the truncated label is
|
||||
/// collision-*resistant*, this is what makes it safe (§Deploy State Machine
|
||||
/// step 1).
|
||||
pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full";
|
||||
|
||||
/// Annotation key: the recorded create-intent fingerprint.
|
||||
pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent";
|
||||
|
||||
/// Annotation key: the image reference this generation actually resolved to,
|
||||
/// for post-hoc attribution (§Image).
|
||||
pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image";
|
||||
|
||||
/// An agent identity the provider derived itself, plus every name it implies.
|
||||
///
|
||||
/// Constructing this type is the *only* way to obtain the names — so a
|
||||
/// caller-supplied pubkey cannot reach a selector by any path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentIdentity {
|
||||
pubkey_hex: String,
|
||||
}
|
||||
|
||||
impl AgentIdentity {
|
||||
/// Derive from the payload's `private_key_nsec`.
|
||||
///
|
||||
/// Accepts bech32 `nsec1…`; a malformed or undecodable key is an
|
||||
/// immediate error, before any substrate read or mutation
|
||||
/// (§Deploy State Machine step 0).
|
||||
pub fn from_nsec(nsec: &str) -> Result<Self, String> {
|
||||
let secret = nostr::SecretKey::from_bech32(nsec.trim())
|
||||
.map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?;
|
||||
let keys = nostr::Keys::new(secret);
|
||||
Ok(Self {
|
||||
pubkey_hex: keys.public_key().to_hex(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Full 64-hex public key — the annotation value and the comparison
|
||||
/// operand for candidate authentication.
|
||||
pub fn pubkey_hex(&self) -> &str {
|
||||
&self.pubkey_hex
|
||||
}
|
||||
|
||||
/// Selector label value: first 32 hex chars (128 bits). A full hex pubkey
|
||||
/// is 64 chars and label values cap at 63, which is why this is truncated
|
||||
/// and why the annotation check is normative rather than decorative.
|
||||
pub fn label_pubkey(&self) -> &str {
|
||||
&self.pubkey_hex[..32]
|
||||
}
|
||||
|
||||
/// Deterministic pod name, also the returned `agent_id`.
|
||||
pub fn pod_name(&self) -> String {
|
||||
format!("buzz-agent-{}", &self.pubkey_hex[..12])
|
||||
}
|
||||
|
||||
/// Per-attempt Secret name. `generation` is a fresh random token per
|
||||
/// create attempt — never reused — which is what makes payload and Secret
|
||||
/// atomic at the pod-spec boundary (§K8s Secrets).
|
||||
pub fn secret_name(&self, generation: &str) -> String {
|
||||
format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation)
|
||||
}
|
||||
|
||||
/// Label selector matching this identity's objects *and* our management
|
||||
/// marker. Selecting on the marker as well as the identity means an
|
||||
/// unmarked look-alike never even enters the candidate list.
|
||||
pub fn selector(&self) -> String {
|
||||
format!(
|
||||
"{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}",
|
||||
self.label_pubkey()
|
||||
)
|
||||
}
|
||||
|
||||
/// The label set stamped on every object this provider creates.
|
||||
pub fn labels(&self) -> std::collections::BTreeMap<String, String> {
|
||||
[
|
||||
(
|
||||
LABEL_AGENT_PUBKEY.to_string(),
|
||||
self.label_pubkey().to_string(),
|
||||
),
|
||||
(LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()),
|
||||
(
|
||||
LABEL_BINDING_VERSION.to_string(),
|
||||
BINDING_VERSION.to_string(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh generation token: 8 lowercase hex chars from the OS RNG.
|
||||
///
|
||||
/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the
|
||||
/// Secret generation and the harness's lifecycle-frame correlator are one
|
||||
/// identity (§Launch data tier 3).
|
||||
pub fn new_generation() -> String {
|
||||
use rand::RngExt;
|
||||
let n: u32 = rand::rng().random();
|
||||
format!("{n:08x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fixed test key. Deriving the pubkey (rather than hardcoding both
|
||||
/// halves) is the point: the test exercises the same derivation the
|
||||
/// reconciler depends on.
|
||||
fn identity() -> AgentIdentity {
|
||||
let keys = nostr::Keys::generate();
|
||||
let nsec = {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
keys.secret_key().to_bech32().unwrap()
|
||||
};
|
||||
let id = AgentIdentity::from_nsec(&nsec).unwrap();
|
||||
assert_eq!(id.pubkey_hex(), keys.public_key().to_hex());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_nsec() {
|
||||
for bad in ["", "nsec1", "not-a-key", "npub1abc"] {
|
||||
assert!(
|
||||
AgentIdentity::from_nsec(bad).is_err(),
|
||||
"accepted malformed key {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_surrounding_whitespace() {
|
||||
let keys = nostr::Keys::generate();
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let nsec = keys.secret_key().to_bech32().unwrap();
|
||||
let padded = format!(" {nsec}\n");
|
||||
assert_eq!(
|
||||
AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(),
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
/// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64,
|
||||
/// one over. That one-char overflow is the whole reason the selector is
|
||||
/// truncated, so it gets an explicit test.
|
||||
#[test]
|
||||
fn label_value_fits_kubernetes_limit() {
|
||||
let id = identity();
|
||||
assert_eq!(id.pubkey_hex().len(), 64);
|
||||
assert_eq!(id.label_pubkey().len(), 32);
|
||||
assert!(id.label_pubkey().len() <= 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pod_name_is_deterministic_and_dns_safe() {
|
||||
let id = identity();
|
||||
assert_eq!(id.pod_name(), id.pod_name());
|
||||
assert_eq!(
|
||||
id.pod_name(),
|
||||
format!("buzz-agent-{}", &id.pubkey_hex()[..12])
|
||||
);
|
||||
assert!(id.pod_name().len() <= 253);
|
||||
assert!(id
|
||||
.pod_name()
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
|
||||
}
|
||||
|
||||
/// Two attempts must never share a Secret name — that uniqueness is what
|
||||
/// stops a losing contender from overwriting the winner's identity.
|
||||
#[test]
|
||||
fn secret_names_are_per_attempt() {
|
||||
let id = identity();
|
||||
let a = id.secret_name(&new_generation());
|
||||
let b = id.secret_name(&new_generation());
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with(&id.pod_name()));
|
||||
assert!(a.len() <= 253);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_requires_the_management_marker() {
|
||||
let id = identity();
|
||||
let sel = id.selector();
|
||||
assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey())));
|
||||
assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_created_object_carries_the_marker() {
|
||||
let labels = identity().labels();
|
||||
assert_eq!(
|
||||
labels.get(LABEL_MANAGED_BY).map(String::as_str),
|
||||
Some(MANAGED_BY)
|
||||
);
|
||||
assert_eq!(
|
||||
labels.get(LABEL_BINDING_VERSION).map(String::as_str),
|
||||
Some(BINDING_VERSION)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
//! Decoding API objects into verified observations (spec §Deploy State
|
||||
//! Machine step 1).
|
||||
//!
|
||||
//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the
|
||||
//! conformance tests drive the *shipped* decoder with real API types rather
|
||||
//! than a test-only stand-in, and it keeps `classify.rs` free of API types.
|
||||
//!
|
||||
//! Verification is the gate, not a filter: [`verify`] returns `None` for any
|
||||
//! object whose full-pubkey annotation does not equal the derived pubkey or
|
||||
//! that lacks the management marker, so an unverified object cannot reach
|
||||
//! classification, deletion, or the returned `agent_id`.
|
||||
|
||||
use crate::classify::{Fence, PullFailure, Startup, VerifiedPod};
|
||||
use crate::intent::Fingerprint;
|
||||
use crate::naming::{
|
||||
AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION,
|
||||
LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY,
|
||||
};
|
||||
use k8s_openapi::api::core::v1::{Pod, Secret};
|
||||
|
||||
/// Container name the provider creates; status is read from this container.
|
||||
pub const CONTAINER_NAME: &str = "agent";
|
||||
|
||||
/// The startup state, or a state that cannot be settled without one more read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StartupObservation {
|
||||
Resolved(Startup),
|
||||
/// `CreateContainerConfigError` — recoverable *unless* the referenced
|
||||
/// Secret is confirmed absent by a most-recent read. The kubelet's reason
|
||||
/// string is a hint; the provider verifies before treating it as fatal
|
||||
/// (§Deploy State Machine: "provably" means a verified absence, never a
|
||||
/// reason string).
|
||||
ConfigErrorPendingSecretCheck {
|
||||
secret_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Does this object carry the management marker (§Pod shape)?
|
||||
///
|
||||
/// Identity labels prove identity; the marker asserts protocol ownership.
|
||||
/// Without it an object that merely matches our schema fails closed.
|
||||
fn has_marker(labels: Option<&std::collections::BTreeMap<String, String>>) -> bool {
|
||||
let Some(labels) = labels else { return false };
|
||||
labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY)
|
||||
&& labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION)
|
||||
}
|
||||
|
||||
/// Does the full-pubkey annotation equal the derived pubkey?
|
||||
///
|
||||
/// The 32-hex label is collision-*resistant*, not collision-free, which is
|
||||
/// why this check is normative rather than decorative (`:1152-1166`).
|
||||
fn annotation_matches(
|
||||
annotations: Option<&std::collections::BTreeMap<String, String>>,
|
||||
identity: &AgentIdentity,
|
||||
) -> bool {
|
||||
annotations
|
||||
.and_then(|a| a.get(ANNOTATION_PUBKEY_FULL))
|
||||
.map(|v| v == identity.pubkey_hex())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Is this Secret ours and this identity's? The same fence GC applies before
|
||||
/// deleting anything.
|
||||
pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool {
|
||||
has_marker(secret.metadata.labels.as_ref())
|
||||
&& annotation_matches(secret.metadata.annotations.as_ref(), identity)
|
||||
}
|
||||
|
||||
/// Decode a pod's startup state from its status.
|
||||
///
|
||||
/// "Started" means `state.running` on our container — not pod phase. A pod can
|
||||
/// sit in phase `Running` with a container that never started, and a pod being
|
||||
/// gracefully deleted stays in phase `Running` for its whole grace period.
|
||||
pub fn decode_startup(pod: &Pod) -> StartupObservation {
|
||||
use StartupObservation::Resolved;
|
||||
|
||||
let status = pod.status.as_ref();
|
||||
let phase = status.and_then(|s| s.phase.as_deref());
|
||||
|
||||
let container = status
|
||||
.and_then(|s| s.container_statuses.as_ref())
|
||||
.and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME));
|
||||
|
||||
if let Some(state) = container.and_then(|c| c.state.as_ref()) {
|
||||
if state.running.is_some() {
|
||||
return Resolved(Startup::Started);
|
||||
}
|
||||
if state.terminated.is_some() {
|
||||
return Resolved(Startup::Terminated);
|
||||
}
|
||||
if let Some(waiting) = state.waiting.as_ref() {
|
||||
let reason = waiting.reason.as_deref().unwrap_or_default();
|
||||
let message = waiting.message.as_deref().unwrap_or_default();
|
||||
return match reason {
|
||||
// Structurally invalid reference: no retry can fix it.
|
||||
"InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken),
|
||||
"ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) {
|
||||
Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)),
|
||||
None => Resolved(Startup::NeverStartedRecoverable),
|
||||
},
|
||||
"CreateContainerConfigError" => match referenced_secret(pod) {
|
||||
Some(secret_name) => {
|
||||
StartupObservation::ConfigErrorPendingSecretCheck { secret_name }
|
||||
}
|
||||
None => Resolved(Startup::NeverStartedRecoverable),
|
||||
},
|
||||
_ => Resolved(Startup::NeverStartedRecoverable),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No container status yet (unscheduled, image pulling before the kubelet
|
||||
// reports, quota-blocked). A terminal phase without container status still
|
||||
// means the pod is done.
|
||||
match phase {
|
||||
Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated),
|
||||
_ => Resolved(Startup::NeverStartedRecoverable),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Secret name this pod's `envFrom` references, if any.
|
||||
pub fn referenced_secret(pod: &Pod) -> Option<String> {
|
||||
pod.spec
|
||||
.as_ref()?
|
||||
.containers
|
||||
.iter()
|
||||
.flat_map(|c| c.env_from.iter().flatten())
|
||||
.find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone()))
|
||||
}
|
||||
|
||||
/// Classify a pull failure from the kubelet's message.
|
||||
///
|
||||
/// Reporting only — [`PullFailure`] is structurally excluded from
|
||||
/// `Action::Delete`, so a wrong guess here can delay a report but can never
|
||||
/// destroy anything. `None` means "no permanent cause recognized", which
|
||||
/// leaves the pod on the ordinary observational path.
|
||||
fn classify_pull_failure(message: &str) -> Option<PullFailure> {
|
||||
let m = message.to_ascii_lowercase();
|
||||
if m.contains("401")
|
||||
|| m.contains("unauthorized")
|
||||
|| m.contains("403")
|
||||
|| m.contains("denied")
|
||||
|| m.contains("authentication required")
|
||||
{
|
||||
return Some(PullFailure::Unauthorized);
|
||||
}
|
||||
if m.contains("manifest unknown")
|
||||
|| m.contains("not found")
|
||||
|| m.contains("manifest_unknown")
|
||||
|| m.contains("repository does not exist")
|
||||
{
|
||||
return Some(PullFailure::ManifestUnknown);
|
||||
}
|
||||
if m.contains("no match for platform") || m.contains("no matching manifest") {
|
||||
return Some(PullFailure::ArchMismatch);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The redacted, actionable condition text for a pull failure.
|
||||
///
|
||||
/// Names the registry and the immutable reference — never credentials, and
|
||||
/// never the kubelet's raw message, which can echo a registry token.
|
||||
pub fn pull_failure_message(failure: PullFailure, image: &str) -> String {
|
||||
let registry = image.split('/').next().unwrap_or(image);
|
||||
match failure {
|
||||
PullFailure::Unauthorized => format!(
|
||||
"the cluster is not authorized to pull {image} from {registry}. \
|
||||
This pull retries indefinitely and will not succeed on its own: \
|
||||
grant the cluster's nodes access to that registry."
|
||||
),
|
||||
PullFailure::ManifestUnknown => {
|
||||
format!("{registry} has no image at {image}. Check the digest and repository.")
|
||||
}
|
||||
PullFailure::ArchMismatch => format!(
|
||||
"{image} has no variant for the architecture of the nodes it was \
|
||||
scheduled on."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest actionable condition for a pod that has not started, redacted.
|
||||
///
|
||||
/// Two sources, deliberately treated differently:
|
||||
///
|
||||
/// * The container's waiting **reason** is included; its **message** is not.
|
||||
/// Waiting messages are kubelet-composed and echo the thing that failed —
|
||||
/// for a pull that is the registry request, which can carry credential
|
||||
/// material. The reason token alone (`ImagePullBackOff`,
|
||||
/// `CreateContainerConfigError`) is the diagnostic; the message adds
|
||||
/// exposure, not information the user can act on.
|
||||
/// * Pod-condition messages **are** included. They are scheduler- and
|
||||
/// kubelet-composed from the pod's own spec and cluster capacity
|
||||
/// ("0/3 nodes are available: Insufficient memory"), which is precisely the
|
||||
/// actionable half and contains nothing derived from Secret data.
|
||||
pub fn condition(pod: &Pod) -> Option<String> {
|
||||
let status = pod.status.as_ref()?;
|
||||
|
||||
if let Some(state) = status
|
||||
.container_statuses
|
||||
.as_ref()
|
||||
.and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME))
|
||||
.and_then(|c| c.state.as_ref())
|
||||
{
|
||||
if let Some(waiting) = state.waiting.as_ref() {
|
||||
if let Some(reason) = waiting.reason.as_deref() {
|
||||
return Some(format!("the container is waiting, reason {reason}"));
|
||||
}
|
||||
}
|
||||
// Exit code and reason only — the terminated `message` is
|
||||
// process-composed output and falls under the same redaction rule as
|
||||
// waiting messages.
|
||||
if let Some(terminated) = state.terminated.as_ref() {
|
||||
return Some(match terminated.reason.as_deref() {
|
||||
Some(reason) => format!(
|
||||
"the container exited with code {} ({reason})",
|
||||
terminated.exit_code
|
||||
),
|
||||
None => format!("the container exited with code {}", terminated.exit_code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| {
|
||||
cs.iter().find(|c| c.status == "False").map(|c| {
|
||||
(
|
||||
c.type_.clone(),
|
||||
c.reason.clone().unwrap_or_default(),
|
||||
c.message.clone().unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
}) {
|
||||
let detail = [reason, message]
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(": ");
|
||||
return Some(if detail.is_empty() {
|
||||
format!("pod condition {type_} is false")
|
||||
} else {
|
||||
format!("pod condition {type_} is false: {detail}")
|
||||
});
|
||||
}
|
||||
|
||||
status.phase.as_deref().map(|p| format!("the pod is {p}"))
|
||||
}
|
||||
|
||||
/// Verify a label-selected pod and decode it, or reject it.
|
||||
///
|
||||
/// `startup` is supplied by the caller because settling
|
||||
/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer
|
||||
/// must not perform.
|
||||
pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option<VerifiedPod> {
|
||||
if !has_marker(pod.metadata.labels.as_ref()) {
|
||||
return None;
|
||||
}
|
||||
if !annotation_matches(pod.metadata.annotations.as_ref(), identity) {
|
||||
return None;
|
||||
}
|
||||
Some(VerifiedPod {
|
||||
name: pod.metadata.name.clone()?,
|
||||
fence: Fence {
|
||||
uid: pod.metadata.uid.clone()?,
|
||||
resource_version: pod.metadata.resource_version.clone()?,
|
||||
},
|
||||
deletion_marked: pod.metadata.deletion_timestamp.is_some(),
|
||||
startup,
|
||||
recorded_intent: pod
|
||||
.metadata
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|a| a.get(ANNOTATION_CREATE_INTENT))
|
||||
.map(|v| Fingerprint::from_annotation(v)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use k8s_openapi::api::core::v1::{
|
||||
ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting,
|
||||
ContainerStatus, PodStatus,
|
||||
};
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn identity() -> AgentIdentity {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let keys = nostr::Keys::generate();
|
||||
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn base_pod(id: &AgentIdentity) -> Pod {
|
||||
Pod {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(id.pod_name()),
|
||||
uid: Some("uid-1".into()),
|
||||
resource_version: Some("100".into()),
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod {
|
||||
pod.status = Some(PodStatus {
|
||||
phase: Some("Running".into()),
|
||||
container_statuses: Some(vec![ContainerStatus {
|
||||
name: CONTAINER_NAME.into(),
|
||||
state: Some(state),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
});
|
||||
pod
|
||||
}
|
||||
|
||||
fn waiting(reason: &str, message: &str) -> ContainerState {
|
||||
ContainerState {
|
||||
waiting: Some(ContainerStateWaiting {
|
||||
reason: Some(reason.into()),
|
||||
message: Some(message.into()),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_container_is_started() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(
|
||||
base_pod(&id),
|
||||
ContainerState {
|
||||
running: Some(ContainerStateRunning::default()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::Started)
|
||||
);
|
||||
}
|
||||
|
||||
/// Pod phase is not the criterion. A pod in phase `Running` whose
|
||||
/// container never started must NOT read as started, or the reconciler
|
||||
/// no-ops on a pod that will never serve.
|
||||
#[test]
|
||||
fn phase_running_with_waiting_container_is_not_started() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", ""));
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedRecoverable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_container_is_terminated() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(
|
||||
base_pod(&id),
|
||||
ContainerState {
|
||||
terminated: Some(ContainerStateTerminated {
|
||||
exit_code: 0,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::Terminated)
|
||||
);
|
||||
}
|
||||
|
||||
/// A terminal phase with no container status (evicted before the kubelet
|
||||
/// reported) is still terminated — otherwise the residue is never GC'd.
|
||||
#[test]
|
||||
fn terminal_phase_without_container_status_is_terminated() {
|
||||
let id = identity();
|
||||
for phase in ["Succeeded", "Failed"] {
|
||||
let mut pod = base_pod(&id);
|
||||
pod.status = Some(PodStatus {
|
||||
phase: Some(phase.into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::Terminated),
|
||||
"phase {phase}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_image_name_is_provably_broken() {
|
||||
let id = identity();
|
||||
let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref"));
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedProvablyBroken)
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanent pull failures are recognized from the message; anything
|
||||
/// unrecognized stays on the ordinary observational path rather than
|
||||
/// being guessed at.
|
||||
#[test]
|
||||
fn permanent_pull_failures_are_classified() {
|
||||
let id = identity();
|
||||
let cases = [
|
||||
("401 Unauthorized", PullFailure::Unauthorized),
|
||||
("pull access denied", PullFailure::Unauthorized),
|
||||
("manifest unknown", PullFailure::ManifestUnknown),
|
||||
(
|
||||
"no match for platform in manifest",
|
||||
PullFailure::ArchMismatch,
|
||||
),
|
||||
];
|
||||
for (message, expected) in cases {
|
||||
let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message));
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)),
|
||||
"message {message:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let pod = with_container_state(
|
||||
base_pod(&id),
|
||||
waiting("ImagePullBackOff", "dial tcp: i/o timeout"),
|
||||
);
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::Resolved(Startup::NeverStartedRecoverable),
|
||||
"a transient network failure must not be reported as permanent"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kubelet's reason string is a hint, not proof: a config error defers
|
||||
/// to a most-recent Secret read before anything is called broken.
|
||||
#[test]
|
||||
fn config_error_defers_to_a_secret_read() {
|
||||
let id = identity();
|
||||
let mut pod =
|
||||
with_container_state(base_pod(&id), waiting("CreateContainerConfigError", ""));
|
||||
pod.spec = Some(k8s_openapi::api::core::v1::PodSpec {
|
||||
containers: vec![k8s_openapi::api::core::v1::Container {
|
||||
name: CONTAINER_NAME.into(),
|
||||
env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource {
|
||||
secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource {
|
||||
name: "buzz-agent-abc-gen1".into(),
|
||||
optional: Some(false),
|
||||
}),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
decode_startup(&pod),
|
||||
StartupObservation::ConfigErrorPendingSecretCheck {
|
||||
secret_name: "buzz-agent-abc-gen1".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The auto-repair fence: an object that matches our schema but lacks the
|
||||
/// marker, or carries someone else's pubkey, is never verified — so it can
|
||||
/// never be no-op'd against, deleted, or returned as an `agent_id`.
|
||||
#[test]
|
||||
fn unmarked_or_mismatched_objects_fail_verification() {
|
||||
let id = identity();
|
||||
let other = identity();
|
||||
|
||||
let mut unmarked = base_pod(&id);
|
||||
unmarked.metadata.labels = Some(BTreeMap::new());
|
||||
assert!(
|
||||
verify(&unmarked, &id, Startup::Started).is_none(),
|
||||
"unmarked pod verified"
|
||||
);
|
||||
|
||||
let mut wrong_version = base_pod(&id);
|
||||
let mut labels = id.labels();
|
||||
labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string());
|
||||
wrong_version.metadata.labels = Some(labels);
|
||||
assert!(verify(&wrong_version, &id, Startup::Started).is_none());
|
||||
|
||||
let mut mismatched = base_pod(&id);
|
||||
mismatched.metadata.annotations = Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
other.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
assert!(
|
||||
verify(&mismatched, &id, Startup::Started).is_none(),
|
||||
"collision verified"
|
||||
);
|
||||
|
||||
let mut missing = base_pod(&id);
|
||||
missing.metadata.annotations = Some(BTreeMap::new());
|
||||
assert!(verify(&missing, &id, Startup::Started).is_none());
|
||||
|
||||
assert!(
|
||||
verify(&base_pod(&id), &id, Startup::Started).is_some(),
|
||||
"own pod rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fence must come from the observed object, and the deletion mark
|
||||
/// must be read even though the phase says `Running`.
|
||||
#[test]
|
||||
fn verified_pod_carries_the_fence_and_deletion_mark() {
|
||||
let id = identity();
|
||||
let mut pod = base_pod(&id);
|
||||
pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now()));
|
||||
let verified = verify(&pod, &id, Startup::Started).unwrap();
|
||||
assert_eq!(verified.fence.uid, "uid-1");
|
||||
assert_eq!(verified.fence.resource_version, "100");
|
||||
assert!(verified.deletion_marked);
|
||||
}
|
||||
|
||||
/// A pod with no recorded intent reads as `None`, which the classifier
|
||||
/// groups with divergence.
|
||||
#[test]
|
||||
fn missing_intent_annotation_decodes_as_none() {
|
||||
let id = identity();
|
||||
assert!(verify(&base_pod(&id), &id, Startup::Started)
|
||||
.unwrap()
|
||||
.recorded_intent
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// A pull-failure report must name the registry and the immutable
|
||||
/// reference and nothing else — never the kubelet's raw message, which
|
||||
/// can echo a registry token.
|
||||
#[test]
|
||||
fn pull_failure_messages_are_actionable_and_redacted() {
|
||||
let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64));
|
||||
for failure in [
|
||||
PullFailure::Unauthorized,
|
||||
PullFailure::ManifestUnknown,
|
||||
PullFailure::ArchMismatch,
|
||||
] {
|
||||
let msg = pull_failure_message(failure, &image);
|
||||
assert!(msg.contains("ghcr.io"), "{msg}");
|
||||
assert!(msg.contains(&image), "{msg}");
|
||||
for secret in ["Bearer", "password", "nsec1", "token"] {
|
||||
assert!(!msg.contains(secret), "leaked {secret}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_ownership_requires_marker_and_annotation() {
|
||||
let id = identity();
|
||||
let other = identity();
|
||||
let ours = Secret {
|
||||
metadata: ObjectMeta {
|
||||
labels: Some(id.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
id.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(secret_is_ours(&ours, &id));
|
||||
assert!(!secret_is_ours(&ours, &other));
|
||||
|
||||
let mut unmarked = ours.clone();
|
||||
unmarked.metadata.labels = Some(BTreeMap::new());
|
||||
assert!(!secret_is_ours(&unmarked, &id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Pod and Secret construction (spec §Pod shape, §K8s Secrets).
|
||||
//!
|
||||
//! The builder is pure: it turns resolved inputs into API objects and performs
|
||||
//! no I/O, so every normative field is a unit assertion.
|
||||
|
||||
use crate::config::{
|
||||
ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS,
|
||||
WORKSPACE_PATH,
|
||||
};
|
||||
use crate::intent::{Fingerprint, IntentTemplate};
|
||||
use crate::naming::{
|
||||
AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL,
|
||||
};
|
||||
use k8s_openapi::api::core::v1::{
|
||||
Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec,
|
||||
ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume,
|
||||
VolumeMount,
|
||||
};
|
||||
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
|
||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Volume name for the agent's writable workspace.
|
||||
const WORKSPACE_VOLUME: &str = "workspace";
|
||||
|
||||
/// The container name. Fixed: log and exec tooling addresses it by name.
|
||||
const CONTAINER_NAME: &str = "agent";
|
||||
|
||||
/// Build the per-attempt Secret holding the resolved environment.
|
||||
///
|
||||
/// `immutable: true` — the Secret is written once per attempt and never
|
||||
/// updated, which is what lets the pod's `envFrom` reference be treated as an
|
||||
/// atomic binding to this exact payload (§K8s Secrets).
|
||||
pub fn build_secret(
|
||||
identity: &AgentIdentity,
|
||||
namespace: &str,
|
||||
generation: &str,
|
||||
env: BTreeMap<String, String>,
|
||||
) -> Secret {
|
||||
Secret {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(identity.secret_name(generation)),
|
||||
namespace: Some(namespace.to_string()),
|
||||
labels: Some(identity.labels()),
|
||||
annotations: Some(
|
||||
[(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
identity.pubkey_hex().to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
string_data: Some(env),
|
||||
immutable: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the pod for one create attempt.
|
||||
///
|
||||
/// The `fingerprint` is computed from [`intent_template`] over a type that
|
||||
/// cannot contain the generation or any Secret value, so it is stable across
|
||||
/// attempts of the same configuration.
|
||||
pub fn build_pod(
|
||||
identity: &AgentIdentity,
|
||||
cfg: &ProviderConfig,
|
||||
generation: &str,
|
||||
fingerprint: &Fingerprint,
|
||||
) -> Pod {
|
||||
let annotations: BTreeMap<String, String> = [
|
||||
(
|
||||
ANNOTATION_PUBKEY_FULL.to_string(),
|
||||
identity.pubkey_hex().to_string(),
|
||||
),
|
||||
(
|
||||
ANNOTATION_CREATE_INTENT.to_string(),
|
||||
fingerprint.as_str().to_string(),
|
||||
),
|
||||
(ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let requests: BTreeMap<String, Quantity> = [
|
||||
(
|
||||
"cpu".to_string(),
|
||||
Quantity(cfg.resources.cpu_request.clone()),
|
||||
),
|
||||
(
|
||||
"memory".to_string(),
|
||||
Quantity(cfg.resources.memory_request.clone()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let limits: BTreeMap<String, Quantity> = [
|
||||
("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())),
|
||||
(
|
||||
"memory".to_string(),
|
||||
Quantity(cfg.resources.memory_limit.clone()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let container = Container {
|
||||
name: CONTAINER_NAME.to_string(),
|
||||
image: Some(cfg.image.as_str().to_string()),
|
||||
// No `command`/`args`: the image's entrypoint execs the harness as
|
||||
// PID 1 (§Entrypoint). Overriding it here would be how a provider
|
||||
// accidentally puts a shell in front of the signal receiver.
|
||||
env_from: Some(vec![EnvFromSource {
|
||||
secret_ref: Some(SecretEnvSource {
|
||||
name: identity.secret_name(generation),
|
||||
optional: Some(false),
|
||||
}),
|
||||
..Default::default()
|
||||
}]),
|
||||
resources: Some(ResourceRequirements {
|
||||
requests: Some(requests),
|
||||
limits: Some(limits),
|
||||
..Default::default()
|
||||
}),
|
||||
volume_mounts: Some(vec![VolumeMount {
|
||||
name: WORKSPACE_VOLUME.to_string(),
|
||||
mount_path: WORKSPACE_PATH.to_string(),
|
||||
..Default::default()
|
||||
}]),
|
||||
security_context: Some(SecurityContext {
|
||||
allow_privilege_escalation: Some(false),
|
||||
capabilities: Some(Capabilities {
|
||||
drop: Some(vec!["ALL".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
// `readOnlyRootFilesystem` is deliberately unset: the sprig
|
||||
// toolchain writes outside the workspace mount (§Pod shape).
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Pod {
|
||||
metadata: ObjectMeta {
|
||||
name: Some(identity.pod_name()),
|
||||
namespace: Some(cfg.namespace.clone()),
|
||||
labels: Some(identity.labels()),
|
||||
annotations: Some(annotations),
|
||||
..Default::default()
|
||||
},
|
||||
spec: Some(PodSpec {
|
||||
containers: vec![container],
|
||||
restart_policy: Some(RESTART_POLICY.to_string()),
|
||||
termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS),
|
||||
// The agent runs prompted, untrusted code while holding an nsec;
|
||||
// an ambient ServiceAccount token would be an API-stealable
|
||||
// credential it never needs (§Pod shape hardening). Naming a
|
||||
// service account selects a scheduling/RBAC identity and MUST NOT
|
||||
// re-enable token mounting.
|
||||
automount_service_account_token: Some(false),
|
||||
service_account_name: cfg.service_account.clone(),
|
||||
security_context: Some(PodSecurityContext {
|
||||
run_as_non_root: Some(true),
|
||||
run_as_user: Some(RUN_AS_UID),
|
||||
run_as_group: Some(RUN_AS_GID),
|
||||
fs_group: Some(RUN_AS_GID),
|
||||
seccomp_profile: Some(SeccompProfile {
|
||||
type_: "RuntimeDefault".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
volumes: Some(vec![Volume {
|
||||
name: WORKSPACE_VOLUME.to_string(),
|
||||
empty_dir: Some(EmptyDirVolumeSource::default()),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The create-intent template for this configuration (§Deploy State Machine).
|
||||
pub fn intent_template(
|
||||
cfg: &ProviderConfig,
|
||||
env_keys: impl IntoIterator<Item = String>,
|
||||
) -> IntentTemplate {
|
||||
IntentTemplate::new(
|
||||
&cfg.namespace,
|
||||
&cfg.image,
|
||||
&cfg.resources,
|
||||
cfg.service_account.as_deref(),
|
||||
env_keys,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config;
|
||||
|
||||
fn identity() -> AgentIdentity {
|
||||
use nostr::nips::nip19::ToBech32;
|
||||
let keys = nostr::Keys::generate();
|
||||
AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn provider_config() -> ProviderConfig {
|
||||
config::parse(&serde_json::json!({
|
||||
"namespace": "buzz-agents-test",
|
||||
"image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)),
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn pod() -> Pod {
|
||||
let cfg = provider_config();
|
||||
build_pod(
|
||||
&identity(),
|
||||
&cfg,
|
||||
"gen00001",
|
||||
&intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn spec(pod: &Pod) -> &PodSpec {
|
||||
pod.spec.as_ref().unwrap()
|
||||
}
|
||||
|
||||
/// Every hardening default from §Pod shape, asserted individually so a
|
||||
/// dropped one names itself.
|
||||
#[test]
|
||||
fn hardening_defaults_are_all_present() {
|
||||
let pod = pod();
|
||||
let spec = spec(&pod);
|
||||
assert_eq!(spec.automount_service_account_token, Some(false));
|
||||
|
||||
let sc = spec
|
||||
.security_context
|
||||
.as_ref()
|
||||
.expect("pod security context");
|
||||
assert_eq!(sc.run_as_non_root, Some(true));
|
||||
assert_eq!(sc.run_as_user, Some(RUN_AS_UID));
|
||||
assert_ne!(sc.run_as_user, Some(0), "root UID");
|
||||
assert_eq!(sc.run_as_group, Some(RUN_AS_GID));
|
||||
assert_eq!(
|
||||
sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()),
|
||||
Some("RuntimeDefault")
|
||||
);
|
||||
|
||||
let csc = spec.containers[0]
|
||||
.security_context
|
||||
.as_ref()
|
||||
.expect("container sc");
|
||||
assert_eq!(csc.allow_privilege_escalation, Some(false));
|
||||
assert_eq!(
|
||||
csc.capabilities.as_ref().and_then(|c| c.drop.clone()),
|
||||
Some(vec!["ALL".to_string()])
|
||||
);
|
||||
assert_ne!(csc.privileged, Some(true));
|
||||
}
|
||||
|
||||
/// The forbidden host-namespace and hostPath escapes, asserted as absence.
|
||||
#[test]
|
||||
fn never_uses_host_namespaces_or_host_paths() {
|
||||
let pod = pod();
|
||||
let spec = spec(&pod);
|
||||
assert!(spec.host_pid.is_none() || spec.host_pid == Some(false));
|
||||
assert!(spec.host_network.is_none() || spec.host_network == Some(false));
|
||||
assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false));
|
||||
for volume in spec.volumes.as_ref().unwrap() {
|
||||
assert!(
|
||||
volume.host_path.is_none(),
|
||||
"hostPath volume {}",
|
||||
volume.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Never` only. `OnFailure` is gated on the harness exit-code contract
|
||||
/// *and* a crash-loop classification row (`:1121-1139`); the config layer
|
||||
/// refuses `inactivity_seconds: 0` so this arm is unreachable, and the
|
||||
/// assertion keeps it that way.
|
||||
#[test]
|
||||
fn restart_policy_is_never() {
|
||||
assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never"));
|
||||
}
|
||||
|
||||
/// 60s, not Kubernetes' default 30s — which would SIGKILL the harness
|
||||
/// mid-drain and leave presence stale-online (§Pod shape).
|
||||
#[test]
|
||||
fn declares_the_sixty_second_grace_budget() {
|
||||
assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60));
|
||||
}
|
||||
|
||||
/// The pod must not override the image's entrypoint: the image execs the
|
||||
/// harness as PID 1, and a `command` here is how a shell ends up in front
|
||||
/// of the signal receiver (§Entrypoint).
|
||||
#[test]
|
||||
fn does_not_override_the_image_entrypoint() {
|
||||
let pod = pod();
|
||||
let container = &spec(&pod).containers[0];
|
||||
assert!(container.command.is_none(), "overrode the entrypoint");
|
||||
assert!(container.args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_is_an_emptydir_mounted_at_home() {
|
||||
let pod = pod();
|
||||
let spec = spec(&pod);
|
||||
let volume = &spec.volumes.as_ref().unwrap()[0];
|
||||
assert!(volume.empty_dir.is_some());
|
||||
assert!(volume.persistent_volume_claim.is_none());
|
||||
let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0];
|
||||
assert_eq!(mount.name, volume.name);
|
||||
assert_eq!(mount.mount_path, WORKSPACE_PATH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_carry_the_configured_requests_and_limits() {
|
||||
let mut cfg = provider_config();
|
||||
cfg.resources.cpu_limit = "4".into();
|
||||
let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
|
||||
let r = spec(&pod).containers[0].resources.as_ref().unwrap();
|
||||
assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into()));
|
||||
assert_eq!(
|
||||
r.requests.as_ref().unwrap()["memory"],
|
||||
Quantity("2Gi".into())
|
||||
);
|
||||
assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into()));
|
||||
assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into()));
|
||||
}
|
||||
|
||||
/// `envFrom` must point at this attempt's Secret and must NOT be optional:
|
||||
/// an optional reference starts the container with no identity at all,
|
||||
/// turning a missing-Secret bug into an agent that silently cannot
|
||||
/// authenticate.
|
||||
#[test]
|
||||
fn env_from_references_this_attempts_secret_and_is_required() {
|
||||
let id = identity();
|
||||
let cfg = provider_config();
|
||||
let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f"));
|
||||
let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0];
|
||||
let secret_ref = source.secret_ref.as_ref().unwrap();
|
||||
assert_eq!(secret_ref.name, id.secret_name("gen00042"));
|
||||
assert_eq!(secret_ref.optional, Some(false));
|
||||
assert!(source.config_map_ref.is_none());
|
||||
}
|
||||
|
||||
/// Identity, ownership marker, and the recorded intent all travel on the
|
||||
/// pod — the GC and reconciliation fences read exactly these.
|
||||
#[test]
|
||||
fn pod_carries_identity_marker_and_recorded_intent() {
|
||||
let id = identity();
|
||||
let cfg = provider_config();
|
||||
let fp = intent_template(&cfg, ["A".to_string()]).fingerprint();
|
||||
let pod = build_pod(&id, &cfg, "g", &fp);
|
||||
let meta = &pod.metadata;
|
||||
assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str()));
|
||||
assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test"));
|
||||
assert_eq!(meta.labels.as_ref().unwrap(), &id.labels());
|
||||
let ann = meta.annotations.as_ref().unwrap();
|
||||
assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex());
|
||||
assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str());
|
||||
assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str());
|
||||
}
|
||||
|
||||
/// The Secret is immutable and marker-bearing: immutability is what makes
|
||||
/// the pod's `envFrom` an atomic binding, and the marker is what GC
|
||||
/// requires before it will delete anything.
|
||||
#[test]
|
||||
fn secret_is_immutable_marked_and_holds_the_env() {
|
||||
let id = identity();
|
||||
let env: BTreeMap<String, String> =
|
||||
[("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into();
|
||||
let secret = build_secret(&id, "ns", "gen1", env.clone());
|
||||
assert_eq!(secret.immutable, Some(true));
|
||||
assert_eq!(secret.string_data.as_ref().unwrap(), &env);
|
||||
assert_eq!(
|
||||
secret.metadata.name.as_deref(),
|
||||
Some(id.secret_name("gen1").as_str())
|
||||
);
|
||||
assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels());
|
||||
assert_eq!(
|
||||
secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL],
|
||||
id.pubkey_hex()
|
||||
);
|
||||
// `data` must stay unset — setting both is an apiserver rejection.
|
||||
assert!(secret.data.is_none());
|
||||
}
|
||||
|
||||
/// Naming a service account selects a scheduling identity; it must not
|
||||
/// re-enable token mounting (§Pod shape hardening, `:1221-1225`).
|
||||
#[test]
|
||||
fn service_account_does_not_re_enable_token_mounting() {
|
||||
let mut cfg = provider_config();
|
||||
cfg.service_account = Some("agent-sa".into());
|
||||
let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f"));
|
||||
assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa"));
|
||||
assert_eq!(spec(&pod).automount_service_account_token, Some(false));
|
||||
}
|
||||
|
||||
/// The fingerprint recorded on the pod is the one the classifier will
|
||||
/// recompute — pinned end-to-end so a builder change that forgets to feed
|
||||
/// the template a field cannot pass silently.
|
||||
#[test]
|
||||
fn recorded_fingerprint_matches_a_fresh_computation() {
|
||||
let cfg = provider_config();
|
||||
let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()];
|
||||
let fp = intent_template(&cfg, keys.clone()).fingerprint();
|
||||
let pod = build_pod(&identity(), &cfg, "gen-a", &fp);
|
||||
let recorded = Fingerprint::from_annotation(
|
||||
&pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT],
|
||||
);
|
||||
assert_eq!(recorded, intent_template(&cfg, keys).fingerprint());
|
||||
}
|
||||
|
||||
/// Two attempts differing only in generation must record the *same*
|
||||
/// fingerprint, or the divergence discriminator fires on every deploy and
|
||||
/// the never-started row deletes healthy pending pods.
|
||||
#[test]
|
||||
fn generation_does_not_change_the_recorded_fingerprint() {
|
||||
let cfg = provider_config();
|
||||
let keys = ["BUZZ_RELAY_URL".to_string()];
|
||||
let a = intent_template(&cfg, keys.clone()).fingerprint();
|
||||
let b = intent_template(&cfg, keys).fingerprint();
|
||||
let id = identity();
|
||||
let pod_a = build_pod(&id, &cfg, "gen-1", &a);
|
||||
let pod_b = build_pod(&id, &cfg, "gen-2", &b);
|
||||
let read =
|
||||
|p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone();
|
||||
assert_eq!(read(&pod_a), read(&pod_b));
|
||||
// ...while the Secret they reference differs.
|
||||
let secret_of = |p: &Pod| {
|
||||
spec(p).containers[0].env_from.as_ref().unwrap()[0]
|
||||
.secret_ref
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.name
|
||||
.clone()
|
||||
};
|
||||
assert_ne!(secret_of(&pod_a), secret_of(&pod_b));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
//! The stdin/stdout JSON protocol (spec §Provider Protocol).
|
||||
//!
|
||||
//! One process per operation: one JSON object in, one JSON object out.
|
||||
//! These types are this provider's view of the contract; the golden fixtures
|
||||
//! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The wire-contract version this provider speaks (spec §Info).
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// Request envelope. `op` discriminates; unknown ops are an in-band error.
|
||||
///
|
||||
/// `request_id` is deliberately absent from every variant. The desktop sends
|
||||
/// it, but the exchange is one request and one response per process, so there
|
||||
/// is nothing to correlate and nothing in the response schema to echo it into
|
||||
/// (`:387`, `:416` — neither response shape carries it). Serde ignores it on
|
||||
/// the way in, so a caller that sends it is accepted; typing it would only
|
||||
/// create a field nothing reads.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
pub enum Request {
|
||||
Info,
|
||||
Deploy(Box<DeployRequest>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployRequest {
|
||||
pub agent: AgentPayload,
|
||||
#[serde(default)]
|
||||
pub provider_config: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The agent payload (spec §Deploy).
|
||||
///
|
||||
/// Only the fields this binding actually consumes are typed. `name` (display
|
||||
/// name), `model`, `provider`, and `turn_timeout_seconds` are deliberately
|
||||
/// absent: object names derive from the pubkey, not the display name
|
||||
/// (`:1150-1152`), the model/provider pair arrives already resolved inside
|
||||
/// `launch`, and the timeout is ignored upstream — typing any of them would
|
||||
/// invite a provider-side remap the spec forbids.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentPayload {
|
||||
pub relay_url: String,
|
||||
pub private_key_nsec: String,
|
||||
#[serde(default)]
|
||||
pub auth_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub respond_to: Option<String>,
|
||||
#[serde(default)]
|
||||
pub respond_to_allowlist: Option<Vec<String>>,
|
||||
/// User env, already merged global < persona < agent by the desktop and
|
||||
/// already stripped of reserved keys. Superseded by `launch.env` when
|
||||
/// `launch` is present — a provider MUST NOT re-merge it on top
|
||||
/// (§Launch data, precedence tier 2).
|
||||
#[serde(default)]
|
||||
pub env_vars: BTreeMap<String, String>,
|
||||
/// The desktop-resolved launch contract. Absent only from a desktop
|
||||
/// predating Known Defect 3's fix.
|
||||
#[serde(default)]
|
||||
pub launch: Option<LaunchBlock>,
|
||||
}
|
||||
|
||||
/// Desktop-resolved launch data (spec §Launch data).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct LaunchBlock {
|
||||
/// Command *name*, resolved against the image's PATH — never a host path.
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Layered env: baked → runtime metadata → definition → global → persona
|
||||
/// → agent. Precedence tier 2.
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Overridable behavior defaults. Precedence tier 1 — user env beats
|
||||
/// these, matching the local spawn.
|
||||
#[serde(default)]
|
||||
pub policy_env: BTreeMap<String, String>,
|
||||
/// Resolved workspace owner (hex). The respond-to gate's one
|
||||
/// irreducible input; without it or `auth_tag` the harness cannot match
|
||||
/// `!shutdown`.
|
||||
#[serde(default)]
|
||||
pub owner_pubkey: Option<String>,
|
||||
}
|
||||
|
||||
/// Response envelope. Serialized flat — `{"ok": true, …}` — because the
|
||||
/// desktop reads `ok`, `error`, and `agent_id` off the top level.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Response {
|
||||
Info(InfoResponse),
|
||||
Deploy(DeployResponse),
|
||||
Error(ErrorResponse),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InfoResponse {
|
||||
pub ok: bool,
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
pub protocol_version: u32,
|
||||
pub description: &'static str,
|
||||
pub config_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeployResponse {
|
||||
pub ok: bool,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Response::Error(ErrorResponse {
|
||||
ok: false,
|
||||
error: message.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The provider's self-description (spec §Info). Pure — no cluster
|
||||
/// contact — because the desktop calls it to render the config form
|
||||
/// before a kubeconfig is known to exist.
|
||||
pub fn info() -> Self {
|
||||
Response::Info(InfoResponse {
|
||||
ok: true,
|
||||
name: "kubernetes",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
description: "Runs agents as pods in a Kubernetes cluster",
|
||||
config_schema: crate::config::config_schema(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn deployed(agent_id: impl Into<String>) -> Self {
|
||||
Response::Deploy(DeployResponse {
|
||||
ok: true,
|
||||
agent_id: agent_id.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_info_request() {
|
||||
let r: Request = serde_json::from_str(r#"{"op":"info","request_id":"abc"}"#).unwrap();
|
||||
assert!(matches!(r, Request::Info));
|
||||
}
|
||||
|
||||
/// The desktop sends `request_id` on every call, but the exchange is 1:1
|
||||
/// per process — a provider that hard-required it would fail a
|
||||
/// conforming-but-minimal caller for no safety gain.
|
||||
#[test]
|
||||
fn request_id_is_optional() {
|
||||
let r: Request = serde_json::from_str(r#"{"op":"info"}"#).unwrap();
|
||||
assert!(matches!(r, Request::Info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_op() {
|
||||
assert!(serde_json::from_str::<Request>(r#"{"op":"undeploy"}"#).is_err());
|
||||
}
|
||||
|
||||
/// Payload fields this binding does not consume must not break parsing:
|
||||
/// the desktop sends `model`, `provider`, `system_prompt` and more, and a
|
||||
/// provider that rejected them would break on every real deploy.
|
||||
#[test]
|
||||
fn ignores_unconsumed_payload_fields() {
|
||||
let json = r#"{
|
||||
"op":"deploy","request_id":"r1",
|
||||
"agent":{
|
||||
"name":"a","relay_url":"wss://r","private_key_nsec":"nsec1x",
|
||||
"model":"gpt-5","provider":"openai","system_prompt":"hi",
|
||||
"turn_timeout_seconds":30,"parallelism":10,
|
||||
"agent_command":"goose","agent_args":[]
|
||||
},
|
||||
"provider_config":{"namespace":"ns"}
|
||||
}"#;
|
||||
let r: Request = serde_json::from_str(json).unwrap();
|
||||
let Request::Deploy(d) = r else {
|
||||
panic!("wrong op")
|
||||
};
|
||||
assert_eq!(d.agent.relay_url, "wss://r");
|
||||
assert!(d.agent.launch.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_launch_block() {
|
||||
let json = r#"{
|
||||
"op":"deploy",
|
||||
"agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x",
|
||||
"launch":{
|
||||
"command":"goose","args":["run","--x"],
|
||||
"env":{"GOOSE_MODEL":"m"},
|
||||
"policy_env":{"GOOSE_MODE":"auto"},
|
||||
"owner_pubkey":"deadbeef"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let Request::Deploy(d) = serde_json::from_str::<Request>(json).unwrap() else {
|
||||
panic!("wrong op")
|
||||
};
|
||||
let l = d.agent.launch.unwrap();
|
||||
assert_eq!(l.command.as_deref(), Some("goose"));
|
||||
assert_eq!(l.args, ["run", "--x"]);
|
||||
assert_eq!(l.env["GOOSE_MODEL"], "m");
|
||||
assert_eq!(l.policy_env["GOOSE_MODE"], "auto");
|
||||
assert_eq!(l.owner_pubkey.as_deref(), Some("deadbeef"));
|
||||
}
|
||||
|
||||
/// A null `owner_pubkey`/`auth_tag` must parse (the refusal is a policy
|
||||
/// decision made later, with a specific message), not fail as a type error.
|
||||
#[test]
|
||||
fn null_owner_fields_parse() {
|
||||
let json = r#"{"op":"deploy","agent":{
|
||||
"relay_url":"wss://r","private_key_nsec":"nsec1x","auth_tag":null,
|
||||
"launch":{"owner_pubkey":null}
|
||||
}}"#;
|
||||
let Request::Deploy(d) = serde_json::from_str::<Request>(json).unwrap() else {
|
||||
panic!("wrong op")
|
||||
};
|
||||
assert!(d.agent.auth_tag.is_none());
|
||||
assert!(d.agent.launch.unwrap().owner_pubkey.is_none());
|
||||
}
|
||||
|
||||
/// The desktop reads `ok`/`error`/`agent_id` off the top level, so the
|
||||
/// enum must serialize flat with no variant tag.
|
||||
#[test]
|
||||
fn responses_serialize_flat() {
|
||||
let v = serde_json::to_value(Response::deployed("buzz-agent-abc")).unwrap();
|
||||
assert_eq!(v["ok"], true);
|
||||
assert_eq!(v["agent_id"], "buzz-agent-abc");
|
||||
assert!(v.get("Deploy").is_none());
|
||||
|
||||
let v = serde_json::to_value(Response::error("boom")).unwrap();
|
||||
assert_eq!(v["ok"], false);
|
||||
assert_eq!(v["error"], "boom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Provider wire fixtures
|
||||
|
||||
The shared arbiter for the stdin/stdout contract between the desktop
|
||||
(`agents_deploy.rs`) and this provider (spec §Provider Protocol).
|
||||
|
||||
Each `*.request.json` is a request the desktop can emit; each matching
|
||||
`*.response.json` is the exact response this provider produces for it. The
|
||||
provider side is asserted by `tests/wire_fixtures.rs`; the desktop side should
|
||||
assert that its emitted payloads parse as the corresponding request.
|
||||
|
||||
Three rules keep these useful rather than decorative:
|
||||
|
||||
* **Requests are recorded, not invented.** A fixture that no caller emits
|
||||
tests a contract nobody has. "Recorded" means *executed and transcribed* —
|
||||
`deploy-full-launch.request.json` is the output of the desktop's real
|
||||
`build_launch_block` → `deploy_payload_json` path, not a shape derived by
|
||||
reading those functions. Deriving it is how this fixture acquired four
|
||||
impossible values at once: a `respond_to` that was a pubkey where the
|
||||
desktop serializes a kebab-case `RespondTo` enum, allowlist and owner
|
||||
values failing `validate_respond_to_allowlist`'s 64-hex rule
|
||||
(`types.rs:897`), an invented `BUZZ_ACP_PARALLELISM` where the emitter
|
||||
writes `BUZZ_ACP_AGENTS` (`runtime.rs:729`), and a `launch.env` key from
|
||||
no layer of `resolve_effective_harness_descriptor`.
|
||||
* **The provider cannot police this file, so the desktop must.** Every field
|
||||
above is one this provider is deliberately indifferent to — `respond_to` is
|
||||
an opaque `Option<String>`, the allowlist an opaque `Vec<String>`,
|
||||
`policy_env` an arbitrary map — so `the_full_desktop_payload_is_accepted`
|
||||
passes on invented data exactly as happily as on recorded data. The
|
||||
enforcement is the desktop's whole-object equality test, which *builds* the
|
||||
payload and compares it to this file. A completeness guard (the case-list
|
||||
directory scan in `wire_fixtures.rs`) stops a case from going missing; it
|
||||
cannot tell you a case is false.
|
||||
* **Responses are byte-compared after key-sorted re-serialization**, so a
|
||||
field rename or a type change fails here rather than in a desktop that
|
||||
silently reads `undefined`.
|
||||
|
||||
`deploy-*` fixtures cover only responses reachable without a cluster —
|
||||
refusals and malformed input. A successful deploy needs an apiserver and is
|
||||
covered by the conformance suite, not by a static fixture.
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-6",
|
||||
"agent": {
|
||||
"agent_args": [],
|
||||
"agent_command": "goose",
|
||||
"auth_tag": "tag-1",
|
||||
"env_vars": {
|
||||
"USER_KEY": "user-value"
|
||||
},
|
||||
"idle_timeout_seconds": null,
|
||||
"launch": {
|
||||
"args": [
|
||||
"acp"
|
||||
],
|
||||
"command": "goose",
|
||||
"env": {
|
||||
"GOOSE_MODEL": "gpt-5",
|
||||
"GOOSE_PROVIDER": "openai",
|
||||
"USER_KEY": "user-value"
|
||||
},
|
||||
"owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"policy_env": {
|
||||
"BUZZ_ACP_AGENTS": "10",
|
||||
"BUZZ_ACP_DISPLAY_NAME": "worker",
|
||||
"BUZZ_ACP_LAZY_POOL": "true",
|
||||
"BUZZ_ACP_MODEL": "gpt-5",
|
||||
"BUZZ_ACP_RELAY_OBSERVER": "true",
|
||||
"BUZZ_ACP_SESSION_TITLE": "worker",
|
||||
"GOOSE_MODE": "auto"
|
||||
}
|
||||
},
|
||||
"max_turn_duration_seconds": null,
|
||||
"model": "gpt-5",
|
||||
"name": "worker",
|
||||
"parallelism": 10,
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"provider": "openai",
|
||||
"relay_url": "wss://relay.example",
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
],
|
||||
"system_prompt": null,
|
||||
"turn_timeout_seconds": 300
|
||||
},
|
||||
"provider_config": {
|
||||
"namespace": "buzz-agents-test",
|
||||
"image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"inactivity_seconds": 3600
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-5",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"deploy refused: neither auth_tag nor launch.owner_pubkey resolved — without an owner the agent cannot honor !shutdown"}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-3",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "tag-1",
|
||||
"provider": " relay-mesh "
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-2",
|
||||
"agent": {
|
||||
"name": "mesh-agent",
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "tag-1",
|
||||
"provider": "relay-mesh"
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"op": "deploy",
|
||||
"request_id": "req-4",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "tag-1"
|
||||
},
|
||||
"provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig:latest"}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"ok":false,"error":"provider_config.image \"ghcr.io/block/buzz-sprig:latest\" is not digest-pinned: a tag is a mutable pointer, and this object runs with the agent's private key. Use name@sha256:<64 hex chars>"}
|
||||
@@ -0,0 +1 @@
|
||||
{"op":"info","request_id":"req-1"}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Golden wire fixtures (spec §Provider Protocol).
|
||||
//!
|
||||
//! These drive the **built binary** over a real pipe rather than calling an
|
||||
//! in-process function: the contract the desktop depends on is
|
||||
//! `stdin → one JSON object on stdout → exit code`, and an in-process test
|
||||
//! would assert the shape of a value while skipping the three things that
|
||||
//! actually break — the process writing nothing, writing two objects, or
|
||||
//! signalling the outcome through the exit code.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-wire")
|
||||
}
|
||||
|
||||
/// Feed one request to the binary; return `(stdout, exit code)`.
|
||||
fn run(request: &str) -> (String, i32) {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-backend-kubernetes"))
|
||||
// A kubeconfig that does not exist, so a fixture that accidentally
|
||||
// reaches the cluster fails loudly here instead of depending on
|
||||
// whatever cluster the developer is pointed at.
|
||||
.env("KUBECONFIG", "/nonexistent/kubeconfig-for-fixture-tests")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("could not run the provider binary");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("no stdin")
|
||||
.write_all(request.as_bytes())
|
||||
.expect("could not write the request");
|
||||
let out = child.wait_with_output().expect("provider did not exit");
|
||||
(
|
||||
String::from_utf8(out.stdout).expect("stdout was not UTF-8"),
|
||||
out.status.code().unwrap_or(-1),
|
||||
)
|
||||
}
|
||||
|
||||
fn read(name: &str) -> String {
|
||||
std::fs::read_to_string(fixtures().join(name))
|
||||
.unwrap_or_else(|e| panic!("could not read fixture {name}: {e}"))
|
||||
}
|
||||
|
||||
/// Every response fixture, byte-compared after key-sorted re-serialization so
|
||||
/// a field rename fails here rather than in a desktop reading `undefined`.
|
||||
#[test]
|
||||
fn responses_match_their_fixtures() {
|
||||
let cases = [
|
||||
"deploy-relay-mesh",
|
||||
"deploy-relay-mesh-padded",
|
||||
"deploy-tag-image",
|
||||
"deploy-no-owner",
|
||||
];
|
||||
// The list must cover every response fixture on disk. A literal array is
|
||||
// never empty, so `!is_empty()` would assert nothing; what can actually go
|
||||
// wrong is a fixture added to the directory and never added here, which
|
||||
// reads as a passing suite that exercises one case fewer than it appears to.
|
||||
let mut on_disk: Vec<String> = std::fs::read_dir(fixtures())
|
||||
.expect("could not read the fixture directory")
|
||||
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
|
||||
.filter_map(|name| Some(name.strip_suffix(".response.json")?.to_string()))
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
let mut listed: Vec<String> = cases.iter().map(|c| c.to_string()).collect();
|
||||
listed.sort();
|
||||
assert_eq!(on_disk, listed, "response fixtures and cases disagree");
|
||||
|
||||
for case in cases {
|
||||
let (stdout, code) = run(&read(&format!("{case}.request.json")));
|
||||
assert_eq!(code, 0, "{case}: a produced response must exit 0");
|
||||
|
||||
// Exactly one object, terminated by exactly one newline. Two responses
|
||||
// would leave the desktop's reader holding a second one forever.
|
||||
assert_eq!(
|
||||
stdout.matches('\n').count(),
|
||||
1,
|
||||
"{case}: expected exactly one line, got {stdout:?}"
|
||||
);
|
||||
|
||||
let actual: serde_json::Value =
|
||||
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{case}: {e}: {stdout:?}"));
|
||||
let expected: serde_json::Value =
|
||||
serde_json::from_str(&read(&format!("{case}.response.json"))).unwrap();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"{case}: response drifted from its fixture"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `info` is checked on the fields the desktop reads rather than byte-for-byte:
|
||||
/// the namespace default is randomly generated per call (§K8s Namespace), so a
|
||||
/// golden copy of it would be a test that fails every run.
|
||||
#[test]
|
||||
fn info_response_carries_the_contract_fields() {
|
||||
let (stdout, code) = run(&read("info.request.json"));
|
||||
assert_eq!(code, 0);
|
||||
let info: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(info["ok"], true);
|
||||
assert_eq!(info["protocol_version"], 1);
|
||||
assert_eq!(info["name"], "kubernetes");
|
||||
let schema = &info["config_schema"];
|
||||
assert_eq!(
|
||||
schema["required"],
|
||||
serde_json::json!(["namespace", "image"])
|
||||
);
|
||||
let default = schema["properties"]["namespace"]["default"]
|
||||
.as_str()
|
||||
.expect("no generated namespace default");
|
||||
assert!(
|
||||
default.starts_with("buzz-agents-"),
|
||||
"unexpected namespace default: {default}"
|
||||
);
|
||||
let image_default = schema["properties"]["image"]["default"]
|
||||
.as_str()
|
||||
.expect("no image default");
|
||||
assert!(
|
||||
image_default.starts_with("ghcr.io/block/buzz-sprig:")
|
||||
&& image_default.contains("@sha256:"),
|
||||
"unexpected image default: {image_default}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The desktop's richest payload must parse. No response fixture: this one
|
||||
/// reaches the cluster, so its outcome depends on a kubeconfig. What it
|
||||
/// guards is that every field the desktop sends is *accepted* — a payload the
|
||||
/// provider rejects at parse time is a deploy that never starts.
|
||||
#[test]
|
||||
fn the_full_desktop_payload_is_accepted() {
|
||||
let (stdout, code) = run(&read("deploy-full-launch.request.json"));
|
||||
assert_eq!(code, 0);
|
||||
let response: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
let error = response["error"].as_str().unwrap_or_default();
|
||||
// It fails — there is no cluster — but it must fail at the *connection*,
|
||||
// having accepted every field above it.
|
||||
assert!(
|
||||
error.contains("kubeconfig"),
|
||||
"the full payload was rejected before reaching the cluster: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sami's pre-registered respond-to matrix, driven through the built binary.
|
||||
///
|
||||
/// `build_env` runs at `main.rs:124`, `client::connect` at `:132`, so under a
|
||||
/// kubeconfig that cannot exist the error string *is* the ordering assertion:
|
||||
/// "kubeconfig" means the gate passed and we reached the cluster, anything
|
||||
/// else means we refused before writing a Secret. A test asserting only
|
||||
/// `ok: false` would pass on the connection error and prove nothing.
|
||||
///
|
||||
/// The cases are applied to the real full-launch request so each one differs
|
||||
/// from a known-good deploy in exactly the field under test.
|
||||
#[test]
|
||||
fn the_respond_to_gate_matches_the_harness_acceptance_surface() {
|
||||
let key_a = "a".repeat(64);
|
||||
let padded_upper = format!(" {} ", "A".repeat(64));
|
||||
// (name, respond_to, allowlist, must reach the cluster)
|
||||
let cases: Vec<(&str, &str, Option<Vec<String>>, bool)> = vec![
|
||||
("allowlist + []", "allowlist", Some(vec![]), false),
|
||||
("allowlist + absent", "allowlist", None, false),
|
||||
(
|
||||
"allowlist + junk",
|
||||
"allowlist",
|
||||
Some(vec!["beefcafe".into()]),
|
||||
false,
|
||||
),
|
||||
("unparseable mode", "npub1abc", None, false),
|
||||
("padded mode", " allowlist ", None, false),
|
||||
(
|
||||
"allowlist + two valid",
|
||||
"allowlist",
|
||||
Some(vec![key_a.clone(), "b".repeat(64)]),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"owner-only + junk list",
|
||||
"owner-only",
|
||||
Some(vec!["beefcafe".into()]),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"allowlist + padded upper",
|
||||
"allowlist",
|
||||
Some(vec![padded_upper]),
|
||||
true,
|
||||
),
|
||||
("nobody", "nobody", None, true),
|
||||
("anyone", "anyone", None, true),
|
||||
];
|
||||
|
||||
let base: serde_json::Value =
|
||||
serde_json::from_str(&read("deploy-full-launch.request.json")).unwrap();
|
||||
|
||||
for (name, mode, allowlist, reaches_cluster) in cases {
|
||||
let mut request = base.clone();
|
||||
let agent = &mut request["agent"];
|
||||
agent["respond_to"] = serde_json::json!(mode);
|
||||
agent["respond_to_allowlist"] = match &allowlist {
|
||||
Some(list) => serde_json::json!(list),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
let (stdout, code) = run(&request.to_string());
|
||||
assert_eq!(code, 0, "{name}: provider did not exit cleanly");
|
||||
let response: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
let error = response["error"].as_str().unwrap_or_default();
|
||||
let reached = error.contains("kubeconfig");
|
||||
|
||||
assert_eq!(
|
||||
reached, reaches_cluster,
|
||||
"{name}: expected reaches_cluster={reaches_cluster}, got error: {error}"
|
||||
);
|
||||
if !reaches_cluster {
|
||||
assert!(
|
||||
error.contains("deploy refused"),
|
||||
"{name}: refused, but not by the gate: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
[package]
|
||||
name = "buzz-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Agent-first CLI for Buzz relay"
|
||||
|
||||
[lib]
|
||||
name = "buzz_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI argument parsing — derive macros + env var support (BUZZ_API_TOKEN auto-wired)
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# HTTP client — async REST calls to the relay
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
|
||||
# Async runtime — tokio macros + multi-thread for reqwest
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
# Serialization — JSON body building and response passthrough
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Structured error types with exit code mapping
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Nostr event signing — used in `buzz auth`, auto-mint, and signed event writes
|
||||
nostr = { workspace = true }
|
||||
|
||||
# UUID parsing for validate_uuid + event building
|
||||
uuid = { workspace = true }
|
||||
|
||||
# RFC3339 observer timestamps for owner-reviewed agent draft requests
|
||||
chrono = { workspace = true }
|
||||
|
||||
# Typed event builders for all write operations
|
||||
buzz-sdk = { workspace = true }
|
||||
buzz-core = { workspace = true }
|
||||
|
||||
# Base64 encoding — NIP-98 event serialization for Authorization header
|
||||
base64 = "0.22"
|
||||
|
||||
# SHA-256 — NIP-98 payload hash tag, Blossom file hash, mem patch base-hash
|
||||
sha2 = "0.11"
|
||||
|
||||
# Unified-diff parser and strict applier — `mem patch`
|
||||
diffy = "0.5"
|
||||
|
||||
# Hex encoding — SHA-256 hash output for Blossom uploads
|
||||
hex = { workspace = true }
|
||||
|
||||
# Byte buffers returned by authenticated media downloads
|
||||
bytes = "1"
|
||||
|
||||
# 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
|
||||
buzz-persona = { path = "../buzz-persona" }
|
||||
|
||||
# Platform app-data dir resolution — locates the desktop app's
|
||||
# channel-templates.json store for `channels create --template`
|
||||
dirs = "6"
|
||||
|
||||
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
|
||||
buzz-ws-client = { path = "../buzz-ws-client" }
|
||||
|
||||
# Explicit rustls dep with ring provider — required to install the process-level
|
||||
# CryptoProvider at startup. Without this the standalone `buzz` binary panics when
|
||||
# a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo
|
||||
# invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to
|
||||
# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
# Random number generation — full jitter for exponential backoff in with_retry
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Scratch files for channel-templates.json fixtures in tests
|
||||
tempfile = "3"
|
||||
# Minimal HTTP test server for retry/policy integration tests
|
||||
axum = { workspace = true }
|
||||
@@ -0,0 +1,184 @@
|
||||
# Buzz CLI
|
||||
|
||||
Agent-first command-line interface for Buzz relay. JSON in, JSON out.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cargo install --path crates/buzz-cli
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
| Env Var | Mode | Use Case |
|
||||
|---------|------|----------|
|
||||
| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
|
||||
|
||||
```bash
|
||||
# Private key identity (NIP-98 signed requests)
|
||||
export BUZZ_PRIVATE_KEY="nsec1..."
|
||||
buzz channels list
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict.
|
||||
|
||||
```bash
|
||||
# Set relay URL (defaults to http://localhost:3000)
|
||||
export BUZZ_RELAY_URL="https://relay.example.com"
|
||||
|
||||
# Messages
|
||||
buzz messages send --channel <uuid> --content "Hello"
|
||||
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
|
||||
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
|
||||
buzz messages get --channel <uuid> --limit 20
|
||||
buzz messages thread --channel <uuid> --event <event-id>
|
||||
buzz messages search --query "architecture"
|
||||
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
|
||||
buzz messages edit --event <event-id> --content "Updated text"
|
||||
buzz messages delete --event <event-id>
|
||||
|
||||
# Diffs
|
||||
buzz messages send-diff --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch
|
||||
|
||||
# Channels
|
||||
buzz channels list
|
||||
buzz channels create --name "my-channel" --type stream --visibility open
|
||||
buzz channels join --channel <uuid>
|
||||
buzz channels topic --channel <uuid> --topic "New topic"
|
||||
|
||||
# Reactions
|
||||
buzz reactions add --event <event-id> --emoji "👍"
|
||||
buzz reactions get --event <event-id>
|
||||
|
||||
# Users & Presence
|
||||
buzz users get # your own profile
|
||||
buzz users get --pubkey <hex> # single user
|
||||
buzz users get --pubkey <hex> --pubkey <hex> # batch (max 200)
|
||||
buzz users get --name Honey --owner me # exact-name lookup in your managed agents
|
||||
buzz users set-presence --status online
|
||||
buzz users set-status --text "heads down on the CLI" --emoji "🚀"
|
||||
buzz users set-status --clear # remove your status
|
||||
|
||||
# DMs
|
||||
buzz dms open --pubkey <hex>
|
||||
buzz dms list
|
||||
|
||||
# Workflows
|
||||
buzz workflows list --channel <uuid>
|
||||
buzz workflows trigger --workflow <uuid>
|
||||
buzz workflows approve --token <uuid>
|
||||
buzz workflows approve --token <uuid> --approved false --note "needs revision"
|
||||
|
||||
# Forum
|
||||
buzz messages vote --event <event-id> --direction up
|
||||
|
||||
# Canvas
|
||||
buzz canvas get --channel <uuid>
|
||||
buzz canvas set --channel <uuid> --content "# Welcome"
|
||||
|
||||
# Agent Memory (NIP-AE)
|
||||
buzz mem ls
|
||||
buzz mem get <slug>
|
||||
buzz mem set <slug> "my-value"
|
||||
buzz mem patch <slug> --base-hash <hex> < diff.patch # or --no-base-hash
|
||||
buzz mem rm <slug>
|
||||
|
||||
# Repository protection
|
||||
buzz repos protect list --id my-repo
|
||||
buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete
|
||||
buzz repos protect remove --id my-repo --ref refs/heads/main
|
||||
|
||||
# Pipe to jq
|
||||
buzz channels list | jq '.[].name'
|
||||
```
|
||||
|
||||
`protect set` replaces every existing rule for the exact ref pattern. Any
|
||||
constraint omitted from the command is removed. `protect list` reports malformed
|
||||
stored rules in `validation_error` so an owner can remove and repair them.
|
||||
|
||||
## Commands
|
||||
|
||||
| Group | Subcommand | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `messages` | `send` | Send a message to a channel |
|
||||
| | `send-diff` | Send a code diff with metadata |
|
||||
| | `edit` | Edit a message you sent |
|
||||
| | `delete` | Delete a message |
|
||||
| | `get` | List messages in a channel |
|
||||
| | `thread` | Get a message thread |
|
||||
| | `search` | Full-text search, filterable by author |
|
||||
| | `vote` | Vote on a forum post |
|
||||
| `channels` | `list` | List channels |
|
||||
| | `get` | Get channel details |
|
||||
| | `create` | Create a channel |
|
||||
| | `update` | Update channel name/description |
|
||||
| | `topic` | Set channel topic |
|
||||
| | `purpose` | Set channel purpose |
|
||||
| | `join` | Join a channel |
|
||||
| | `leave` | Leave a channel |
|
||||
| | `archive` | Archive a channel |
|
||||
| | `unarchive` | Unarchive a channel |
|
||||
| | `delete` | Delete a channel |
|
||||
| | `members` | List channel members |
|
||||
| | `add-member` | Add a member |
|
||||
| | `remove-member` | Remove a member |
|
||||
| `canvas` | `get` | Get channel canvas |
|
||||
| | `set` | Set channel canvas |
|
||||
| `reactions` | `add` | React to a message |
|
||||
| | `remove` | Remove a reaction |
|
||||
| | `get` | List reactions |
|
||||
| `dms` | `list` | List DM conversations |
|
||||
| | `open` | Open a DM (1–8 pubkeys) |
|
||||
| | `add-member` | Add member to DM group |
|
||||
| `users` | `get` | Get user profile(s) |
|
||||
| | `set-profile` | Update your profile |
|
||||
| | `presence` | Get presence status |
|
||||
| | `set-presence` | Set presence status |
|
||||
| | `set-status` | Set or clear your NIP-38 profile status |
|
||||
| `workflows` | `list` | List workflows |
|
||||
| | `get` | Get workflow definition |
|
||||
| | `create` | Create a workflow |
|
||||
| | `update` | Update a workflow |
|
||||
| | `delete` | Delete a workflow |
|
||||
| | `trigger` | Trigger a workflow |
|
||||
| | `runs` | Get workflow run history |
|
||||
| | `approve` | Approve/deny a workflow step |
|
||||
| `feed` | `get` | Get your activity feed |
|
||||
| `social` | `publish` | Publish a NIP-01 note |
|
||||
| | `set-contacts` | Set NIP-02 contact list |
|
||||
| | `event` | Get a Nostr event |
|
||||
| | `notes` | Get notes for a user |
|
||||
| | `contacts` | Get NIP-02 contact list |
|
||||
| `repos` | `create` | Announce a git repository (NIP-34) |
|
||||
| | `get` | Get a repository announcement |
|
||||
| | `list` | List repository announcements |
|
||||
| | `protect list` | List branch and tag protection rules |
|
||||
| | `protect set` | Create or replace a protection rule |
|
||||
| | `protect remove` | Remove a protection rule |
|
||||
| `upload` | `file` | Upload a file to the Blossom store |
|
||||
| `pack` | `validate` | Validate a persona pack (local, no relay) |
|
||||
| | `inspect` | Inspect a persona pack (local, no relay) |
|
||||
| `mem` | `ls` | List non-tombstoned memories |
|
||||
| | `get` | Print memory value to stdout |
|
||||
| | `hash` | Print SHA-256 hex of memory value |
|
||||
| | `set` | Write a memory value (use `-` for stdin) |
|
||||
| | `patch` | Apply unified diff to memory value |
|
||||
| | `rm` | Publish a tombstone to delete memory |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
buzz <group> <subcommand> [flags]
|
||||
│
|
||||
├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Buzz Relay REST API
|
||||
│ (clap) (handlers) (reqwest)
|
||||
│
|
||||
├─ validate.rs (UUID, hex, content size, percent-encode)
|
||||
└─ error.rs (CliError → JSON stderr + exit code)
|
||||
|
||||
stdout: raw relay JSON
|
||||
stderr: {"error": "category", "message": "detail"}
|
||||
exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict
|
||||
```
|
||||
@@ -0,0 +1,623 @@
|
||||
# buzz-cli Live Testing Guide
|
||||
|
||||
Manual testing runbook for verifying every CLI command against a local relay.
|
||||
An agent or developer follows this step by step, running each command and
|
||||
checking the output.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
Docker services running and healthy:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
# buzz-postgres healthy
|
||||
# buzz-redis healthy
|
||||
```
|
||||
|
||||
If not running: `just setup` from the repo root.
|
||||
|
||||
Tools: `jq`, `curl`, Rust toolchain.
|
||||
|
||||
---
|
||||
|
||||
## 2. Build the CLI
|
||||
|
||||
```bash
|
||||
cargo build -p buzz-cli
|
||||
```
|
||||
|
||||
Use `cargo run -p buzz-cli --` or the built binary at `target/debug/buzz`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Start the Relay
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
cd REPOS/buzz-nostr
|
||||
set -a && source .env && set +a
|
||||
cargo run -p buzz-relay
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000/_liveness
|
||||
# "ok" or 200 status
|
||||
```
|
||||
|
||||
The `.env` should have `BUZZ_REQUIRE_AUTH_TOKEN=false` for local dev.
|
||||
|
||||
---
|
||||
|
||||
## 4. Mint Test Credentials
|
||||
|
||||
### Option A: buzz-admin (full scopes including admin)
|
||||
|
||||
This mints a token with all CLI-relevant scopes (including `admin:channels`)
|
||||
via direct DB access. Use this for testing admin operations (archive,
|
||||
delete-channel, add/remove-channel-member).
|
||||
|
||||
```bash
|
||||
DATABASE_URL="${DATABASE_URL:?set DATABASE_URL for the local Buzz database}" \
|
||||
cargo run -p buzz-admin -- mint-token \
|
||||
--name "cli-test" \
|
||||
--scopes "messages:read,messages:write,channels:read,channels:write,users:read,users:write,files:read,files:write,admin:channels"
|
||||
```
|
||||
|
||||
This generates a keypair and prints:
|
||||
- **Private key (nsec)** — save for `BUZZ_PRIVATE_KEY` testing
|
||||
|
||||
Export:
|
||||
|
||||
```bash
|
||||
export BUZZ_RELAY_URL="http://localhost:3000"
|
||||
export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output
|
||||
```
|
||||
|
||||
### Scope reference
|
||||
|
||||
| Scope | Self-mintable | Needed for |
|
||||
|-------|:---:|------------|
|
||||
| `messages:read` | ✅ | `messages get`, `messages thread`, `messages search`, `feed get` |
|
||||
| `messages:write` | ✅ | `messages send`, `messages edit`, `messages delete`, `reactions`, `messages vote` |
|
||||
| `channels:read` | ✅ | `channels list`, `channels get`, `channels members` |
|
||||
| `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` |
|
||||
| `users:read` | ✅ | `users get`, `users presence` |
|
||||
| `users:write` | ✅ | `users set-profile`, `users set-presence`, `users set-status` |
|
||||
| `files:read` | ✅ | — |
|
||||
| `files:write` | ✅ | — |
|
||||
| `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Unit Tests
|
||||
|
||||
```bash
|
||||
cargo test -p buzz-cli
|
||||
# Expected: see cargo test -p buzz-cli for current count
|
||||
|
||||
cargo clippy -p buzz-cli -- -D warnings
|
||||
# Expected: zero warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Live Testing — Command by Command
|
||||
|
||||
Run each command, verify exit code 0 and check output. Most commands
|
||||
return JSON (pipe through `jq .` to validate). Commands are ordered so
|
||||
earlier ones create resources that later ones need.
|
||||
|
||||
### 6.1 Channels
|
||||
|
||||
```bash
|
||||
# channels create (stream)
|
||||
buzz channels create --name "test-stream" --type stream --visibility open \
|
||||
--description "CLI test channel" | jq .
|
||||
# Save the channel ID:
|
||||
CHANNEL_ID=$(buzz channels create --name "test-cli" --type stream --visibility open | jq -r '.channel_id')
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"...","channel_id":"<uuid>"}
|
||||
|
||||
# channels create (forum) — needed for messages vote later
|
||||
FORUM_ID=$(buzz channels create --name "test-forum" --type forum --visibility open | jq -r '.channel_id')
|
||||
|
||||
# channels list
|
||||
buzz channels list | jq .
|
||||
# Expected: [{"channel_id":"...","name":"...","description":"...","created_at":N}]
|
||||
buzz channels list --visibility open | jq .
|
||||
buzz channels list --member | jq .
|
||||
|
||||
# channels get
|
||||
buzz channels get --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"channel_id":"...","name":"...","description":"...","created_at":N,"pubkey":"..."} or null
|
||||
|
||||
# channels update
|
||||
buzz channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
|
||||
--description "Updated" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels topic
|
||||
buzz channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels purpose
|
||||
buzz channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels join (may already be a member from create)
|
||||
buzz channels join --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels leave
|
||||
# NOTE: Fails with 400 "cannot remove the last owner" if this identity is the
|
||||
# sole owner (which it is after channels create). To test leave successfully,
|
||||
# first add-member a second pubkey as owner. The relay enforces ≥1 owner.
|
||||
buzz channels leave --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."} (or 400 if last owner)
|
||||
|
||||
# Re-join so we can send messages
|
||||
buzz channels join --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels archive (requires admin:channels scope)
|
||||
buzz channels archive --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
|
||||
# channels unarchive
|
||||
buzz channels unarchive --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"..."}
|
||||
```
|
||||
|
||||
### 6.2 Canvas
|
||||
|
||||
```bash
|
||||
# canvas set
|
||||
buzz canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
|
||||
|
||||
# canvas set from stdin
|
||||
echo "# Canvas from stdin" | buzz canvas set --channel "$CHANNEL_ID" --content - | jq .
|
||||
|
||||
# canvas get
|
||||
buzz canvas get --channel "$CHANNEL_ID"
|
||||
# Expected: raw markdown string, or: null
|
||||
```
|
||||
|
||||
### 6.3 Messages
|
||||
|
||||
```bash
|
||||
# messages send
|
||||
MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
|
||||
echo "$MSG"
|
||||
EVENT_ID=$(echo "$MSG" | jq -r '.event_id')
|
||||
|
||||
# messages send with reply + broadcast
|
||||
REPLY=$(buzz messages send --channel "$CHANNEL_ID" --content "Reply" \
|
||||
--reply-to "$EVENT_ID" --broadcast | jq .)
|
||||
echo "$REPLY"
|
||||
REPLY_ID=$(echo "$REPLY" | jq -r '.event_id')
|
||||
|
||||
# messages send with mentions — @name in content is auto-resolved, no flag needed
|
||||
buzz messages send --channel "$CHANNEL_ID" --content "Hey @someone" | jq .
|
||||
|
||||
# messages send with NIP-27 nostr:npub1… inline mention — auto-resolved to p-tag
|
||||
buzz messages send --channel "$CHANNEL_ID" \
|
||||
--content "Check with nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg on this" | jq .
|
||||
|
||||
# messages send from stdin — safe path for content with shell metacharacters
|
||||
# (backticks, $vars, code blocks) that would otherwise be expanded by the shell.
|
||||
echo 'Body with `backticks` and $vars stays literal.' \
|
||||
| buzz messages send --channel "$CHANNEL_ID" --content - | jq .
|
||||
|
||||
# messages get
|
||||
buzz messages get --channel "$CHANNEL_ID" | jq .
|
||||
buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq .
|
||||
|
||||
# messages thread
|
||||
buzz messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
|
||||
|
||||
# messages search
|
||||
buzz messages search --query "Hello" | jq .
|
||||
buzz messages search --query "CLI test" --limit 5 | jq .
|
||||
|
||||
# messages edit
|
||||
buzz messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq .
|
||||
|
||||
# messages delete
|
||||
buzz messages delete --event "$REPLY_ID" | jq .
|
||||
```
|
||||
|
||||
### 6.4 Diff Messages
|
||||
|
||||
```bash
|
||||
# messages send-diff from stdin
|
||||
echo '--- a/foo.rs
|
||||
+++ b/foo.rs
|
||||
@@ -1,3 +1,3 @@
|
||||
-fn old() {}
|
||||
+fn new() {}' | buzz messages send-diff \
|
||||
--channel "$CHANNEL_ID" \
|
||||
--diff - \
|
||||
--repo "https://github.com/example/repo" \
|
||||
--commit "abcdef1234567890abcdef1234567890abcdef12" | jq .
|
||||
|
||||
# messages send-diff with metadata
|
||||
echo "diff content" | buzz messages send-diff \
|
||||
--channel "$CHANNEL_ID" \
|
||||
--diff - \
|
||||
--repo "https://github.com/example/repo" \
|
||||
--commit "abcdef1234567890abcdef1234567890abcdef12" \
|
||||
--file "src/main.rs" \
|
||||
--lang "rust" \
|
||||
--description "Refactored main" | jq .
|
||||
|
||||
# messages send-diff with branch + PR metadata
|
||||
echo "diff content" | buzz messages send-diff \
|
||||
--channel "$CHANNEL_ID" \
|
||||
--diff - \
|
||||
--repo "https://github.com/example/repo" \
|
||||
--commit "abcdef1234567890abcdef1234567890abcdef12" \
|
||||
--parent-commit "1234567890abcdef1234567890abcdef12345678" \
|
||||
--source-branch "feature/cli" \
|
||||
--target-branch "main" \
|
||||
--pr 42 | jq .
|
||||
```
|
||||
|
||||
### 6.5 Reactions
|
||||
|
||||
```bash
|
||||
# Send a message to react to
|
||||
REACT_MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "React to this")
|
||||
REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id')
|
||||
|
||||
# reactions add
|
||||
buzz reactions add --event "$REACT_ID" --emoji "👍" | jq .
|
||||
|
||||
# reactions get
|
||||
buzz reactions get --event "$REACT_ID" | jq .
|
||||
# Expected: {"reactions":[{"emoji":"...","count":N,"pubkeys":["..."]}]}
|
||||
|
||||
# reactions remove
|
||||
buzz reactions remove --event "$REACT_ID" --emoji "👍" | jq .
|
||||
```
|
||||
|
||||
### 6.6 DMs
|
||||
|
||||
```bash
|
||||
# dms list
|
||||
buzz dms list | jq .
|
||||
# Expected: [{"dm_id":"...","participants":["..."],"created_at":N}]
|
||||
|
||||
# dms open (needs a real pubkey — use your own or a test one)
|
||||
# Get your own pubkey first:
|
||||
MY_PUBKEY=$(buzz users get | jq -r '.[0].pubkey // empty')
|
||||
echo "My pubkey: $MY_PUBKEY"
|
||||
|
||||
# dms open with a synthetic pubkey (relay will create the user)
|
||||
DM_RESULT=$(buzz dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
|
||||
echo "$DM_RESULT" | jq .
|
||||
# Expected: {"event_id":"...","accepted":true,"message":"...","dm_id":"<uuid>"}
|
||||
DM_ID=$(echo "$DM_RESULT" | jq -r '.dm_id')
|
||||
|
||||
# dms add-member (requires messages:write scope — NOT admin:channels)
|
||||
buzz dms add-member --channel "$DM_ID" \
|
||||
--pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq .
|
||||
```
|
||||
|
||||
### 6.7 Users & Presence
|
||||
|
||||
```bash
|
||||
# users get — own profile (0 pubkeys)
|
||||
buzz users get | jq .
|
||||
# Expected: [{...profile...}] — always returns an array, even for single results
|
||||
|
||||
# users get — single pubkey
|
||||
buzz users get --pubkey "$MY_PUBKEY" | jq .
|
||||
|
||||
# users get — batch (2+ pubkeys)
|
||||
buzz users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
|
||||
|
||||
# users set-profile
|
||||
buzz users set-profile --name "CLI Test Agent" --about "Testing buzz-cli" | jq .
|
||||
|
||||
# users presence
|
||||
buzz users presence --pubkeys "$MY_PUBKEY" | jq .
|
||||
|
||||
# users set-presence
|
||||
buzz users set-presence --status online | jq .
|
||||
buzz users set-presence --status away | jq .
|
||||
buzz users set-presence --status offline | jq .
|
||||
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
|
||||
|
||||
# users set-status — NIP-38 kind:30315 on the d:general coordinate
|
||||
buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq .
|
||||
buzz users set-status --text "no emoji this time" | jq .
|
||||
|
||||
# users set-status — emoji-only status (intentional: text is blank, emoji is kept)
|
||||
buzz users set-status --text "" --emoji "🎶" | jq .
|
||||
|
||||
# users set-status --clear — removes the status (empty content, d:general only)
|
||||
buzz users set-status --clear | jq .
|
||||
|
||||
# --clear is mutually exclusive with --text/--emoji
|
||||
buzz users set-status --clear --text "nope" 2>&1; echo "exit: $?"
|
||||
# Expected: exit 1 — clap conflict error
|
||||
```
|
||||
|
||||
### 6.8 Channel Members (add/remove require admin:channels)
|
||||
|
||||
```bash
|
||||
# channels add-member
|
||||
buzz channels add-member --channel "$CHANNEL_ID" \
|
||||
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" \
|
||||
--role member | jq .
|
||||
|
||||
# channels members
|
||||
buzz channels members --channel "$CHANNEL_ID" | jq .
|
||||
# Expected: [{"pubkey":"...","role":"..."}]
|
||||
|
||||
# channels remove-member
|
||||
buzz channels remove-member --channel "$CHANNEL_ID" \
|
||||
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq .
|
||||
```
|
||||
|
||||
### 6.9 Workflows
|
||||
|
||||
```bash
|
||||
# workflows create
|
||||
# NOTE: trigger uses `on:` tag (serde internally tagged enum).
|
||||
# Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook
|
||||
# Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc.
|
||||
WF=$(buzz workflows create --channel "$CHANNEL_ID" \
|
||||
--yaml 'name: test-wf
|
||||
trigger:
|
||||
on: webhook
|
||||
steps:
|
||||
- id: step1
|
||||
action: send_message
|
||||
text: "Hello from workflow"' | jq .)
|
||||
echo "$WF"
|
||||
WF_ID=$(echo "$WF" | jq -r '.workflow_id')
|
||||
|
||||
# workflows list
|
||||
buzz workflows list --channel "$CHANNEL_ID" | jq .
|
||||
|
||||
# workflows get
|
||||
buzz workflows get --workflow "$WF_ID" | jq .
|
||||
# Expected: {"workflow_id":"...","content":"<yaml>","created_at":N,"pubkey":"..."} or null
|
||||
|
||||
# workflows update (requires --channel)
|
||||
buzz workflows update --channel "$CHANNEL_ID" --workflow "$WF_ID" \
|
||||
--yaml 'name: test-wf-updated
|
||||
trigger:
|
||||
on: webhook
|
||||
steps:
|
||||
- id: step1
|
||||
action: send_message
|
||||
text: "Updated"' | jq .
|
||||
|
||||
# workflows trigger
|
||||
# NOTE: May return 400 "workflow not found" — the relay indexes workflow
|
||||
# definitions into a DB table asynchronously. If the definition event hasn't
|
||||
# been indexed yet, the trigger handler won't find it.
|
||||
buzz workflows trigger --workflow "$WF_ID" | jq .
|
||||
|
||||
# workflows runs
|
||||
buzz workflows runs --workflow "$WF_ID" | jq .
|
||||
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal
|
||||
|
||||
# workflows approve — requires a workflow run waiting for approval
|
||||
# This is hard to test ad-hoc without a workflow that has an approval gate.
|
||||
# Test the validation instead:
|
||||
buzz workflows approve --token "00000000-0000-0000-0000-000000000000" 2>&1 || true
|
||||
# Should fail with relay error (token not found), not a validation error
|
||||
# To test the deny path: buzz workflows approve --token <UUID> --approved false
|
||||
|
||||
# workflows delete
|
||||
buzz workflows delete --workflow "$WF_ID" | jq .
|
||||
```
|
||||
|
||||
### 6.10 Feed
|
||||
|
||||
```bash
|
||||
buzz feed get | jq .
|
||||
buzz feed get --limit 5 | jq .
|
||||
# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first
|
||||
```
|
||||
|
||||
### 6.11 Forum & Voting
|
||||
|
||||
```bash
|
||||
# Send a forum post (kind 45001) to the forum channel
|
||||
FORUM_POST=$(buzz messages send --channel "$FORUM_ID" \
|
||||
--content "Forum post for vote testing" --kind 45001 | jq .)
|
||||
echo "$FORUM_POST"
|
||||
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id')
|
||||
|
||||
# messages vote (up)
|
||||
buzz messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
|
||||
|
||||
# messages vote (down)
|
||||
buzz messages vote --event "$FORUM_EVENT_ID" --direction down | jq .
|
||||
```
|
||||
|
||||
### 6.12 Notes (NIP-23 long-form, kind:30023)
|
||||
|
||||
Editable team-knowledge notes keyed by `(kind:30023, you, d=slug)`. `set` is an
|
||||
idempotent upsert; `rm` is a NIP-09 a-tag deletion. Output is plain text (refs),
|
||||
not JSON — except `get`/`ls`, which emit JSON.
|
||||
|
||||
```bash
|
||||
# set (first publish — --title required, body from stdin)
|
||||
cat <<'EOF' | buzz notes set --name dco-check --title "DCO Check" \
|
||||
--summary "How we verify DCO" --tag dco --tag ci --content -
|
||||
Run `git log --format='%(trailers:key=Signed-off-by)'` ...
|
||||
EOF
|
||||
# → prints event_id / naddr / coordinate / slug / title
|
||||
|
||||
# set (edit — omit --title to carry it forward; published_at preserved)
|
||||
echo "Updated body." | buzz notes set --name dco-check --content -
|
||||
|
||||
# get by name (own author resolves directly; cross-author #d query otherwise)
|
||||
buzz notes get --name dco-check | jq .
|
||||
buzz notes get --name dco-check --content-only
|
||||
|
||||
# get by naddr (exact coordinate; paste the naddr from a set/get above)
|
||||
buzz notes get --naddr "$NADDR" | jq .
|
||||
|
||||
# ls (own by default; --author all across the team; --tag filters)
|
||||
buzz notes ls | jq .
|
||||
buzz notes ls --tag dco | jq .
|
||||
buzz notes ls --author all --limit 10 | jq .
|
||||
|
||||
# rm (NIP-09 a-tag deletion; subsequent get must 404)
|
||||
buzz notes rm --name dco-check
|
||||
# → prints deleted <coordinate> / deletion <event-id>
|
||||
buzz notes get --name dco-check # exits non-zero: not found
|
||||
|
||||
# rm of a slug you never published → NotFound, no kind:5 emitted
|
||||
buzz notes rm --name does-not-exist # exits non-zero
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Path Testing
|
||||
|
||||
Verify the CLI produces correct JSON on stderr and correct exit codes.
|
||||
|
||||
```bash
|
||||
# Exit 1: Invalid UUID
|
||||
buzz channels get --channel "not-a-uuid" 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"}
|
||||
# exit: 1
|
||||
|
||||
# Exit 1: Invalid hex64
|
||||
buzz messages delete --event "not-hex" 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
|
||||
# exit: 1
|
||||
|
||||
# Exit 1: Invalid --type value (clap validates the enum — multi-line error)
|
||||
buzz channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"user_error","message":"error: invalid value 'invalid' for '--type <CHANNEL_TYPE>'\n [possible values: stream, forum]\n..."}
|
||||
# exit: 1
|
||||
|
||||
# Exit 1: Invalid --direction value
|
||||
buzz messages vote --event "$(printf '0%.0s' {1..64})" \
|
||||
--direction sideways 2>&1; echo "exit: $?"
|
||||
# exit: 1
|
||||
|
||||
# Exit 1: Empty body guard
|
||||
buzz users set-profile 2>&1; echo "exit: $?"
|
||||
# exit: 1 (at least one field required)
|
||||
|
||||
# Exit 3: No auth configured
|
||||
env -u BUZZ_PRIVATE_KEY \
|
||||
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
|
||||
# exit: 3
|
||||
|
||||
# Not-found returns null, not an error (exit 0)
|
||||
buzz channels get --channel "00000000-0000-0000-0000-000000000000"
|
||||
# stdout: null
|
||||
# exit: 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Auth Testing
|
||||
|
||||
Test authentication.
|
||||
|
||||
```bash
|
||||
# Private key (BUZZ_PRIVATE_KEY)
|
||||
BUZZ_PRIVATE_KEY="nsec1..." buzz channels list | jq .
|
||||
# Should succeed
|
||||
|
||||
# No auth → exit 3
|
||||
env -u BUZZ_PRIVATE_KEY \
|
||||
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
|
||||
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
|
||||
# exit: 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Cleanup
|
||||
|
||||
```bash
|
||||
# Delete test channels
|
||||
buzz channels delete --channel "$CHANNEL_ID" | jq .
|
||||
buzz channels delete --channel "$FORUM_ID" | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist
|
||||
|
||||
| # | Command | Tested | Notes |
|
||||
|---|---------|:------:|-------|
|
||||
| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions, stdin |
|
||||
| 2 | `messages send-diff` | ☐ | Stdin, metadata, branch/PR |
|
||||
| 3 | `messages edit` | ☐ | |
|
||||
| 4 | `messages delete` | ☐ | |
|
||||
| 5 | `messages get` | ☐ | With limit |
|
||||
| 6 | `messages thread` | ☐ | |
|
||||
| 7 | `messages search` | ☐ | With limit |
|
||||
| 8 | `messages vote` | ☐ | Up and down |
|
||||
| 9 | `channels list` | ☐ | With visibility, member |
|
||||
| 10 | `channels get` | ☐ | |
|
||||
| 11 | `channels create` | ☐ | Stream and forum |
|
||||
| 12 | `channels update` | ☐ | |
|
||||
| 13 | `channels topic` | ☐ | |
|
||||
| 14 | `channels purpose` | ☐ | |
|
||||
| 15 | `channels join` | ☐ | |
|
||||
| 16 | `channels leave` | ☐ | |
|
||||
| 17 | `channels archive` | ☐ | Needs admin:channels |
|
||||
| 18 | `channels unarchive` | ☐ | Needs admin:channels |
|
||||
| 19 | `channels delete` | ☐ | Needs admin:channels |
|
||||
| 20 | `channels members` | ☐ | |
|
||||
| 21 | `channels add-member` | ☐ | Needs admin:channels |
|
||||
| 22 | `channels remove-member` | ☐ | Needs admin:channels |
|
||||
| 23 | `canvas get` | ☐ | |
|
||||
| 24 | `canvas set` | ☐ | Direct and stdin |
|
||||
| 25 | `reactions add` | ☐ | |
|
||||
| 26 | `reactions remove` | ☐ | |
|
||||
| 27 | `reactions get` | ☐ | |
|
||||
| 28 | `dms list` | ☐ | |
|
||||
| 29 | `dms open` | ☐ | |
|
||||
| 30 | `dms add-member` | ☐ | Needs messages:write |
|
||||
| 31 | `users get` | ☐ | Self, single, batch |
|
||||
| 32 | `users set-profile` | ☐ | |
|
||||
| 33 | `users presence` | ☐ | |
|
||||
| 34 | `users set-presence` | ☐ | online, away, offline |
|
||||
| 35 | `workflows list` | ☐ | |
|
||||
| 36 | `workflows create` | ☐ | |
|
||||
| 37 | `workflows update` | ☐ | |
|
||||
| 38 | `workflows delete` | ☐ | |
|
||||
| 39 | `workflows trigger` | ☐ | |
|
||||
| 40 | `workflows runs` | ☐ | |
|
||||
| 41 | `workflows get` | ☐ | |
|
||||
| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny |
|
||||
| 43 | `feed get` | ☐ | |
|
||||
| 44 | `social publish` | ☐ | |
|
||||
| 45 | `social set-contacts` | ☐ | |
|
||||
| 46 | `social event` | ☐ | |
|
||||
| 47 | `social notes` | ☐ | |
|
||||
| 48 | `social contacts` | ☐ | |
|
||||
| 49 | `repos create` | ☐ | |
|
||||
| 50 | `repos get` | ☐ | |
|
||||
| 51 | `repos list` | ☐ | |
|
||||
| 52 | `repos protect list` | ☐ | Empty/populated rules; unknown rules visible; malformed rule reported in validation_error |
|
||||
| 53 | `repos protect set` | ☐ | Create and replace complete exact-ref rule; verify metadata is preserved |
|
||||
| 54 | `repos protect remove` | ☐ | Remove exact ref; missing rule → NotFound |
|
||||
| 55 | `upload file` | ☐ | |
|
||||
| 56 | `pack validate` | ☐ | Local, no relay |
|
||||
| 57 | `pack inspect` | ☐ | Local, no relay |
|
||||
| 58 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard |
|
||||
| 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 |
|
||||
| 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
|
||||
| 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |
|
||||
| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 |
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Owner-reviewed agent draft requests published through Buzz observer frames.
|
||||
|
||||
use buzz_core::observer::{encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY};
|
||||
use nostr::{Event, Keys, PublicKey};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
const REQUEST_KIND: &str = "agent_management_request";
|
||||
const MAX_NAME_CHARS: usize = 120;
|
||||
const MAX_PROMPT_CHARS: usize = 20_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAgentDraft {
|
||||
pub channel_id: String,
|
||||
pub display_name: String,
|
||||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAgentDraft {
|
||||
pub channel_id: String,
|
||||
pub agent_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub respond_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManagementRequest<T> {
|
||||
#[serde(rename = "type")]
|
||||
request_type: &'static str,
|
||||
action: &'static str,
|
||||
request_id: String,
|
||||
request: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ObserverEvent<T> {
|
||||
seq: u64,
|
||||
timestamp: String,
|
||||
kind: &'static str,
|
||||
agent_index: Option<usize>,
|
||||
channel_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
payload: ManagementRequest<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BuiltDraftRequest {
|
||||
pub event: Event,
|
||||
pub request_id: String,
|
||||
pub action: &'static str,
|
||||
}
|
||||
|
||||
fn required(value: String, label: &str, max: usize) -> Result<String, CliError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(CliError::Usage(format!("{label} is required")));
|
||||
}
|
||||
if value.chars().count() > max {
|
||||
return Err(CliError::Usage(format!(
|
||||
"{label} is too long (max {max} characters)"
|
||||
)));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
fn optional(value: Option<String>, label: &str) -> Result<Option<String>, CliError> {
|
||||
value.map(|value| required(value, label, 300)).transpose()
|
||||
}
|
||||
|
||||
fn build<T: Serialize>(
|
||||
keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
channel_id: String,
|
||||
action: &'static str,
|
||||
request: T,
|
||||
) -> Result<BuiltDraftRequest, CliError> {
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let payload = ObserverEvent {
|
||||
seq: 0,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
kind: REQUEST_KIND,
|
||||
agent_index: None,
|
||||
channel_id: Some(channel_id),
|
||||
session_id: None,
|
||||
turn_id: None,
|
||||
payload: ManagementRequest {
|
||||
request_type: REQUEST_KIND,
|
||||
action,
|
||||
request_id: request_id.clone(),
|
||||
request,
|
||||
},
|
||||
};
|
||||
let encrypted = encrypt_observer_payload(keys, owner, &payload)
|
||||
.map_err(|error| CliError::Other(format!("could not encrypt draft request: {error}")))?;
|
||||
let event = buzz_sdk::build_agent_observer_frame(
|
||||
&owner.to_hex(),
|
||||
&keys.public_key().to_hex(),
|
||||
OBSERVER_FRAME_TELEMETRY,
|
||||
&encrypted,
|
||||
)
|
||||
.map_err(|error| CliError::Other(format!("could not build draft request: {error}")))?
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|error| CliError::Other(format!("could not sign draft request: {error}")))?;
|
||||
Ok(BuiltDraftRequest {
|
||||
event,
|
||||
request_id,
|
||||
action,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_create(
|
||||
keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
draft: CreateAgentDraft,
|
||||
) -> Result<BuiltDraftRequest, CliError> {
|
||||
let channel_id = required(draft.channel_id, "channel", 128)?;
|
||||
uuid::Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
|
||||
let request = CreateAgentDraft {
|
||||
channel_id: channel_id.clone(),
|
||||
display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?,
|
||||
system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
|
||||
};
|
||||
build(keys, owner, channel_id, "create", request)
|
||||
}
|
||||
|
||||
pub fn build_update(
|
||||
keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
draft: UpdateAgentDraft,
|
||||
) -> Result<BuiltDraftRequest, CliError> {
|
||||
let channel_id = required(draft.channel_id, "channel", 128)?;
|
||||
uuid::Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
|
||||
let respond_to = optional(draft.respond_to, "respond-to")?;
|
||||
if respond_to
|
||||
.as_deref()
|
||||
.is_some_and(|value| value != "owner-only" && value != "anyone")
|
||||
{
|
||||
return Err(CliError::Usage(
|
||||
"respond-to must be owner-only or anyone".into(),
|
||||
));
|
||||
}
|
||||
let request = UpdateAgentDraft {
|
||||
channel_id: channel_id.clone(),
|
||||
agent_name: required(draft.agent_name, "agent name", MAX_NAME_CHARS)?,
|
||||
display_name: optional(draft.display_name, "display name")?,
|
||||
system_prompt: draft
|
||||
.system_prompt
|
||||
.map(|value| required(value, "system prompt", MAX_PROMPT_CHARS))
|
||||
.transpose()?,
|
||||
runtime: optional(draft.runtime, "runtime")?,
|
||||
provider: optional(draft.provider, "provider")?,
|
||||
model: optional(draft.model, "model")?,
|
||||
respond_to,
|
||||
};
|
||||
if request.display_name.is_none()
|
||||
&& request.system_prompt.is_none()
|
||||
&& request.runtime.is_none()
|
||||
&& request.provider.is_none()
|
||||
&& request.model.is_none()
|
||||
&& request.respond_to.is_none()
|
||||
{
|
||||
return Err(CliError::Usage(
|
||||
"include at least one field to update".into(),
|
||||
));
|
||||
}
|
||||
build(keys, owner, channel_id, "update", request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::observer::{decrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_TAG};
|
||||
|
||||
const CHANNEL: &str = "7c07e659-3610-42f4-9a5e-1e9973c09da9";
|
||||
|
||||
#[test]
|
||||
fn create_is_owner_encrypted_and_matches_desktop_contract() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let built = build_create(
|
||||
&agent,
|
||||
&owner.public_key(),
|
||||
CreateAgentDraft {
|
||||
channel_id: CHANNEL.into(),
|
||||
display_name: "Research helper".into(),
|
||||
system_prompt: "Find sources.".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(built.event.kind.as_u16(), 24_200);
|
||||
let tags: Vec<Vec<String>> = built
|
||||
.event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.as_slice().to_vec())
|
||||
.collect();
|
||||
assert!(tags
|
||||
.iter()
|
||||
.any(|tag| tag == &["p", &owner.public_key().to_hex()]));
|
||||
assert!(tags
|
||||
.iter()
|
||||
.any(|tag| tag == &[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]));
|
||||
assert!(tags
|
||||
.iter()
|
||||
.any(|tag| tag == &[OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY]));
|
||||
assert!(!tags
|
||||
.iter()
|
||||
.any(|tag| tag.first().map(String::as_str) == Some("h")));
|
||||
|
||||
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
|
||||
assert_eq!(payload["kind"], REQUEST_KIND);
|
||||
assert_eq!(payload["channelId"], CHANNEL);
|
||||
assert_eq!(payload["payload"]["type"], REQUEST_KIND);
|
||||
assert_eq!(payload["payload"]["action"], "create");
|
||||
assert_eq!(
|
||||
payload["payload"]["request"]["displayName"],
|
||||
"Research helper"
|
||||
);
|
||||
assert!(payload["payload"]["request"].get("runtime").is_none());
|
||||
assert!(payload["payload"]["request"].get("respondTo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_requires_a_change() {
|
||||
let error = build_update(
|
||||
&Keys::generate(),
|
||||
&Keys::generate().public_key(),
|
||||
UpdateAgentDraft {
|
||||
channel_id: CHANNEL.into(),
|
||||
agent_name: "Scout".into(),
|
||||
display_name: None,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
respond_to: None,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("at least one field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_rejects_invalid_channel() {
|
||||
let error = build_create(
|
||||
&Keys::generate(),
|
||||
&Keys::generate().public_key(),
|
||||
CreateAgentDraft {
|
||||
channel_id: "general".into(),
|
||||
display_name: "Scout".into(),
|
||||
system_prompt: "Help".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("invalid channel UUID"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
//! Desktop-local channel template loading for `buzz channels create --template`.
|
||||
//!
|
||||
//! Templates live in a JSON file the desktop app owns
|
||||
//! (`<app-data>/templates/channel-templates.json`); this module duplicates the
|
||||
//! wire shape (`desktop/src-tauri/src/templates/types.rs`) rather than sharing
|
||||
//! a crate, since buzz-cli and desktop-tauri are independent crates and the
|
||||
//! shape is small and stable. Only the fields the CLI needs to read are kept.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
/// Tauri bundle identifier for the production desktop app. `dirs::data_dir()`
|
||||
/// joined with this segment matches `app.path().app_data_dir()` exactly
|
||||
/// (Tauri resolves app-data as the platform data dir plus the identifier).
|
||||
const PROD_BUNDLE_IDENTIFIER: &str = "xyz.block.buzz.app";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChannelTemplateRecord {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default = "default_channel_type")]
|
||||
pub channel_type: String,
|
||||
#[serde(default = "default_visibility")]
|
||||
pub visibility: String,
|
||||
#[serde(default)]
|
||||
pub canvas_template: Option<String>,
|
||||
#[serde(default)]
|
||||
pub agents: TemplateAgentRoster,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateAgentRoster {
|
||||
#[serde(default)]
|
||||
pub personas: Vec<TemplateAgentEntry>,
|
||||
#[serde(default)]
|
||||
pub teams: Vec<TemplateTeamEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateAgentEntry {
|
||||
pub persona_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateTeamEntry {
|
||||
pub team_id: String,
|
||||
}
|
||||
|
||||
fn default_channel_type() -> String {
|
||||
"stream".to_string()
|
||||
}
|
||||
|
||||
fn default_visibility() -> String {
|
||||
"open".to_string()
|
||||
}
|
||||
|
||||
/// Resolve the desktop app's `channel-templates.json` path.
|
||||
///
|
||||
/// `override_path` (from `--templates-file`) always wins — useful for the dev
|
||||
/// store or tests. Otherwise defaults to the prod bundle's app-data dir:
|
||||
/// `<platform-data-dir>/xyz.block.buzz.app/templates/channel-templates.json`.
|
||||
pub fn resolve_templates_path(override_path: Option<&str>) -> Result<PathBuf, CliError> {
|
||||
if let Some(p) = override_path {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
let data_dir = dirs::data_dir().ok_or_else(|| {
|
||||
CliError::Other("could not resolve platform app-data directory".to_string())
|
||||
})?;
|
||||
Ok(data_dir
|
||||
.join(PROD_BUNDLE_IDENTIFIER)
|
||||
.join("templates")
|
||||
.join("channel-templates.json"))
|
||||
}
|
||||
|
||||
/// Load and parse the channel-templates store from `path`.
|
||||
fn load_templates(path: &Path) -> Result<Vec<ChannelTemplateRecord>, CliError> {
|
||||
if !path.exists() {
|
||||
return Err(CliError::NotFound(format!(
|
||||
"no channel templates store found at {} (create a template in Buzz Desktop first, \
|
||||
or pass --templates-file)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| CliError::Other(format!("failed to read {}: {e}", path.display())))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse {}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Load the templates store and find the template matching `name`
|
||||
/// (case-insensitive, exact match). Errors list available names if not found.
|
||||
pub fn find_template(path: &Path, name: &str) -> Result<ChannelTemplateRecord, CliError> {
|
||||
let templates = load_templates(path)?;
|
||||
let needle = name.to_ascii_lowercase();
|
||||
if let Some(t) = templates
|
||||
.into_iter()
|
||||
.find(|t| t.name.to_ascii_lowercase() == needle)
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
Err(CliError::NotFound(format!(
|
||||
"no channel template named '{name}' (available: {})",
|
||||
available_names(path)?
|
||||
)))
|
||||
}
|
||||
|
||||
fn available_names(path: &Path) -> Result<String, CliError> {
|
||||
let templates = load_templates(path)?;
|
||||
if templates.is_empty() {
|
||||
return Ok("<none>".to_string());
|
||||
}
|
||||
Ok(templates
|
||||
.iter()
|
||||
.map(|t| t.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn write_store(json: &str) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().expect("tempfile");
|
||||
f.write_all(json.as_bytes()).expect("write");
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_templates_path_honors_override() {
|
||||
let path = resolve_templates_path(Some("/tmp/custom.json")).unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/custom.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_templates_path_defaults_to_prod_bundle() {
|
||||
let path = resolve_templates_path(None).unwrap();
|
||||
assert!(path.ends_with("xyz.block.buzz.app/templates/channel-templates.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_template_matches_case_insensitive() {
|
||||
let f = write_store(r#"[{"id":"t1","name":"Buzz Team","createdAt":"x","updatedAt":"x"}]"#);
|
||||
let t = find_template(f.path(), "buzz team").expect("found");
|
||||
assert_eq!(t.name, "Buzz Team");
|
||||
assert_eq!(t.channel_type, "stream");
|
||||
assert_eq!(t.visibility, "open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_template_not_found_lists_available_names() {
|
||||
let f = write_store(
|
||||
r#"[{"id":"t1","name":"Buzz Team","createdAt":"x","updatedAt":"x"},
|
||||
{"id":"t2","name":"Standup","createdAt":"x","updatedAt":"x"}]"#,
|
||||
);
|
||||
let err = find_template(f.path(), "nope").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Buzz Team"));
|
||||
assert!(msg.contains("Standup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_template_missing_store_is_not_found() {
|
||||
let err = find_template(Path::new("/nonexistent/channel-templates.json"), "x").unwrap_err();
|
||||
assert!(matches!(err, CliError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_templates_parses_full_roster() {
|
||||
let f = write_store(
|
||||
r##"[{
|
||||
"id":"t1","name":"Buzz Team","channel_type":"forum","visibility":"private",
|
||||
"canvas_template":"# {channel.name}",
|
||||
"agents":{"personas":[{"personaId":"builtin:fizz"}],"teams":[{"teamId":"team-1"}]},
|
||||
"created_at":"x","updated_at":"x"
|
||||
}]"##,
|
||||
);
|
||||
let t = find_template(f.path(), "Buzz Team").expect("found");
|
||||
assert_eq!(t.channel_type, "forum");
|
||||
assert_eq!(t.visibility, "private");
|
||||
assert_eq!(t.canvas_template.as_deref(), Some("# {channel.name}"));
|
||||
assert_eq!(t.agents.personas.len(), 1);
|
||||
assert_eq!(t.agents.personas[0].persona_id, "builtin:fizz");
|
||||
assert_eq!(t.agents.teams.len(), 1);
|
||||
assert_eq!(t.agents.teams[0].team_id, "team-1");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{parse_uuid, sdk_err, validate_hex64};
|
||||
|
||||
/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey.
|
||||
pub async fn cmd_list_dms(client: &BuzzClient, 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": [41001],
|
||||
"#p": [my_pk],
|
||||
"limit": limit
|
||||
});
|
||||
let resp = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
let dms: Vec<serde_json::Value> = events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let dm_id = extract_d_tag(e);
|
||||
let participants: Vec<String> = e
|
||||
.get("tags")
|
||||
.and_then(|t| t.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(|tag| {
|
||||
let arr = tag.as_array()?;
|
||||
if arr.first()?.as_str()? == "p" {
|
||||
arr.get(1)?.as_str().map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"dm_id": dm_id,
|
||||
"participants": participants,
|
||||
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let output = serde_json::to_string(&dms).unwrap_or_default();
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag.
|
||||
pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), CliError> {
|
||||
if pubkeys.is_empty() || pubkeys.len() > 8 {
|
||||
return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into()));
|
||||
}
|
||||
for pk in pubkeys {
|
||||
validate_hex64(pk)?;
|
||||
}
|
||||
let dm_id = Uuid::new_v4().to_string();
|
||||
let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect();
|
||||
|
||||
// build_dm_open doesn't accept a d-tag, so we build the event manually
|
||||
// using the SDK builder and add the d-tag ourselves.
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
let mut tags: Vec<Tag> = refs
|
||||
.iter()
|
||||
.map(|pk| Tag::parse(["p", *pk]).map_err(|e| CliError::Other(format!("tag error: {e}"))))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tags.push(Tag::parse(["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
|
||||
let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags);
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
// Try to extract relay-assigned channel_id from response message.
|
||||
// Relay returns: {"event_id":"...","accepted":true,"message":"response:{\"channel_id\":\"...\",\"created\":true}"}
|
||||
let relay_dm_id = serde_json::from_str::<serde_json::Value>(&resp)
|
||||
.ok()
|
||||
.and_then(|v| v.get("message")?.as_str().map(|s| s.to_string()))
|
||||
.and_then(|msg| {
|
||||
let json_part = msg.strip_prefix("response:")?;
|
||||
serde_json::from_str::<serde_json::Value>(json_part).ok()
|
||||
})
|
||||
.and_then(|v| v.get("channel_id")?.as_str().map(|s| s.to_string()));
|
||||
let final_dm_id = relay_dm_id.unwrap_or(dm_id);
|
||||
|
||||
let mut normalized: serde_json::Value =
|
||||
serde_json::from_str(&resp).unwrap_or(serde_json::json!({}));
|
||||
normalized["dm_id"] = serde_json::json!(final_dm_id);
|
||||
if normalized.get("accepted").is_none() {
|
||||
normalized["accepted"] = serde_json::json!(true);
|
||||
}
|
||||
println!("{normalized}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide a DM channel — sign and submit a kind:41012 event with h-tag.
|
||||
pub async fn cmd_hide_dm(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
||||
let channel_uuid = parse_uuid(channel_id)?;
|
||||
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
let tags = vec![Tag::parse(["h", &channel_uuid.to_string()])
|
||||
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
|
||||
let builder =
|
||||
EventBuilder::new(Kind::Custom(buzz_sdk::kind::KIND_DM_HIDE as u16), "").tags(tags);
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{}", normalize_write_response(&resp));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a member to a DM group — sign and submit a kind:41011 event.
|
||||
pub async fn cmd_add_dm_member(
|
||||
client: &BuzzClient,
|
||||
channel_id: &str,
|
||||
pubkey: &str,
|
||||
) -> Result<(), CliError> {
|
||||
let channel_uuid = parse_uuid(channel_id)?;
|
||||
validate_hex64(pubkey)?;
|
||||
|
||||
let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?;
|
||||
let event = client.sign_event(builder)?;
|
||||
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{}", normalize_write_response(&resp));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch(cmd: crate::DmsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::DmsCmd;
|
||||
match cmd {
|
||||
DmsCmd::List { limit } => cmd_list_dms(client, limit).await,
|
||||
DmsCmd::Open { pubkeys } => cmd_open_dm(client, &pubkeys).await,
|
||||
DmsCmd::AddMember { channel, pubkey } => cmd_add_dm_member(client, &channel, &pubkey).await,
|
||||
DmsCmd::Hide { channel } => cmd_hide_dm(client, &channel).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use std::io::Read;
|
||||
|
||||
use crate::client::{normalize_write_response, BuzzClient};
|
||||
use crate::error::CliError;
|
||||
use crate::i18n;
|
||||
use buzz_sdk::CustomEmoji;
|
||||
|
||||
/// d-tag for a member's own custom emoji set (kind:30030). Mirrors the SDK
|
||||
/// constant; the workspace palette is the union of every member's own set.
|
||||
const CUSTOM_EMOJI_SET_D_TAG: &str = buzz_sdk::CUSTOM_EMOJI_SET_D_TAG;
|
||||
|
||||
/// Custom emoji entry in CLI output.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct EmojiEntry {
|
||||
shortcode: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Parse `["emoji", shortcode, url]` tags from one event into entries.
|
||||
fn emoji_tags_of(event: &serde_json::Value) -> Vec<EmojiEntry> {
|
||||
let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for tag in tags {
|
||||
let Some(parts) = tag.as_array() else {
|
||||
continue;
|
||||
};
|
||||
if parts.first().and_then(|v| v.as_str()) != Some("emoji") {
|
||||
continue;
|
||||
}
|
||||
let (Some(shortcode), Some(url)) = (
|
||||
parts.get(1).and_then(|v| v.as_str()),
|
||||
parts.get(2).and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
out.push(EmojiEntry {
|
||||
shortcode: shortcode.to_string(),
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Union every member's kind:30030 set, collapsed to one entry per shortcode.
|
||||
/// The most recently published set (`created_at`) wins; equal timestamps
|
||||
/// tie-break to the lexicographically-smallest URL. Deterministic and
|
||||
/// fetch-order-independent. Sorted by shortcode.
|
||||
fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
|
||||
let mut by_shortcode: std::collections::HashMap<String, (String, i64)> =
|
||||
std::collections::HashMap::new();
|
||||
for event in events {
|
||||
let created_at = event
|
||||
.get("created_at")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
for entry in emoji_tags_of(event) {
|
||||
match by_shortcode.get(&entry.shortcode) {
|
||||
Some((url, at)) if *at > created_at || (*at == created_at && *url <= entry.url) => {
|
||||
}
|
||||
_ => {
|
||||
by_shortcode.insert(entry.shortcode, (entry.url, created_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<EmojiEntry> = by_shortcode
|
||||
.into_iter()
|
||||
.map(|(shortcode, (url, _))| EmojiEntry { shortcode, url })
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
|
||||
out
|
||||
}
|
||||
|
||||
/// List the workspace custom emoji palette: the union of every member's
|
||||
/// own kind:30030 set (d=`buzz:custom-emoji`).
|
||||
async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
|
||||
"#d": [CUSTOM_EMOJI_SET_D_TAG],
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
|
||||
let emojis = union_custom_emoji(&events);
|
||||
let output = serde_json::json!({ "emojis": emojis });
|
||||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the caller's own current custom emoji set (latest kind:30030 under
|
||||
/// the d-tag, authored by the caller). Empty when none published yet.
|
||||
async fn fetch_own_emoji(client: &BuzzClient) -> Result<Vec<CustomEmoji>, CliError> {
|
||||
let me = client.keys().public_key().to_hex();
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
|
||||
"#d": [CUSTOM_EMOJI_SET_D_TAG],
|
||||
"authors": [me],
|
||||
"limit": 1,
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse own emoji set: {e}")))?;
|
||||
// The relay keeps only the latest per (pubkey, d_tag), but be defensive.
|
||||
let Some(event) = events.last() else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
Ok(emoji_tags_of(event)
|
||||
.into_iter()
|
||||
.map(|e| CustomEmoji {
|
||||
shortcode: e.shortcode,
|
||||
url: e.url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Publish the caller's own (replaced) kind:30030 set, signed as the caller.
|
||||
async fn publish_own_set(client: &BuzzClient, emojis: &[CustomEmoji]) -> Result<(), CliError> {
|
||||
let builder = buzz_sdk::build_custom_emoji_set(emojis)
|
||||
.map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?;
|
||||
let event = client.sign_event(builder)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{}", normalize_write_response(&resp));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add/update a shortcode in the caller's own set (read-modify-write).
|
||||
async fn cmd_set(client: &BuzzClient, shortcode: &str, url: &str) -> Result<(), CliError> {
|
||||
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
|
||||
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
|
||||
let mut emojis = fetch_own_emoji(client).await?;
|
||||
emojis.retain(|e| e.shortcode != normalized);
|
||||
emojis.push(CustomEmoji {
|
||||
shortcode: normalized,
|
||||
url: url.to_string(),
|
||||
});
|
||||
publish_own_set(client, &emojis).await
|
||||
}
|
||||
|
||||
/// Remove a shortcode from the caller's own set (read-modify-write).
|
||||
async fn cmd_rm(client: &BuzzClient, shortcode: &str) -> Result<(), CliError> {
|
||||
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
|
||||
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
|
||||
let mut emojis = fetch_own_emoji(client).await?;
|
||||
let before = emojis.len();
|
||||
emojis.retain(|e| e.shortcode != normalized);
|
||||
if emojis.len() == before {
|
||||
// Nothing to remove; avoid republishing an unchanged set.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"accepted": true, "message": "not present"})
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
publish_own_set(client, &emojis).await
|
||||
}
|
||||
|
||||
/// 10 MiB — a safety rail against runaway producers. An emoji manifest will
|
||||
/// never approach this size in practice.
|
||||
const STDIN_MAX_BYTES: u64 = 10_000_000;
|
||||
|
||||
/// Read from a file path or stdin. Returns `CliError::Usage` on empty stdin,
|
||||
/// `CliError::Other` on I/O failure.
|
||||
fn read_source(file: Option<&str>) -> Result<String, CliError> {
|
||||
match file {
|
||||
Some(path) => std::fs::read_to_string(path)
|
||||
.map_err(|e| CliError::Other(format!("failed to read file '{path}': {e}"))),
|
||||
None => {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.take(STDIN_MAX_BYTES)
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
|
||||
if buf.is_empty() {
|
||||
return Err(CliError::Usage(
|
||||
"no input: provide --file or pipe JSON to stdin".into(),
|
||||
));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write to a file path or stdout.
|
||||
fn write_output(output: &str, file: Option<&str>) -> Result<(), CliError> {
|
||||
match file {
|
||||
Some(path) => std::fs::write(path, output)
|
||||
.map_err(|e| CliError::Other(format!("failed to write file '{path}': {e}"))),
|
||||
None => {
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export custom emojis to stdout or a file.
|
||||
async fn cmd_export(
|
||||
client: &BuzzClient,
|
||||
file: Option<&str>,
|
||||
scope: &crate::EmojiScope,
|
||||
) -> Result<(), CliError> {
|
||||
let entries: Vec<EmojiEntry> = match scope {
|
||||
crate::EmojiScope::Own => {
|
||||
let mut entries: Vec<EmojiEntry> = fetch_own_emoji(client)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|e| EmojiEntry {
|
||||
shortcode: e.shortcode,
|
||||
url: e.url,
|
||||
})
|
||||
.collect();
|
||||
// Sort to match union_custom_emoji output order so repeated
|
||||
// export | import --replace cycles are stable.
|
||||
entries.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
|
||||
entries
|
||||
}
|
||||
crate::EmojiScope::Workspace => {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
|
||||
"#d": [CUSTOM_EMOJI_SET_D_TAG],
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
|
||||
union_custom_emoji(&events)
|
||||
}
|
||||
};
|
||||
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
|
||||
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
|
||||
write_output(&output, file)
|
||||
}
|
||||
|
||||
/// Import custom emojis from stdin or a file into the caller's own set.
|
||||
async fn cmd_import(
|
||||
client: &BuzzClient,
|
||||
file: Option<&str>,
|
||||
replace: bool,
|
||||
dry_run: bool,
|
||||
) -> Result<(), CliError> {
|
||||
// 1. Read raw JSON
|
||||
let raw = read_source(file)?;
|
||||
|
||||
// 2. Parse and extract ["emojis"] array
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| CliError::Usage(format!("invalid JSON: {e}")))?;
|
||||
let arr = parsed
|
||||
.get("emojis")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| {
|
||||
CliError::Usage("input must be a JSON object with an \"emojis\" array".into())
|
||||
})?;
|
||||
|
||||
// 3–4. Parse each element and normalize shortcodes
|
||||
let mut import_entries: Vec<CustomEmoji> = Vec::with_capacity(arr.len());
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
let shortcode = item
|
||||
.get("shortcode")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"shortcode\" field")))?;
|
||||
let url = item
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"url\" field")))?;
|
||||
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
|
||||
.map_err(|e| CliError::Usage(format!("emojis[{i}]: invalid shortcode: {e}")))?;
|
||||
import_entries.push(CustomEmoji {
|
||||
shortcode: normalized,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Deduplicate within the import batch (first occurrence wins)
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
import_entries.retain(|e| seen.insert(e.shortcode.clone()));
|
||||
|
||||
// 6. Build final set
|
||||
let final_set: Vec<CustomEmoji> = if replace {
|
||||
import_entries
|
||||
} else {
|
||||
let mut existing = fetch_own_emoji(client).await?;
|
||||
let existing_shortcodes: std::collections::HashSet<String> =
|
||||
existing.iter().map(|e| e.shortcode.clone()).collect();
|
||||
for entry in import_entries {
|
||||
if !existing_shortcodes.contains(&entry.shortcode) {
|
||||
existing.push(entry);
|
||||
}
|
||||
}
|
||||
existing
|
||||
};
|
||||
|
||||
// 7. Dry-run: print final set to stdout, warn to stderr
|
||||
if dry_run {
|
||||
let entries: Vec<EmojiEntry> = final_set
|
||||
.iter()
|
||||
.map(|e| EmojiEntry {
|
||||
shortcode: e.shortcode.clone(),
|
||||
url: e.url.clone(),
|
||||
})
|
||||
.collect();
|
||||
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
|
||||
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
|
||||
println!("{output}");
|
||||
eprintln!("{}", i18n::label(i18n::current(), "dry_run_not_published"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 8. Publish
|
||||
publish_own_set(client, &final_set).await
|
||||
}
|
||||
|
||||
pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::EmojiCmd;
|
||||
match cmd {
|
||||
EmojiCmd::List => cmd_list(client).await,
|
||||
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
|
||||
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
|
||||
EmojiCmd::Export { file, scope } => cmd_export(client, file.as_deref(), &scope).await,
|
||||
EmojiCmd::Import {
|
||||
file,
|
||||
replace,
|
||||
dry_run,
|
||||
} => cmd_import(client, file.as_deref(), replace, dry_run).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn union_latest_set_wins_per_shortcode() {
|
||||
let events = vec![
|
||||
serde_json::json!({
|
||||
"created_at": 100,
|
||||
"tags": [
|
||||
["d", "buzz:custom-emoji"],
|
||||
["emoji", "zort", "https://example.com/zort.png"],
|
||||
["emoji", "narf", "https://example.com/narf.png"]
|
||||
]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"created_at": 200,
|
||||
"tags": [
|
||||
["d", "buzz:custom-emoji"],
|
||||
// newer set claims zort with a different url — newer wins
|
||||
["emoji", "zort", "https://example.com/zort2.png"]
|
||||
]
|
||||
}),
|
||||
];
|
||||
let emojis = union_custom_emoji(&events);
|
||||
let pairs: Vec<(&str, &str)> = emojis
|
||||
.iter()
|
||||
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![
|
||||
("narf", "https://example.com/narf.png"),
|
||||
("zort", "https://example.com/zort2.png"),
|
||||
]
|
||||
);
|
||||
// Order-independence: reversed input yields the identical palette.
|
||||
let reversed: Vec<_> = events.into_iter().rev().collect();
|
||||
let emojis_rev = union_custom_emoji(&reversed);
|
||||
let pairs_rev: Vec<(&str, &str)> = emojis_rev
|
||||
.iter()
|
||||
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
|
||||
.collect();
|
||||
assert_eq!(pairs, pairs_rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_equal_timestamps_tie_break_to_smallest_url() {
|
||||
let events = vec![
|
||||
serde_json::json!({
|
||||
"created_at": 100,
|
||||
"tags": [["emoji", "zort", "https://example.com/zort2.png"]]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"created_at": 100,
|
||||
"tags": [["emoji", "zort", "https://example.com/zort.png"]]
|
||||
}),
|
||||
];
|
||||
let emojis = union_custom_emoji(&events);
|
||||
assert_eq!(emojis.len(), 1);
|
||||
assert_eq!(emojis[0].shortcode, "zort");
|
||||
assert_eq!(emojis[0].url, "https://example.com/zort.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use crate::client::{normalize_events, BuzzClient};
|
||||
use crate::error::CliError;
|
||||
|
||||
const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"];
|
||||
|
||||
/// Get activity feed — query events mentioning our pubkey (via p-tag).
|
||||
pub async fn cmd_get_feed(
|
||||
client: &BuzzClient,
|
||||
since: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
types: Option<&str>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
let my_pk = client.keys().public_key().to_hex();
|
||||
let limit = limit.unwrap_or(20).min(50);
|
||||
|
||||
let mut filter = serde_json::json!({
|
||||
"#p": [my_pk],
|
||||
"limit": limit
|
||||
});
|
||||
|
||||
if let Some(s) = since {
|
||||
filter["since"] = serde_json::json!(s);
|
||||
}
|
||||
|
||||
if let Some(types_str) = types {
|
||||
let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect();
|
||||
for t in &type_list {
|
||||
if !VALID_FEED_TYPES.contains(t) {
|
||||
return Err(crate::error::CliError::Usage(format!(
|
||||
"invalid feed type {t:?} — must be one of: {}",
|
||||
VALID_FEED_TYPES.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
filter["feed_types"] = serde_json::json!(type_list);
|
||||
}
|
||||
|
||||
let resp = client.query(&filter).await?;
|
||||
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
||||
events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)));
|
||||
let normalized = normalize_events(&events);
|
||||
let output = match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let evts: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&normalized).unwrap_or_default();
|
||||
let compact: Vec<serde_json::Value> = evts
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.get("id").cloned().unwrap_or_default(),
|
||||
"content": e.get("content").cloned().unwrap_or_default(),
|
||||
"created_at": e.get("created_at").cloned().unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&compact).unwrap_or_default()
|
||||
}
|
||||
crate::OutputFormat::Json => normalized,
|
||||
};
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch(
|
||||
cmd: crate::FeedCmd,
|
||||
client: &BuzzClient,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
use crate::FeedCmd;
|
||||
match cmd {
|
||||
FeedCmd::Get {
|
||||
since,
|
||||
limit,
|
||||
types,
|
||||
} => cmd_get_feed(client, since, limit, types.as_deref(), format).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use crate::client::BuzzClient;
|
||||
use crate::commands::with_git_provenance;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
|
||||
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
|
||||
|
||||
pub async fn cmd_create_issue(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
subject: &str,
|
||||
content: &str,
|
||||
labels: &[String],
|
||||
to: &[String],
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(repo_owner)?;
|
||||
validate_repo_id(repo_id)?;
|
||||
let body = read_or_stdin(content)?;
|
||||
|
||||
let meta = GitIssueMeta {
|
||||
labels: labels.to_vec(),
|
||||
recipients: to.to_vec(),
|
||||
};
|
||||
|
||||
let repo = GitRepoCoord {
|
||||
owner: repo_owner.to_string(),
|
||||
id: repo_id.to_string(),
|
||||
};
|
||||
|
||||
let builder = with_git_provenance(
|
||||
buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?,
|
||||
)?;
|
||||
let event = client.sign_event(builder)?;
|
||||
let event_id = event.id.to_hex();
|
||||
let resp = client.submit_event(event).await?;
|
||||
// `link` renders as a rich preview card in Buzz Desktop when included in
|
||||
// a chat message — agents announce issues with it (see base_prompt.md).
|
||||
let link = crate::links::issue_link(&event_id, repo_owner, repo_id);
|
||||
crate::client::print_create_response(&resp, "link", &link);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> {
|
||||
validate_hex64(event)?;
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [1621],
|
||||
"ids": [event]
|
||||
});
|
||||
let resp = client.query(&filter).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_list_issues(
|
||||
client: &BuzzClient,
|
||||
repo_owner: &str,
|
||||
repo_id: &str,
|
||||
author: Option<&str>,
|
||||
label: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(repo_owner)?;
|
||||
validate_repo_id(repo_id)?;
|
||||
|
||||
let a_value = format!("30617:{repo_owner}:{repo_id}");
|
||||
let mut filter = serde_json::json!({
|
||||
"kinds": [1621],
|
||||
"#a": [a_value]
|
||||
});
|
||||
|
||||
if let Some(pk) = author {
|
||||
validate_hex64(pk)?;
|
||||
filter["authors"] = serde_json::json!([pk]);
|
||||
}
|
||||
if let Some(l) = label {
|
||||
filter["#t"] = serde_json::json!([l]);
|
||||
}
|
||||
if let Some(n) = limit {
|
||||
filter["limit"] = serde_json::json!(n);
|
||||
}
|
||||
|
||||
let resp = client.query(&filter).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_issue_status(
|
||||
client: &BuzzClient,
|
||||
issue: &str,
|
||||
status: &str,
|
||||
content: Option<&str>,
|
||||
repo_owner: Option<&str>,
|
||||
repo_id: Option<&str>,
|
||||
euc: Option<&str>,
|
||||
to: &[String],
|
||||
) -> Result<(), CliError> {
|
||||
validate_hex64(issue)?;
|
||||
let status = crate::commands::patches::parse_status(status)?;
|
||||
let body = match content {
|
||||
Some(c) => read_or_stdin(c)?,
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let repo = match (repo_owner, repo_id) {
|
||||
(Some(owner), Some(id)) => {
|
||||
validate_hex64(owner)?;
|
||||
validate_repo_id(id)?;
|
||||
Some(GitRepoCoord {
|
||||
owner: owner.to_string(),
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(CliError::Usage(
|
||||
"--repo-owner and --repo-id must be given together".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors `buzz patches status`: default a `p` tag to the repo owner
|
||||
// for discoverability, plus a `--to` escape hatch for the issue author
|
||||
// or anyone else who should be notified of the status change.
|
||||
let mut recipients = Vec::new();
|
||||
if let Some(ref repo) = repo {
|
||||
recipients.push(repo.owner.clone());
|
||||
}
|
||||
for recipient in to {
|
||||
validate_hex64(recipient)?;
|
||||
if !recipients.contains(recipient) {
|
||||
recipients.push(recipient.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let meta = GitStatusMeta {
|
||||
root_event: issue.to_string(),
|
||||
accepted_revision_root: None,
|
||||
repo,
|
||||
euc: euc.map(str::to_string),
|
||||
recipients,
|
||||
applied_patches: vec![],
|
||||
merge_commit: None,
|
||||
applied_as_commits: vec![],
|
||||
};
|
||||
|
||||
let builder =
|
||||
with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?;
|
||||
let event = client.sign_event(builder)?;
|
||||
let resp = client.submit_event(event).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
use crate::IssuesCmd;
|
||||
match cmd {
|
||||
IssuesCmd::Create {
|
||||
repo_owner,
|
||||
repo_id,
|
||||
title,
|
||||
content,
|
||||
label,
|
||||
to,
|
||||
} => cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await,
|
||||
IssuesCmd::Get { event } => cmd_get_issue(client, &event).await,
|
||||
IssuesCmd::List {
|
||||
repo_owner,
|
||||
repo_id,
|
||||
author,
|
||||
label,
|
||||
limit,
|
||||
} => {
|
||||
cmd_list_issues(
|
||||
client,
|
||||
&repo_owner,
|
||||
&repo_id,
|
||||
author.as_deref(),
|
||||
label.as_deref(),
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
IssuesCmd::Status {
|
||||
issue,
|
||||
status,
|
||||
content,
|
||||
repo_owner,
|
||||
repo_id,
|
||||
euc,
|
||||
to,
|
||||
} => {
|
||||
cmd_issue_status(
|
||||
client,
|
||||
&issue,
|
||||
&status,
|
||||
content.as_deref(),
|
||||
repo_owner.as_deref(),
|
||||
repo_id.as_deref(),
|
||||
euc.as_deref(),
|
||||
&to,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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
Reference in New Issue
Block a user