Compare commits

..
76 Commits
Author SHA1 Message Date
hh c120953b02 feat(frontends,conversations,telegram): the agent can hand a file to the human 2026-09-08 14:04:44 +02:00
hh 5eb9f435ec feat(conversations): the recall hook and the user sink learn whose turn it is 2026-09-06 23:26:26 +02:00
hh e79e9fc149 feat(frontends,security,api,ui): a frontend can hold a token scope of its own 2026-09-06 23:26:26 +02:00
hh 60782d6276 feat(ui,docs): the open tab lives in the conversation url, admin screenshots in the readme 2026-09-05 04:55:54 +02:00
hh 421248b0ad fix(ui): a stream that went quiet reconnects, the rail says so, the panel opens on the master
A phone that slept brings back an SSE socket that never errored and never
delivers: the panel read as live for hours while showing nothing new. Every
chunk now marks the connection alive and a watchdog drops the socket after
40s of silence - twice the server's keepalive - so the loop reconnects.
Each attempt gets its own controller, which is what lets the watchdog (and
a manual refresh) end one connection without ending the loop.

The connection is admitted in one dot beside the rail's search box, tapped
to reconnect at once rather than waiting out the backoff - the pill that
used to say this was dropped in the redesign and the panel had no way to
own up to being stale.

With nothing picked, the shell lands on the freshest open master instead of
a prompt to pick something: what a phone wants when the panel opens. An
explicit pick, and following the active note, still win.
2026-09-05 00:20:03 +02:00
hh 8019c76e53 fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn 2026-09-04 20:23:33 +02:00
hh 2afd3bc3c1 chore(ui): drop an unused suppression 2026-09-02 05:17:51 +02:00
hh 5b851c11d4 perf(panel): cached info and history paint first, lazy markdown up the thread, background prefetch 2026-09-02 05:16:13 +02:00
hh 2d903c7c5f fix(panel): thread scroller opts out of scroll anchoring 2026-09-02 05:08:51 +02:00
hh ef7474f636 fix(panel): composer no longer doubles the status-bar inset 2026-09-02 05:00:35 +02:00
hh fe1c894b93 fix(panel): column-reverse thread keeps the bottom, no island in the plugin 2026-09-02 04:58:48 +02:00
hh 2815956f5b feat(ui): times on injects and every spoken message 2026-09-02 04:52:26 +02:00
hh c46ba026ba feat(api): limits read unifiedWindows, unknown seven-day windows labelled by model 2026-09-02 04:46:43 +02:00
hh 81d6560bf7 style(ui): flat menu sheets, digests folded with job runs, shorter spend caption 2026-09-02 04:43:04 +02:00
hh f35980ba85 feat(ui): graph on d3-force with steady labels, quieter quota text, flat rail 2026-09-02 04:35:01 +02:00
hh c6401f1114 feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words 2026-09-02 04:24:15 +02:00
hh 025f2dbc3f feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph 2026-09-02 03:48:26 +02:00
hh 85b14e2c2f feat(telegram): stop generation from the client, questions survive a restart, images reach the model inline, albums become one turn 2026-09-02 02:11:44 +02:00
hh 7a8ee0f200 feat(markdown): thinking blocks between hidden tool calls collapse to the first one 2026-09-02 01:44:02 +02:00
hh 2d34d488d7 docs(examples): compose for the minimal setup - postgres, real env, no phase notes 2026-09-02 01:28:51 +02:00
hh 0e07e8409d refactor: no comments left - one-line module docstrings, contracts on public fields only; jobs/job.py; example config and README 2026-09-02 00:33:04 +02:00
hh 3915ab48f9 refactor(telegram): every user-facing string lives in TelegramTexts with English defaults 2026-09-02 00:16:38 +02:00
hh 9aaddbed75 refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text 2026-09-02 00:13:20 +02:00
hh 253bde1b11 fix(telegram,conversations): attachments by day with sweep in both modes, merge renames the topic, gone topics unbind, failures visible 2026-09-01 23:13:22 +02:00
hh 61b5bda31a feat(telegram): block-level markdown to html, wikilinks, tables, quotes, fence-safe chunking 2026-09-01 23:13:21 +02:00
hh e2d611dd81 feat(agents,claude_sdk): skill sets per conversation kind 2026-09-01 23:13:21 +02:00
hh bac40ff268 feat(scheduler,conversations,api): cron and schedule in the gateway tz, job runs persisted 2026-09-01 23:13:17 +02:00
hh 39f2bc05d9 chore(deps): claude-agent-sdk 0.2.150 bundles cli 2.1.257 2026-09-01 22:00:41 +02:00
hh 76d6f81789 fix(mcp,redact,gateway_tools): mask credentials in tool results, names kept and values gone 2026-09-01 14:12:49 +00:00
hh 8f9ca2800c fix(redact,cli): mask credentials in every log line, silence httpx request urls 2026-09-01 14:12:49 +00:00
hh f9bf51badf feat(envelope,conversations): recall hook under the user text, user_sink for the setup's reply log 2026-09-01 16:08:53 +02:00
hh 90063a7cfe fix(storage,api): usage cost as per-turn delta of the sdk running total, backfill 2026-09-01 15:37:53 +02:00
hh a8491330fa feat(injects,conversations,scheduler,gateway_tools,api): wake priority, master and parent aliases, scheduled injects start a turn 2026-08-30 20:34:12 +02:00
hh 064379ca90 fix(cli): mask query tokens in uvicorn access log 2026-08-30 18:30:26 +02:00
hh e90963a6c3 feat(auth): bootstrap token entries carry an optional scope 2026-08-30 18:25:48 +02:00
hh 064d855382 feat(auth): query-string token for headerless webhook senders 2026-08-30 18:10:00 +02:00
hh 5d67cfcc9e fix(gateway_tools,conversations): say refused in user turns, the reply reaches the human itself 2026-08-30 02:26:35 +02:00
hh b4853b9fd0 feat(scheduler): job dedupe flag for webhooks that must not collapse 2026-08-29 22:15:15 +02:00
hh 2d12391176 fix(ui): chat keeps the bottom pinned while async markdown lands 2026-08-29 19:26:45 +02:00
hh 60dc7db925 feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews 2026-08-29 18:30:33 +02:00
hh be114e6eaf feat(injects,conversations): configurable inject header, urgent inject marks the turn it cut 2026-08-29 17:07:43 +02:00
hh 4a59c59bda fix(claude_sdk,conversations,rotation): context size from the last api call, new-day text by rotation reason 2026-08-29 16:54:29 +02:00
hh 72639c4b90 feat(policy,claude_sdk,cli,config_loader): pretooluse policy rules per agent, tool call audit, sibling imports for config 2026-08-29 15:27:26 +02:00
hh 1786406b31 feat(distill,conversations,scheduler,api,ui): close deep chats with a distiller fork, digest and index, idle and weekly state jobs 2026-08-29 03:15:39 +02:00
hh 12728b2eb4 refactor(envelope): drop the reply counter and the injects line, keep time and vault changes 2026-08-29 02:38:39 +02:00
hh bc4233977d feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page 2026-08-29 02:29:05 +02:00
hh fd69eae05d feat(conversations): pre-SDK rows read from canonical messages, implied titles, markdown adopts them by first prompt 2026-08-29 01:44:05 +02:00
hh d3a3e7b1f1 fix(markdown): a rewritten assistant reply reseeds the session instead of resuming it 2026-08-29 01:37:52 +02:00
hh 772db11c7f fix(ui): sidebar footer in one row, no user name 2026-08-29 01:19:58 +02:00
hh 4fb020c788 fix(ui): thread header counts only queued items; label the live dot 2026-08-29 01:08:44 +02:00
hh 932a7a80af feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars 2026-08-29 00:49:06 +02:00
hh 3ca5843868 feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews 2026-08-29 00:18:08 +02:00
hh 8ea4cea3ef feat(ui): endpoint catalog with copyable urls on the now page 2026-08-28 22:30:11 +02:00
hh b6c5a80326 chore(ui): beaver favicon from beaver-land and impeccable design sidecar 2026-08-28 22:20:36 +02:00
hh 98796a82c6 feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot 2026-08-28 22:14:15 +02:00
hh 61562e947d fix(backends): remember the session id from the first frame so a cut turn stays resumable 2026-08-28 19:38:47 +02:00
hh 609af9fe67 fix(core): a turn cut by shutdown keeps running_turn for recover 2026-08-28 19:33:26 +02:00
hh e469e7e174 fix(telegram): brief-seeded branches reply and stream in their topic 2026-08-28 19:21:03 +02:00
hh c88726db9e feat(core): seeds without text wait for the first turn 2026-08-28 19:03:08 +02:00
hh 38986cb82e fix(telegram,core): no draft for seed turns 2026-08-28 18:59:00 +02:00
hh 43407016d0 fix(telegram): last draft push is the final text so the client folds it into the message 2026-08-28 18:51:16 +02:00
hh 7240e0cdac fix(telegram,core): bare seeds stay silent in telegram 2026-08-28 18:42:35 +02:00
hh 7b0c61c18e fix(telegram): final message waits for the draft push in flight 2026-08-28 18:32:45 +02:00
hh 0110cbd1cc fix(telegram,core): first message seeds the branch, registered commands, no ellipses, html drafts 2026-08-28 18:25:46 +02:00
hh 9663fbf956 feat(telegram,core): master topic instead of general in private chats 2026-08-28 18:12:58 +02:00
hh 5177392f46 feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons 2026-08-28 17:57:09 +02:00
hh 5fc58e3bc7 fix(backends,markdown): stream turn events live, no blank lines for tool blocks 2026-08-28 17:27:35 +02:00
hh 5d2ce68f6b feat(core): publish tool.result on the bus for the activity panel 2026-08-28 17:01:00 +02:00
hh 70349e5ff4 refactor(agents,core,frontends): typed kinds, prompts per kind, frontend agents as parameters 2026-08-28 16:36:44 +02:00
hh 827fa0977b feat(core,frontends,agents): agent kinds, frontend routing, anthropic on conversations, vault mirror 2026-08-28 03:50:33 +02:00
hh e3074c266a feat(core,backends,frontends,storage): conversations, inject queue, session pool, gateway mcp tools, api frontend 2026-08-28 03:08:30 +02:00
hh ab52fdc2b8 fix(backends,storage,core): setuid in exec wrapper, missing-column migration, runner config dir for mirror, tagged prompt sources 2026-08-28 02:10:19 +02:00
hh 7424d52f88 feat(backends,storage,core,markdown,infra): claude agent sdk backend, session store, transcript seeding, runner isolation 2026-08-28 01:56:39 +02:00
hh b3a584a362 docs(env): .env.example for local development 2026-08-27 23:45:53 +02:00
hh a2a80ab150 chore: make ruff format and ty clean
Format two mcp modules with the current ruff, and narrow the text-block
check in conversation_store through a typed helper so ty stops flagging
dict.get on an unnarrowed block.
2026-08-27 23:22:23 +02:00
hh d33039f497 feat(ui,infra): SvelteKit ui scaffold, Makefile, pre-commit, claude hooks
ui/: SvelteKit 2 + Svelte 5 + Tailwind 4 + shadcn-svelte (luma, lucide)
on bun with ultracite; adapter-static with SPA fallback; theme tokens
taken from msos. Dockerfile gains a bun stage that builds ui/build.
Makefile and pre-commit cover ruff, ty, ultracite and svelte-check;
.claude settings run the matching fixer after edits. docs/PRODUCT.md
captures product truth for the UI work.
2026-08-27 23:22:18 +02:00
23 changed files with 734 additions and 36 deletions

No files matched your search

+4
View File
@@ -28,3 +28,7 @@ RAYCAST_BEARER=
# токен от @BotFather, свой user id - @userinfobot
TELEGRAM_BOT_TOKEN=
TELEGRAM_USER_ID=
# Откуда тулза send_file имеет право брать файлы (через ':').
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
SEND_FILE_ROOTS=/mnt/hole/shared
+6 -2
View File
@@ -22,9 +22,13 @@ is a full setup built on them.
the envelope, the in-process gateway tools.
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
the subscription budget.
- **frontends/** - the windows: Telegram (master = General, topic = branch),
- **frontends/** - the windows: Telegram (master = General, topic = branch,
files out of the agent through `send_file`),
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.
+8 -1
View File
@@ -6,6 +6,7 @@ import asyncio
import functools
import logging
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any
import psycopg
@@ -33,7 +34,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 +195,8 @@ 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),
send_file_roots=_roots(settings.send_file_roots),
)
for fe in gateway.frontends:
fe.configure(runtime)
@@ -296,6 +299,10 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
await uvicorn.Server(config).serve()
def _roots(value: str) -> tuple[Path, ...]:
return tuple(Path(p) for p in value.split(":") if p.strip())
class _LateConversations:
conversations: Conversations | None = None
+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
+27 -1
View File
@@ -1,4 +1,4 @@
"""Putting words into a conversation: a message, an inject, ``say``, ``schedule``."""
"""Putting words into a conversation: a message, an inject, ``say``, a file."""
from __future__ import annotations
@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.turns import Turns
from beaver_gateway.frontends.files import SendFileError
if TYPE_CHECKING:
from datetime import datetime
@@ -110,6 +111,31 @@ class Messaging(Turns):
turn_id=runner.turn_id if runner is not None else None,
)
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Hand a file to the human: this conversation's window, else the master's."""
for target in (conv, await self.open_master()):
if target is None:
continue
for fe in self._frontends:
if target.kind in fe.kinds and fe.sends_files:
note = await fe.send_file(
target, path, caption=caption, method=method
)
_log.info("send_file[%s]: %s", target.external_id, note)
return note
msg = (
"nowhere to send: no frontend that sends files shows this "
"conversation, and there is no open master"
)
raise SendFileError(msg)
def file_note(self) -> str:
"""What the ``send_file`` tool tells the model about roots and size."""
sender = next((f for f in self._frontends if f.sends_files), None)
return sender.file_note() if sender else "no frontend here sends files"
async def schedule(
self,
conv: Conversation,
@@ -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)
+46 -2
View File
@@ -14,6 +14,7 @@ from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import as_kind
from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError
from beaver_gateway.security.redact import redact_data
URGENCY_HELP = (
@@ -42,7 +43,23 @@ SAY_IN_USER_TURN = (
"reply text reaches them by itself - put what you wanted to say into the "
"reply instead of repeating it here"
)
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat")
TOOL_NAMES = (
"read_conversation",
"spawn",
"say",
"schedule",
"inject",
"close_chat",
"send_file",
)
SEND_FILE_HELP = (
"Send a file from disk to the human, into this conversation's window (the "
"master's if it has none): {note}. `method` is how Telegram renders it; "
"`auto` reads the extension - image is a photo, mp3/flac/m4a audio, ogg "
"voice, mp4/mov video, anything else a document. Photo and video are "
"recompressed, so pass `document` when the bytes must arrive untouched. "
"`caption` takes the same markdown as your replies."
)
def build_tool_server(
@@ -232,6 +249,33 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
)
return _text(f"queued #{item.id}")
@tool(
"send_file",
SEND_FILE_HELP.format(note=conversations.file_note()),
{
"type": "object",
"properties": {
"path": {"type": "string", "description": "absolute path on disk"},
"caption": {"type": "string"},
"method": {"type": "string", "enum": [AUTO, *METHODS], "default": AUTO},
},
"required": ["path"],
},
)
async def send_file(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
return _text(
await conversations.send_file(
conv,
str(args["path"]),
caption=str(args.get("caption") or ""),
method=str(args.get("method") or AUTO),
)
)
except SendFileError as exc:
return _error(str(exc))
@tool(
"close_chat",
"Close this deep chat once the current reply is finished: the "
@@ -248,7 +292,7 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
return _error(str(exc))
return _text("ok: the chat closes after this reply")
return [read_conversation, spawn, say, schedule, inject, close_chat]
return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
def _text(text: str) -> dict[str, Any]:
+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:
+31
View File
@@ -10,8 +10,12 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from beaver_gateway.frontends.files import SendFileError
from beaver_gateway.security.auth import BUILTIN_SCOPES
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
from pathlib import Path
from starlette.types import ASGIApp
@@ -54,6 +58,10 @@ 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."""
send_file_roots: tuple[Path, ...] = ()
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
class Frontend(ABC):
@@ -64,6 +72,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 +88,9 @@ class Frontend(ABC):
kinds: tuple[Kind, ...] = ()
path: str | None = None
landing: bool = False
scope: str | None = None
sends_files: bool = False
"""Whether ``send_file`` reaches a human through this frontend."""
@abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ...
@@ -95,3 +110,19 @@ class Frontend(ABC):
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
"""Show in the window that the conversation is over (a renamed topic)."""
return False
async def send_file(
self,
conv: Conversation, # noqa: ARG002
path: str, # noqa: ARG002
*,
caption: str = "", # noqa: ARG002
method: str = "auto", # noqa: ARG002
) -> str:
"""Put a file in this conversation's window; the answer goes to the model."""
msg = f"frontend {self.name!r} cannot send files"
raise SendFileError(msg)
def file_note(self) -> str:
"""Roots and ceiling, for the tool description the model reads."""
return ""
+95
View File
@@ -0,0 +1,95 @@
"""What an agent may hand to a human: an allowed path, a ceiling, a method.
The ceiling and the method names belong to the frontend's API; the check
lives here so every frontend refuses the same way, in words the model reads.
"""
from __future__ import annotations
import mimetypes
from dataclasses import dataclass, replace
from pathlib import Path
__all__ = ["AUTO", "DEFAULTS", "METHODS", "SendFileError", "SendFiles", "method_for"]
AUTO = "auto"
METHODS = ("document", "photo", "audio", "voice", "video", "video_note", "animation")
_BY_SUFFIX = {
".jpg": "photo",
".jpeg": "photo",
".png": "photo",
".webp": "photo",
".gif": "animation",
".mp3": "audio",
".flac": "audio",
".m4a": "audio",
".wav": "audio",
".aac": "audio",
".ogg": "voice",
".oga": "voice",
".opus": "voice",
".mp4": "video",
".m4v": "video",
".mov": "video",
}
_BY_TYPE = {"image": "photo", "audio": "audio", "video": "video"}
class SendFileError(Exception):
"""Why a file was not sent, worded for the model that asked."""
@dataclass(frozen=True, slots=True)
class SendFiles:
"""Which files a frontend accepts from an agent and how big they may be."""
roots: tuple[Path, ...] = ()
"""Directories a path must resolve inside; nothing outside is sendable."""
max_bytes: int = 50 * 1024 * 1024
"""Upload ceiling of the API behind the frontend; the Bot API gives 50 MB."""
def with_roots(self, *roots: Path) -> SendFiles:
return replace(self, roots=tuple(dict.fromkeys((*self.roots, *roots))))
@property
def note(self) -> str:
"""Roots and ceiling as one line for the tool description."""
where = ", ".join(str(r) for r in self.roots) or "none, so nothing is sendable"
return f"paths under {where}, up to {mb(self.max_bytes)} MB"
def resolve(self, raw: str) -> Path:
path = Path(raw.strip()).expanduser().resolve()
if not any(path.is_relative_to(r.expanduser().resolve()) for r in self.roots):
msg = f"{path} is outside the allowed roots ({self.note}); copy it there"
raise SendFileError(msg)
if not path.is_file():
msg = f"{path} is not a file"
raise SendFileError(msg)
size = path.stat().st_size
if size > self.max_bytes:
msg = (
f"{path.name} is {mb(size)} MB, over the {mb(self.max_bytes)} MB limit"
)
raise SendFileError(msg)
return path
DEFAULTS = SendFiles()
def method_for(path: Path, method: str = AUTO) -> str:
if method and method != AUTO:
if method not in METHODS:
msg = f"unknown method {method!r}; one of: {', '.join((AUTO, *METHODS))}"
raise SendFileError(msg)
return method
guessed = _BY_SUFFIX.get(path.suffix.lower())
if guessed:
return guessed
mime, _ = mimetypes.guess_type(path.name)
return _BY_TYPE.get(mime.split("/")[0], "document") if mime else "document"
def mb(size: int) -> str:
return f"{round(size / 1024 / 1024, 2):g}"
@@ -21,10 +21,13 @@ from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.exceptions import TelegramAPIError
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
from aiogram.types import (
BotCommand,
CallbackQuery,
FSInputFile,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
@@ -33,10 +36,17 @@ from aiogram.types import (
)
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.files import (
DEFAULTS,
SendFileError,
SendFiles,
mb,
method_for,
)
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.inbox import Inbox
from beaver_gateway.frontends.telegram.outbox import Outbox
from beaver_gateway.frontends.telegram.render import chunks, status_label
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
from beaver_gateway.frontends.telegram.texts import TelegramTexts
if TYPE_CHECKING:
@@ -53,6 +63,8 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram")
FRONTEND = "telegram"
_DONE = "done"
_COMMANDS = ("merge", "new", "chat", "status", "help", "start")
_CAPTION = 1024
_NO_CAPTION = ("video_note",)
@dataclass(frozen=True, slots=True)
@@ -134,6 +146,7 @@ class _Album:
class TelegramFrontend(Frontend):
name = FRONTEND
kinds = ("master", "branch")
sends_files = True
def __init__(
self,
@@ -144,6 +157,8 @@ class TelegramFrontend(Frontend):
branch_agent: str | None = None,
chat_id: int | None = None,
attachments: Attachments = EPHEMERAL,
send_files: SendFiles = DEFAULTS,
api_base_url: str | None = None,
draft_interval: float = 0.7,
master_topic: str = "🦫 General",
queued_reaction: str = "👀",
@@ -159,6 +174,8 @@ class TelegramFrontend(Frontend):
self.master_agent = master_agent
self.branch_agent = branch_agent
self.attachments = attachments
self.send_files = send_files
self.api_base_url = api_base_url
self.master_topic = master_topic
self.draft_interval = draft_interval
self.queued_reaction = queued_reaction
@@ -187,7 +204,14 @@ class TelegramFrontend(Frontend):
raise RuntimeError(msg)
self._runtime = runtime
if self._bot is None:
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None))
self._bot = Bot(
self._token,
session=self._session(),
default=DefaultBotProperties(parse_mode=None),
)
self.send_files = self.send_files.with_roots(
*runtime.send_file_roots, self.attachments.root
)
self._inbox = Inbox(
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
)
@@ -195,6 +219,16 @@ class TelegramFrontend(Frontend):
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
)
def _session(self) -> AiohttpSession | None:
"""A local Bot API server instead of api.telegram.org (2 GB uploads)."""
if not self.api_base_url:
return None
return AiohttpSession(
api=TelegramAPIServer.from_base(
self.api_base_url.rstrip("/"), is_local=True
)
)
async def serve(self) -> None:
me = await self.bot.get_me()
_log.info(
@@ -251,6 +285,44 @@ class TelegramFrontend(Frontend):
self._topic_names[target[1]] = f"{prefix}{name}"
return True
def file_note(self) -> str:
return self.send_files.note
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Upload the file into this conversation's topic, caption and all."""
target = await self._target_of(conv)
if target is None:
msg = f"conversation {conv.external_id} has no Telegram window"
raise SendFileError(msg)
file = self.send_files.resolve(path)
how = method_for(file, method)
rest = caption.strip()
payload: dict[str, Any] = {
"message_thread_id": target[1],
how: FSInputFile(file),
}
if how == "audio":
payload["title"] = file.stem
if rest and how not in _NO_CAPTION and len(rest) <= _CAPTION:
payload |= {"caption": to_html(rest), "parse_mode": "HTML"}
rest = ""
try:
await getattr(self.bot, f"send_{how}")(target[0], **payload)
except TelegramNetworkError as exc:
msg = f"Telegram did not answer while sending {file.name}: {exc}"
raise SendFileError(msg) from exc
except TelegramAPIError as exc:
msg = f"Telegram refused {file.name} as {how}: {exc}"
raise SendFileError(msg) from exc
except (OSError, TimeoutError) as exc:
msg = f"sending {file.name} failed: {exc}"
raise SendFileError(msg) from exc
if rest:
await self._deliver(conv, rest)
return f"sent {file.name} as {how}, {mb(file.stat().st_size)} MB"
@property
def bot(self) -> Bot:
if self._bot is None:
@@ -26,6 +26,7 @@ TOOL_LABELS: dict[str, str] = {
"mcp__gateway__say": "speaking",
"mcp__gateway__schedule": "setting a reminder",
"mcp__gateway__inject": "passing to another conversation",
"mcp__gateway__send_file": "sending a file",
}
+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",
]
+4
View File
@@ -57,6 +57,10 @@ class Settings(BaseSettings):
"""``Accept-Language`` sent to the Raycast API and the default locale for
auto ``UserPreferences``. One value per gateway (one shared Client)."""
send_file_roots: str = "/mnt/hole/shared"
"""Directories the ``send_file`` tool may read, separated by ``:``. A
frontend always adds the inbox its own attachments land in."""
bootstrap_tokens: str = ""
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
alongside DB-issued tokens; used for first-run setup and recovery."""
+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"
+159
View File
@@ -1,6 +1,7 @@
import asyncio
import base64
import contextlib
import functools
import tempfile
from pathlib import Path
from types import SimpleNamespace
@@ -14,7 +15,9 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.conversations.tools import _tools
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.render import (
@@ -36,6 +39,8 @@ class FakeBot:
def __init__(self) -> None:
self.updates: list[dict[str, Any]] = []
self.sent: list[dict[str, Any]] = []
self.files: list[dict[str, Any]] = []
self.reject_files = False
self.drafts: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = []
self.topics: list[str] = []
@@ -132,6 +137,21 @@ class FakeBot:
async def download(self, file_id, destination=None) -> None:
Path(destination).write_bytes(b"data")
async def _upload(self, method: str, chat_id: int, **kwargs: Any) -> Any:
if self.reject_files:
raise TelegramBadRequest(
method=SendMessage(chat_id=0, text="x"),
message="Bad Request: PHOTO_INVALID_DIMENSIONS",
)
self._message_id += 1
self.files.append({"method": method, "chat_id": chat_id, **kwargs})
return SimpleNamespace(message_id=self._message_id)
def __getattr__(self, name: str) -> Any:
if name.startswith("send_"):
return functools.partial(self._upload, name.removeprefix("send_"))
raise AttributeError(name)
# helpers for tests
def push(self, payload: dict[str, Any]) -> None:
payload["update_id"] = 1000 + len(self.updates)
@@ -1035,3 +1055,142 @@ async def test_question_survives_a_gateway_restart(stack: Stack) -> None:
await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
row = await stack.world.conversations.get(master.external_id)
assert row.flags.get("ask") is None
def _sendable(name: str, body: bytes = b"x") -> tuple[Path, Path]:
root = Path(tempfile.mkdtemp(prefix="beaver-send-")).resolve()
file = root / name
file.write_bytes(body)
return root, file
def test_send_files_takes_only_paths_inside_its_roots() -> None:
root, note = _sendable("note.txt")
outside = Path(tempfile.mkdtemp(prefix="beaver-out-")).resolve()
(outside / "secret.txt").write_bytes(b"y")
(root / "escape.txt").symlink_to(outside / "secret.txt")
files = SendFiles(roots=(root,))
assert files.resolve(f"{root}/../{root.name}/./note.txt") == note
for bad in (
str(outside / "secret.txt"),
f"{root}/../{outside.name}/secret.txt",
str(root / "escape.txt"),
):
with pytest.raises(SendFileError, match="outside the allowed roots"):
files.resolve(bad)
with pytest.raises(SendFileError, match="is not a file"):
files.resolve(str(root / "gone.txt"))
with pytest.raises(SendFileError, match="outside the allowed roots"):
SendFiles().resolve(str(note))
def test_send_files_stops_a_file_over_the_limit() -> None:
root, big = _sendable("big.bin", b"0" * (2 * 1024 * 1024))
files = SendFiles(roots=(root,), max_bytes=1024 * 1024)
with pytest.raises(SendFileError, match="big.bin is 2 MB, over the 1 MB limit"):
files.resolve(str(big))
assert SendFiles(roots=(root,), max_bytes=4 * 1024 * 1024).resolve(str(big)) == big
assert files.note == f"paths under {root}, up to 1 MB"
def test_method_follows_the_extension_unless_it_is_given() -> None:
picked = {
name: method_for(Path(name))
for name in ("a.JPG", "b.gif", "c.mp3", "d.ogg", "e.mov", "f.zip", "g.svg")
}
assert picked == {
"a.JPG": "photo",
"b.gif": "animation",
"c.mp3": "audio",
"d.ogg": "voice",
"e.mov": "video",
"f.zip": "document",
"g.svg": "photo",
}
assert method_for(Path("a.jpg"), "document") == "document"
with pytest.raises(SendFileError, match="unknown method 'sticker'"):
method_for(Path("a.jpg"), "sticker")
async def _send(stack: Stack, conv, **args: Any) -> dict[str, Any]:
tool = next(
t
for t in _tools(stack.world.conversations, conv.external_id)
if t.name == "send_file"
)
return await tool.handler(args)
async def test_send_file_lands_in_the_topic_of_the_conversation(stack: Stack) -> None:
stack.bot.message("work", thread=11)
await stack.until(lambda: stack.sent_with("work"), what="branch reply")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/11"
)
root, shot = _sendable("график.png", b"png")
stack.tg.send_files = SendFiles(roots=(root,))
result = await _send(stack, branch, path=str(shot), caption="**вот**")
assert result["content"][0]["text"].startswith("sent график.png as photo")
(sent,) = stack.bot.files
assert sent["method"] == "photo"
assert sent["message_thread_id"] == 11
assert sent["photo"].path == shot
assert sent["caption"] == "<b>вот</b>" and sent["parse_mode"] == "HTML"
async def test_long_caption_follows_the_file_as_a_message(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
root, doc = _sendable("отчёт.pdf")
stack.tg.send_files = SendFiles(roots=(root,))
await _send(stack, master, path=str(doc), caption="долго " * 300)
(sent,) = stack.bot.files
assert sent["method"] == "document" and "caption" not in sent
await stack.until(lambda: stack.sent_with("долго"), what="caption message")
async def test_send_file_without_a_window_falls_back_to_the_master(
stack: Stack,
) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
job = await stack.world.conversations.spawn(
kind="job", agent="a", seed="brief", text="job", parent=None, origin="test"
)
root, song = _sendable("Пикник.mp3")
stack.tg.send_files = SendFiles(roots=(root,))
await _send(stack, job, path=str(song))
(sent,) = stack.bot.files
assert sent["method"] == "audio" and sent["title"] == "Пикник"
assert (sent["chat_id"], sent["message_thread_id"]) == (USER, 901)
async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
root, shot = _sendable("bad.png")
stack.tg.send_files = SendFiles(roots=(root,))
stack.bot.reject_files = True
result = await _send(stack, master, path=str(shot))
assert result["is_error"]
assert "Telegram refused bad.png as photo" in result["content"][0]["text"]
outside = await _send(stack, master, path="/etc/passwd")
assert outside["is_error"]
assert "outside the allowed roots" in outside["content"][0]["text"]
+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>