Compare commits

...
2 Commits
15 changed files with 293 additions and 29 deletions

No files matched your search

+4 -1
View File
@@ -24,7 +24,10 @@ is a full setup built on them.
the subscription budget.
- **frontends/** - the windows: Telegram (master = General, topic = branch),
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`.
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
`WebhookFrontend` for a window a setup declares itself - its own request
and response schema, its own agent, its own token scope
(`docs/FRONTEND-PLUGINS.md`).
- **mcp/** - MCP servers a setup declares (stdio, http, python tools),
aggregated in-process and handed to agents by name.
- **vault/** - watching a directory of notes for the envelope.
+2 -1
View File
@@ -33,7 +33,7 @@ from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.jobs.scheduler import Scheduler
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.security.auth import TokenStore, scopes_with
from beaver_gateway.storage import (
Database,
PostgresSessionStore,
@@ -194,6 +194,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
pool=pool,
scheduler=scheduler,
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
scopes=scopes_with(fe.scope for fe in gateway.frontends),
)
for fe in gateway.frontends:
fe.configure(runtime)
+9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
import sys
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -30,6 +31,8 @@ if TYPE_CHECKING:
__all__ = ["ConfigError", "Gateway", "load"]
_SCOPE = re.compile(r"[a-z][a-z0-9_-]*")
@dataclass(slots=True)
class Gateway:
@@ -135,6 +138,12 @@ def _validate(gw: Gateway, path: Path) -> None:
f"got {type(f).__name__}"
)
raise ConfigError(msg)
if f.scope is not None and not _SCOPE.fullmatch(f.scope):
msg = (
f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; "
f"a frontend scope is a lowercase name like 'voice', never '*'"
)
raise ConfigError(msg)
names: set[str] = set()
for i, j in enumerate(gw.jobs):
if not isinstance(j, Job):
+19 -6
View File
@@ -25,6 +25,8 @@ class RecallContext:
text: str
kind: str
now: datetime
agent: str = ""
"""Agent whose turn it is; a setup with several tells them apart by it."""
@dataclass(slots=True)
@@ -40,7 +42,12 @@ class Envelope:
texts: EnvelopeTexts = field(default_factory=EnvelopeTexts)
def build(
self, *, now: datetime | None = None, text: str = "", kind: str = "master"
self,
*,
now: datetime | None = None,
text: str = "",
kind: str = "master",
agent: str = "",
) -> str:
now = now or datetime.now(UTC)
changes = self.watch.take() if self.watch is not None else []
@@ -59,20 +66,26 @@ class Envelope:
texts=self.texts,
)
self.last_at = now
block = self.recall_block(text=text, kind=kind, now=now)
block = self.recall_block(text=text, kind=kind, now=now, agent=agent)
return f"{out}\n{block}" if block else out
def recall_only(
self, *, text: str, kind: str, now: datetime | None = None
self, *, text: str, kind: str, now: datetime | None = None, agent: str = ""
) -> str | None:
block = self.recall_block(text=text, kind=kind, now=now or datetime.now(UTC))
block = self.recall_block(
text=text, kind=kind, now=now or datetime.now(UTC), agent=agent
)
return f"{self.texts.header}\n{block}" if block else None
def recall_block(self, *, text: str, kind: str, now: datetime) -> str | None:
def recall_block(
self, *, text: str, kind: str, now: datetime, agent: str = ""
) -> str | None:
if self.recall is None or not text.strip():
return None
try:
block = self.recall(RecallContext(text=text, kind=kind, now=now))
block = self.recall(
RecallContext(text=text, kind=kind, now=now, agent=agent)
)
except Exception: # noqa: BLE001
_log.exception("recall hook failed")
return None
@@ -51,6 +51,8 @@ class UserSaid:
title: str | None
text: str
at: datetime
agent: str = ""
"""Agent whose turn it is; a setup with several tells them apart by it."""
@dataclass(frozen=True, slots=True)
+5 -2
View File
@@ -336,9 +336,11 @@ class Turns(Seeds):
if self._envelope is None:
return None
if conv.kind == "master":
return self._envelope.build(text=text, kind="master")
return self._envelope.build(text=text, kind="master", agent=conv.agent_name)
if conv.kind == "branch":
return self._envelope.recall_only(text=text, kind="branch")
return self._envelope.recall_only(
text=text, kind="branch", agent=conv.agent_name
)
return None
async def _note_user(self, conv: Conversation, text: str) -> None:
@@ -350,6 +352,7 @@ class Turns(Seeds):
title=conv.title,
text=text,
at=datetime.now(UTC),
agent=conv.agent_name,
)
try:
result = self._user_sink(message)
+16 -2
View File
@@ -35,7 +35,7 @@ from beaver_gateway.frontends.sse import (
)
from beaver_gateway.frontends.urls import frontend_url
from beaver_gateway.security import audit
from beaver_gateway.security.auth import VALID_SCOPES, hash_token
from beaver_gateway.security.auth import hash_token
from beaver_gateway.storage import (
create_token,
list_audit_records,
@@ -217,6 +217,7 @@ def build_app( # noqa: PLR0915
k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k)
},
"path": fe.path,
"scope": fe.scope,
"url": frontend_url(request, runtime, fe),
}
for fe in runtime.frontends
@@ -814,6 +815,19 @@ def build_app( # noqa: PLR0915
"content": content,
}
@app.get("/scopes")
async def scopes(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
by_frontend = {
fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope
}
return {
"scopes": [
{"scope": s, "frontend": by_frontend.get(s)}
for s in sorted(runtime.scopes)
]
}
@app.get("/tokens")
async def tokens(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
@@ -828,7 +842,7 @@ def build_app( # noqa: PLR0915
data = await body_of(request)
name = text_of(data, "name").strip()
scope = str(data.get("scope") or "*")
if scope not in VALID_SCOPES:
if scope not in runtime.scopes:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}")
plaintext = secrets.token_urlsafe(32)
async with runtime.db.session() as session:
+9
View File
@@ -10,6 +10,8 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from beaver_gateway.security.auth import BUILTIN_SCOPES
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
@@ -54,6 +56,8 @@ class GatewayRuntime:
scheduler: Any = None
public_url: str | None = None
"""``Gateway.public_url``; ``None`` derives the origin from the request."""
scopes: frozenset[str] = BUILTIN_SCOPES
"""Every token scope this gateway knows: the builtins plus each frontend's."""
class Frontend(ABC):
@@ -64,6 +68,10 @@ class Frontend(ABC):
is for non-HTTP work (polling, vault mirrors) and defaults to nothing.
``landing`` marks the app that ``/`` redirects to.
``scope`` is the token scope this frontend's routes are gated by; a
frontend that names its own scope gets keys nobody else's token opens,
and ``POST /api/tokens`` will mint them (see ``security/auth.py``).
A frontend that shows conversations declares ``name`` (the binding
key) and ``kinds`` (which conversation kinds it shows). The first
frontend whose ``materialize`` returns a binding is the *home* of
@@ -76,6 +84,7 @@ class Frontend(ABC):
kinds: tuple[Kind, ...] = ()
path: str | None = None
landing: bool = False
scope: str | None = None
@abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ...
+16 -7
View File
@@ -21,7 +21,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError
from beaver_gateway.storage import list_active_tokens, touch_token
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from beaver_gateway.storage import Database
@@ -35,8 +35,8 @@ class TokenStoreError(ValueError):
"""Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token."""
VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
"""The scopes a ``Token.scope`` may hold.
BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"})
"""The scopes the gateway's own frontends define.
* ``*`` — wildcard, may use any frontend
* ``messages`` — Anthropic Messages frontend only
@@ -45,16 +45,24 @@ VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"
* ``admin`` — reserved for programmatic admin access; the AdminFrontend
itself authenticates via session cookies, not bearer tokens, so this
scope is unused today.
A setup's own frontend adds its scope on top; see :func:`scopes_with` and
``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates.
"""
def scopes_with(extra: Iterable[str | None]) -> frozenset[str]:
"""The builtin scopes plus every frontend's own; ``None`` entries drop out."""
return BUILTIN_SCOPES | {s for s in extra if s}
@dataclass(frozen=True, slots=True)
class TokenIdentity:
"""What :meth:`TokenStore.verify` resolves to on success.
``token_id`` is the DB row id for persisted tokens, or ``None`` for
an env-bootstrap match (those have no DB row to touch). ``scope``
gates which frontend the token may hit (see :data:`VALID_SCOPES`);
gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`);
bootstrap tokens implicitly get ``"*"``.
"""
@@ -67,8 +75,8 @@ class TokenIdentity:
``"*"`` is the wildcard; an exact match satisfies a single scope.
Unknown ``required`` values intentionally fall through to a
strict equality check — callers should pass one of
:data:`VALID_SCOPES`.
strict equality check — callers should pass a scope the runtime
knows (:data:`BUILTIN_SCOPES` plus every frontend's own).
"""
return self.scope in ("*", required)
@@ -325,11 +333,12 @@ class TokenStore:
__all__ = [
"VALID_SCOPES",
"BUILTIN_SCOPES",
"TokenIdentity",
"TokenStore",
"TokenStoreError",
"hash_token",
"scopes_with",
]
+22
View File
@@ -219,3 +219,25 @@ async def test_failing_recall_or_sink_never_blocks_the_turn(world: World) -> Non
assert prompt.startswith("hello\n\n" + HEADER)
assert root.exists()
await asyncio.sleep(0)
async def test_the_recall_hook_and_the_sink_see_whose_turn_it_is(world: World) -> None:
root, watch = vault()
seen: list[RecallContext] = []
def recall(ctx: RecallContext) -> str | None:
seen.append(ctx)
return None if ctx.agent == "a" else "👤 указатель"
noted: list[UserSaid] = []
world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=recall) # noqa: SLF001
world.conversations._user_sink = noted.append # noqa: SLF001
master = await world.conversations.create(kind="master", agent="a", origin="test")
append(root / "люди" / "Прохор.md", "новое\n")
watch.note(root / "люди" / "Прохор.md")
await world.conversations.post(master, "что там у Прохор")
await world.settle(master, 1)
assert [ctx.agent for ctx in seen] == ["a"]
assert [m.agent for m in noted] == ["a"]
assert "👤" not in ScriptedClient.instances[0].prompts[0]
await asyncio.sleep(0)
+133
View File
@@ -0,0 +1,133 @@
import tempfile
from pathlib import Path
import httpx
import pytest
from fastapi import FastAPI, Request
from beaver_gateway.app import AgentRegistry, McpRegistry
from beaver_gateway.frontends.api.frontend import build_app as build_api
from beaver_gateway.frontends.base import Frontend, GatewayRuntime
from beaver_gateway.frontends.bearer import require_token
from beaver_gateway.security.auth import BUILTIN_SCOPES, TokenStore, scopes_with
from beaver_gateway.storage import Database
ROOT = {"Authorization": "Bearer root-key"}
VOICE = {"Authorization": "Bearer voice-key"}
OPS = {"Authorization": "Bearer ops-key"}
class Speaker(Frontend):
"""A frontend a setup declares, gated by a scope of its own."""
name = "voice"
path = "/voice"
scope = "voice"
def configure(self, runtime: GatewayRuntime) -> None:
self.runtime = runtime
def app(self) -> FastAPI:
app = FastAPI()
@app.post("/")
async def ask(request: Request) -> dict[str, str]:
return {"by": await require_token(request, self.runtime, scope=self.scope)}
return app
@pytest.fixture
async def runtime() -> GatewayRuntime:
root = Path(tempfile.mkdtemp(prefix="beaver-scopes-"))
db = Database(f"sqlite:///{root / 's.db'}")
await db.create_all()
speaker = Speaker()
rt = GatewayRuntime(
agents=AgentRegistry([]),
mcps=McpRegistry([]),
backends={},
token_store=TokenStore(
db,
bootstrap={"root": "root-key", "voice": "voice-key", "ops": "ops-key"},
bootstrap_scopes={"voice": "voice", "ops": "api"},
),
db=db,
frontends=(speaker,),
scopes=scopes_with(fe.scope for fe in (speaker,)),
)
speaker.configure(rt)
yield rt
await db.dispose()
def api(runtime: GatewayRuntime) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=build_api(runtime)), base_url="http://t"
)
def frontend(runtime: GatewayRuntime) -> httpx.AsyncClient:
fe = runtime.frontends[0]
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=fe.app()), base_url="http://t"
)
def test_a_frontend_scope_joins_the_builtin_vocabulary() -> None:
assert "voice" not in BUILTIN_SCOPES
assert scopes_with(["voice", None, "api"]) == BUILTIN_SCOPES | {"voice"}
async def test_the_scope_of_a_frontend_is_listed_with_its_name(
runtime: GatewayRuntime,
) -> None:
async with api(runtime) as client:
listed = await client.get("/scopes", headers=ROOT)
scopes = listed.json()["scopes"]
assert {"scope": "voice", "frontend": "voice"} in scopes
assert {"scope": "api", "frontend": None} in scopes
async def test_tokens_mint_for_a_declared_scope_and_not_for_an_unknown_one(
runtime: GatewayRuntime,
) -> None:
async with api(runtime) as client:
minted = await client.post(
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
)
unknown = await client.post(
"/tokens", json={"name": "nope", "scope": "kitchen"}, headers=ROOT
)
assert minted.status_code == 201
assert minted.json()["token"]["scope"] == "voice"
assert unknown.status_code == 400
assert "kitchen" in unknown.json()["error"]
async def test_the_route_opens_for_its_own_scope_only(runtime: GatewayRuntime) -> None:
async with frontend(runtime) as client:
own = await client.post("/", headers=VOICE)
other = await client.post("/", headers=OPS)
wildcard = await client.post("/", headers=ROOT)
anonymous = await client.post("/")
assert own.status_code == 200
assert own.json()["by"] == "voice"
assert other.status_code == 403
assert wildcard.status_code == 200
assert anonymous.status_code == 401
async def test_a_minted_token_reaches_the_frontend_it_was_minted_for(
runtime: GatewayRuntime,
) -> None:
async with api(runtime) as client:
plaintext = (
await client.post(
"/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT
)
).json()["plaintext"]
async with frontend(runtime) as client:
opened = await client.post("/", headers={"X-Api-Key": plaintext})
assert opened.status_code == 200
assert opened.json()["by"] == "esp32-hall"
+5
View File
@@ -12,6 +12,7 @@ import type {
LimitsResponse,
MemoryFile,
MemoryTree,
ScopeRow,
SearchResponse,
SessionsResponse,
TokenRow,
@@ -351,6 +352,10 @@ export class ApiClient {
});
}
scopes(): Promise<{ scopes: ScopeRow[] }> {
return this.get("/api/scopes");
}
createToken(
name: string,
scope: string
+6
View File
@@ -132,6 +132,7 @@ export interface FrontendInfo {
kinds: Kind[];
name: string;
path: string | null;
scope: string | null;
type: string;
url: string | null;
}
@@ -245,6 +246,11 @@ export interface MemoryFile {
size: number;
}
export interface ScopeRow {
frontend: string | null;
scope: string;
}
export interface TokenRow {
created_at: string;
id: number;
+11 -1
View File
@@ -117,7 +117,17 @@
};
default:
return {
endpoints: base ? [{ hint: "", label: "base", url: base }] : [],
endpoints: base
? [
{
hint: fe.scope
? `POST the frontend's own payload; bearer with scope ${fe.scope}`
: "",
label: "base",
url: base,
},
]
: [],
frontend: fe,
note: "",
};
+34 -9
View File
@@ -2,7 +2,7 @@
import CopyIcon from "@lucide/svelte/icons/copy";
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import type { TokenRow } from "$lib/api/types";
import type { ScopeRow, TokenRow } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
@@ -15,13 +15,23 @@
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const SCOPES = [
{ hint: "every frontend", value: "*" },
{ hint: "conversations API and SSE (panel, plugin)", value: "api" },
{ hint: "Anthropic /v1/messages", value: "messages" },
{ hint: "MCP server", value: "mcp" },
{ hint: "tokens and audit over the API", value: "admin" },
];
const HINTS: Record<string, string> = {
"*": "every frontend",
admin: "tokens and audit over the API",
api: "conversations API and SSE (panel, plugin)",
mcp: "MCP server",
messages: "Anthropic /v1/messages",
};
let scopes = $state<ScopeRow[]>([]);
const options = $derived(
scopes.map((row) => ({
hint: row.frontend
? `the ${row.frontend} frontend`
: (HINTS[row.scope] ?? ""),
value: row.scope,
}))
);
let tokens = $state<TokenRow[] | null>(null);
let includeRevoked = $state(false);
@@ -49,6 +59,21 @@
load(includeRevoked);
});
$effect(() => {
const { client } = session;
if (!client) {
return;
}
client
.scopes()
.then(({ scopes: rows }) => {
scopes = rows;
})
.catch(() => {
scopes = [];
});
});
async function create() {
const { client } = session;
if (!client) {
@@ -254,7 +279,7 @@
>
<Select.Trigger class="w-full">{scope}</Select.Trigger>
<Select.Content>
{#each SCOPES as option (option.value)}
{#each options as option (option.value)}
<Select.Item label={option.value} value={option.value}>
<span class="flex flex-col">
<span>{option.value}</span>