Compare commits

..
5 Commits
45 changed files with 2455 additions and 33 deletions

No files matched your search

+5 -1
View File
@@ -74,7 +74,7 @@ ENV BEAVER_CLAUDE_CLI=/usr/local/bin/claude-cli
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
# without chowning files that Obsidian Sync keeps rewriting as root.
RUN apt-get update \
&& apt-get install -y --no-install-recommends acl \
&& apt-get install -y --no-install-recommends acl sudo \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1001 --create-home --shell /usr/sbin/nologin beaver-runner
@@ -83,6 +83,10 @@ ENV PATH=/app/.venv/bin:$PATH
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app /app
COPY --from=ui /ui/build /app/ui/build
COPY --chmod=0755 docker/secret /usr/local/bin/secret
COPY --chmod=0755 docker/beaver-secret /usr/local/libexec/beaver-secret
COPY --chmod=0440 docker/sudoers /etc/sudoers.d/beaver-secret
RUN visudo -cf /etc/sudoers.d/beaver-secret
WORKDIR /app
ENTRYPOINT ["python", "-m"]
+6 -1
View File
@@ -33,6 +33,11 @@ is a full setup built on them.
aggregated in-process and handed to agents by name.
- **vault/** - watching a directory of notes for the envelope.
- **security/** - bearer tokens, the audit log, credential redaction.
- **keystore/** - secrets the model runs commands with: values and files
sealed with `SECRETS_KEY` (AES-256-GCM) in the gateway's database, set
write-only from the admin, handed out only through `secret run NAME --
cmd` (one `sudo` rule for `beaver-runner`, a root-only socket, files in
`/dev/shm`, values masked in the command's output).
- **events/** - the in-process bus every frontend taps, and the stream
event protocol backends emit.
- **storage/** - SQLModel tables and the Postgres session store.
@@ -40,7 +45,7 @@ is a full setup built on them.
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
from it; `cli.py` is the entrypoint. The admin SPA in `ui/` is the browser
window on all of it: conversations by day, the thread with the tree of tool
calls, the context of the last turn, memory, jobs, usage, tokens and the audit.
calls, the context of the last turn, memory, jobs, usage, tokens, secrets and the audit.
![A conversation in the admin: the thread, the queue, the details](preview/shots/admin-thread.webp)
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/local/bin/python3 -I /app/src/beaver_gateway/keystore/cli.py --root "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/local/bin/python3 -I /app/src/beaver_gateway/keystore/cli.py "$@"
+2
View File
@@ -0,0 +1,2 @@
Defaults:beaver-runner !syslog
beaver-runner ALL=(root) NOPASSWD: /usr/local/libexec/beaver-secret
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"argon2-cffi>=25.1.0",
"claude-agent-sdk>=0.2.157",
"croniter>=6.2.4",
"cryptography>=48.0.0",
"fastapi>=0.136.1",
"fastmcp>=3.3.1",
"greenlet>=3.5.0",
+1
View File
@@ -46,6 +46,7 @@ class ClaudeOptions(BaseModel):
"""Extra inherited variable names to let through the env whitelist."""
max_turns: int | None = None
max_buffer_size: int = 16 * 1024 * 1024
session_store_flush: str = "eager"
"""``eager`` mirrors every transcript frame as it lands, so a gateway
killed mid-turn loses at most the frame in flight."""
+38 -1
View File
@@ -33,6 +33,8 @@ from beaver_gateway.frontends.base import GatewayRuntime
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.keystore import SecretBox, SecretStore
from beaver_gateway.keystore import serve as serve_secrets
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.security.auth import TokenStore, scopes_with
from beaver_gateway.storage import (
@@ -44,7 +46,7 @@ from beaver_gateway.storage import (
)
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from collections.abc import Iterable, Iterator, Sequence
from claude_agent_sdk import McpSdkServerConfig
from fastmcp import FastMCP
@@ -120,6 +122,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
mcps = McpRegistry(gateway.mcps)
db = Database(settings.database_url)
await db.create_all()
secrets = SecretStore(db, _secret_box(settings))
token_store = TokenStore(
db,
bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens),
@@ -197,6 +200,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
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),
secrets=secrets,
)
for fe in gateway.frontends:
fe.configure(runtime)
@@ -224,10 +228,43 @@ async def run(gateway: Gateway, settings: Settings) -> None:
tg.create_task(_serve_root(gateway, extra={"/hooks": hooks}))
if gateway.watch is not None:
tg.create_task(gateway.watch.run())
if secrets.enabled:
tg.create_task(_serve_secrets(secrets, settings=settings, db=db))
for fe in gateway.frontends:
tg.create_task(fe.serve())
def _secret_box(settings: Settings) -> SecretBox | None:
if not settings.secrets_key:
return None
try:
return SecretBox.from_hex(settings.secrets_key)
except ValueError as exc:
_log.error("secrets are off: %s", exc)
return None
async def _serve_secrets(
store: SecretStore, *, settings: Settings, db: Database
) -> None:
async def used(names: Sequence[str]) -> None:
try:
async with db.session() as session:
await append_audit(
session,
actor="runner",
kind="secret_use",
detail={"names": list(names)},
)
except Exception: # noqa: BLE001
_log.exception("secret audit write failed")
try:
await serve_secrets(store, settings.secrets_socket, on_use=used)
except OSError:
_log.exception("secrets socket %s did not open", settings.secrets_socket)
async def _authorize_hook(
request: Request, *, runtime: GatewayRuntime, scope: str
) -> str:
+13 -1
View File
@@ -111,6 +111,7 @@ warnings.filterwarnings("ignore", category=CanUseToolShadowedWarning)
ASK_TOOL = "AskUserQuestion"
__all__ = [
"ORIGIN_HEADER",
"AuditSink",
"ClaudeSdkBackend",
"RunnerConfig",
@@ -120,6 +121,9 @@ __all__ = [
"fingerprint",
]
ORIGIN_HEADER = "X-Conversation-Origin"
"""Rides on every exposed MCP request; the gateway proxy passes it upstream."""
ENV_KEEP: tuple[str, ...] = (
"PATH",
"HOME",
@@ -724,7 +728,14 @@ class ClaudeSdkBackend:
system_prompt = (
prompt_assembly.assemble(sources) if sources else agent.system_prompt
)
servers: dict[str, Any] = dict(self._servers) if spec.tools else {}
servers: dict[str, Any] = (
{
name: {**server, "headers": {ORIGIN_HEADER: key}}
for name, server in self._servers.items()
}
if spec.tools
else {}
)
gateway = (
self._tool_server(key, spec.kind)
if spec.tools and self._tool_server is not None and agent.gateway_tools
@@ -756,6 +767,7 @@ class ClaudeSdkBackend:
plugins=cast("Any", plugins),
skills="all" if plugins else None,
max_turns=opt.max_turns,
max_buffer_size=opt.max_buffer_size,
stderr=lambda line: self._stderr(line.rstrip(), stderr),
)
+30 -1
View File
@@ -20,13 +20,14 @@ except ImportError: # pragma: no cover
_cli_version = "2.1.248"
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
__all__ = [
"CLI_VERSION",
"build_entries",
"close_open_tool_uses",
"fingerprint",
"last_message_before",
"messages_from_entries",
"open_tool_uses",
"prompt_count",
@@ -35,6 +36,7 @@ __all__ = [
"spoken_text",
"strip_tool_entries",
"text_of",
"turn_starts",
"window_entries",
]
@@ -438,6 +440,33 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
return sum(1 for e in entries if _is_prompt_entry(e))
def turn_starts(entries: Iterable[Mapping[str, Any]]) -> list[int]:
"""Where each turn opens: the index of every prompt the SDK was handed.
The CLI stamps only those with ``promptSource``; what it writes as a
user on its own (an interruption note, a skill body) stays inside the
turn it belongs to.
"""
return [
index
for index, entry in enumerate(entries)
if entry.get("promptSource") and _is_prompt_entry(entry)
]
def last_message_before(entries: Sequence[Mapping[str, Any]], index: int) -> str | None:
"""Uuid of the last ``user``/``assistant`` entry ahead of ``index``."""
for entry in reversed(entries[:index]):
uuid = entry.get("uuid")
if (
entry.get("type") in _PROMPT_TYPES
and isinstance(uuid, str)
and not entry.get("isSidechain")
):
return uuid
return None
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
"""What a content block (dict or model) says to the human.
@@ -7,7 +7,7 @@ import contextlib
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
from beaver_gateway.conversations.spawning import Spawning
from beaver_gateway.conversations.rewinding import Rewinding
from beaver_gateway.conversations.state import Question
if TYPE_CHECKING:
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
__all__ = ["Questions"]
class Questions(Spawning):
class Questions(Rewinding):
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
"""Show the question and wait; ``None`` when nobody answered in time."""
conv = await self.get(key)
@@ -0,0 +1,114 @@
"""Taking turns back: the thread moves to a fork that ends before them."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import fork_session_via_store
from beaver_gateway.backends.transcript import (
last_message_before,
messages_from_entries,
reply_text,
text_of,
turn_starts,
)
from beaver_gateway.conversations.spawning import Spawning
if TYPE_CHECKING:
from collections.abc import Sequence
from beaver_gateway.storage.models import Conversation
__all__ = ["Rewinding", "Rewound"]
_log = logging.getLogger(__name__)
PREVIEW = 280
@dataclass(frozen=True, slots=True)
class Rewound:
conversation: Conversation
turns: int
previous_session: str
class Rewinding(Spawning):
async def turns(
self, conv: Conversation, *, limit: int = 10
) -> list[dict[str, Any]]:
"""The latest turns that can be taken back, oldest first; the first stays."""
entries = await self.entries(conv)
starts = turn_starts(entries)
ends = [*starts[1:], len(entries)]
spans = list(zip(starts, ends, strict=True))[1:]
agent = self._agents.get(conv.agent_name)
progress = agent is not None and agent.thinking_is_progress
return [
self._turn(entries[start:end], thinking_is_progress=progress)
for start, end in spans[-limit:]
]
def _turn(
self, entries: Sequence[Any], *, thinking_is_progress: bool
) -> dict[str, Any]:
prompt, *rest = messages_from_entries(entries)
replies = (
reply_text(m["content"], thinking_is_progress=thinking_is_progress)
for m in reversed(rest)
if m["role"] == "assistant"
)
return {
"id": entries[0].get("uuid"),
"ts": entries[0].get("timestamp"),
"text": text_of(self._unwrapped(prompt)["content"])[:PREVIEW],
"reply": next((text for text in replies if text), "")[:PREVIEW],
"failed": any(e.get("isApiErrorMessage") for e in entries),
}
async def rewind(self, conv: Conversation, turn: str) -> Rewound:
"""Take ``turn`` and every turn after it out of what the model sees.
Nothing is deleted: the conversation moves to a fork of its session
that ends on the message before ``turn``, the cut session stays in
the store under its old id.
"""
row_id = cast("int", conv.id)
runner = self._runner(row_id)
if runner.lock.locked():
msg = f"conversation {conv.external_id} is mid-turn; interrupt it first"
raise RuntimeError(msg)
async with runner.lock:
row = await self.get_row(row_id) or conv
entries = await self.entries(row)
starts = turn_starts(entries)
cut = next((i for i in starts[1:] if entries[i].get("uuid") == turn), None)
keep = last_message_before(entries, cut) if cut is not None else None
if cut is None or keep is None:
msg = f"conversation {row.external_id} has no turn {turn} to take back"
raise LookupError(msg)
previous = cast("str", row.session_id)
forked = await fork_session_via_store(
self._store,
previous,
directory=str(self._claude_agent(row.agent_name).cwd),
up_to_message_id=keep,
)
await self._backend(row.agent_name).close(row.external_id)
async def apply(moved: Conversation) -> None:
moved.session_id = forked.session_id
row = await self._update(row, apply)
turns = sum(1 for start in starts if start >= cut)
_log.info(
"rewound %s by %d turns: session %s -> %s",
row.external_id,
turns,
previous,
forked.session_id,
)
return Rewound(conversation=row, turns=turns, previous_session=previous)
+12 -2
View File
@@ -77,9 +77,19 @@ class Rotation:
out.append((master, reason))
return out
async def tick(self, now: datetime | None = None) -> list[Conversation]:
async def tick(
self, now: datetime | None = None, *, force: str | None = None
) -> list[Conversation]:
"""Rotate the masters that are due; ``force`` rotates every open master."""
rotated: list[Conversation] = []
for master, reason in await self.due(now):
if force:
due = [
(m, force)
for m in await self._conversations.find(kind="master", status="open")
]
else:
due = await self.due(now)
for master, reason in due:
new = await self.rotate(master, reason)
if new is not None:
rotated.append(new)
+1 -1
View File
@@ -1,7 +1,7 @@
"""``Conversations`` - the service every frontend, job and gateway tool talks to.
Built as layers, one file each: rows → seeds → turns → messaging →
spawning → questions → closing; this file adds start, stop and restart
spawning → rewinding → questions → closing; this file adds start, stop and restart
recovery. A turn started by a user message streams back to whoever asked;
a turn started by an inject streams nowhere and can only speak via ``say``.
"""
+1 -2
View File
@@ -148,8 +148,7 @@ class Spawning(Messaging):
)
if result.text.strip():
await self.inject(parent, result.text, urgency="normal", origin="merge")
await self.set_status(conv, "merged")
await self.mark_closed(conv)
await self.mark_closed(await self.set_status(conv, "merged"))
self._bus.publish(
"conversation.merged",
conversation_id=conv.external_id,
+4 -4
View File
@@ -68,14 +68,14 @@ class Turns(Seeds):
backend = self._backend(conv.agent_name)
turn_id = turn_id or f"turn_{uuid4().hex[:12]}"
capture = capture or TurnCapture()
resume = session_id if session_id is not None else conv.session_id
async with runner.lock:
runner.turn_id = turn_id
runner.origin = origin
runner.text = shown if shown is not None else _prompt_preview(messages)
runner.started_at = datetime.now(UTC)
runner.tools = {}
await self._mark_running(conv, turn_id)
row = await self._mark_running(conv, turn_id)
resume = session_id if session_id is not None else row.session_id
before = await self.before_turn(conv)
self._bus.publish(
"turn.start",
@@ -463,12 +463,12 @@ class Turns(Seeds):
except Exception: # noqa: BLE001
_log.exception("rate limit write failed")
async def _mark_running(self, conv: Conversation, turn_id: str) -> None:
async def _mark_running(self, conv: Conversation, turn_id: str) -> Conversation:
async def apply(row: Conversation) -> None:
row.running_turn = turn_id
row.last_activity_at = datetime.now(UTC)
await self._update(conv, apply)
return await self._update(conv, apply)
async def _mark_done(
self, conv: Conversation, capture: TurnCapture, *, cut: bool = False
+135 -1
View File
@@ -1,11 +1,13 @@
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits.
Bearer scope ``api``; token and audit management need ``admin``. Every
Bearer scope ``api``; tokens, secrets and audit need ``admin``. Every
write goes through conversations/service.py; the frontend only shapes JSON.
"""
from __future__ import annotations
import base64
import binascii
import json
import logging
import secrets
@@ -34,6 +36,7 @@ from beaver_gateway.frontends.sse import (
sse_pack,
)
from beaver_gateway.frontends.urls import frontend_url
from beaver_gateway.keystore import SecretsDisabledError
from beaver_gateway.security import audit
from beaver_gateway.security.auth import hash_token
from beaver_gateway.storage import (
@@ -46,6 +49,7 @@ from beaver_gateway.storage.models import (
Conversation,
InjectQueueItem,
RateLimit,
Secret,
Token,
Usage,
)
@@ -79,6 +83,8 @@ MEMORY_MAX_ENTRIES = 5000
SEARCH_MIN = 2
GRAPH_LIMIT = 120
SEARCH_LIMIT = 20
TURNS_LIMIT = 10
TURNS_MAX = 50
SNIPPET_MAX = 160
@@ -578,6 +584,42 @@ def build_app( # noqa: PLR0915
"text": result.text,
}
@app.get("/conversations/{public_id}/turns")
async def get_turns(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
limit = min(query_int(request, "limit", TURNS_LIMIT), TURNS_MAX)
return {
"id": conv.external_id,
"turns": await conversations.turns(conv, limit=limit),
}
@app.post("/conversations/{public_id}/rewind")
async def post_rewind(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=ADMIN_SCOPE)
conv = await conv_of(public_id)
turn = text_of(await body_of(request), "turn")
try:
result = await conversations.rewind(conv, turn)
except LookupError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
out = {
"turns": result.turns,
"session_id": result.conversation.session_id,
"previous_session": result.previous_session,
}
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_rewind",
agent_name=conv.agent_name,
conversation=conv.external_id,
**out,
)
return {"id": conv.external_id, **out}
@app.post("/conversations/{public_id}/bind")
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
@@ -940,6 +982,58 @@ def build_app( # noqa: PLR0915
)
return {"id": token_id, "revoked": True}
@app.get("/secrets")
async def secrets_list(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
store = runtime.secrets
rows = await store.rows() if store is not None else []
return {
"enabled": store is not None and store.enabled,
"secrets": [_secret_public(r) for r in rows],
}
@app.put("/secrets/{name}")
async def secret_put(name: str, request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
store = runtime.secrets
if store is None or not store.enabled:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "SECRETS_KEY is not set"
)
data = await body_of(request)
description = data.get("description")
payload, filename = _secret_payload(data)
try:
row = await store.put(
name,
description=str(description) if description is not None else None,
data=payload,
filename=filename,
)
except SecretsDisabledError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await audit.log(
runtime,
actor=f"token:{actor}",
kind="secret_set",
name=name,
replaced=payload is not None,
)
return {"secret": _secret_public(row)}
@app.delete("/secrets/{name}")
async def secret_delete(name: str, request: Request) -> dict[str, Any]:
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
store = runtime.secrets
if store is None or not await store.delete(name):
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no secret {name}")
await audit.log(
runtime, actor=f"token:{actor}", kind="secret_delete", name=name
)
return {"name": name, "deleted": True}
@app.get("/audit")
async def audit_list(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
@@ -1216,6 +1310,46 @@ def _token_public(row: Token) -> dict[str, Any]:
}
def _secret_public(row: Secret) -> dict[str, Any]:
return {
"name": row.name,
"description": row.description,
"kind": "file" if row.filename else "value",
"filename": row.filename,
"size": row.size,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
"used_at": _iso(row.used_at),
}
def _secret_payload(data: dict[str, Any]) -> tuple[bytes | None, str | None]:
value, upload = data.get("value"), data.get("file")
if value is not None and upload is not None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "send `value` or `file`, not both"
)
if isinstance(value, str):
if not value:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`value` is empty")
return value.encode(), None
if isinstance(upload, dict):
filename, content = upload.get("name"), upload.get("content")
if not isinstance(filename, str) or not isinstance(content, str):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "`file` needs `name` and base64 `content`"
)
try:
return base64.b64decode(content, validate=True), filename
except binascii.Error as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "`file.content` is not base64"
) from exc
if value is not None or upload is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`value` must be a string")
return None, None
def _detail(raw: str) -> Any:
try:
return json.loads(raw) if raw else {}
+3
View File
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
from beaver_gateway.backends.base import Backend
from beaver_gateway.conversations.kinds import Kind
from beaver_gateway.frontends.turn_record import TurnRecord
from beaver_gateway.keystore import SecretStore
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import Conversation, ConversationBinding
@@ -62,6 +63,8 @@ class GatewayRuntime:
"""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``."""
secrets: SecretStore | None = None
"""Sealed secrets for ``secret run``; ``None`` in setups without them."""
class Frontend(ABC):
@@ -268,7 +268,10 @@ class TelegramFrontend(Frontend):
)
async def mark_closed(self, conv: Conversation) -> bool:
return await self.mark_topic(conv)
merged = conv.status == "merged"
return await self.mark_topic(
conv, self.texts.closed_prefix if merged else self.texts.idle_prefix
)
async def mark_topic(self, conv: Conversation, prefix: str | None = None) -> bool:
"""Rename the topic; closeForumTopic does not exist in private chats."""
@@ -279,10 +282,7 @@ class TelegramFrontend(Frontend):
name = self._topic_names.get(target[1]) or conv.title or self.texts.branch
if name.startswith(prefix):
return True
await self.bot.edit_forum_topic(
target[0], target[1], name=f"{prefix}{name}"[:128]
)
self._topic_names[target[1]] = f"{prefix}{name}"
await self._rename(target[1], f"{prefix}{self._unmarked(name)}")
return True
def file_note(self) -> str:
@@ -462,6 +462,36 @@ class TelegramFrontend(Frontend):
attachments=attachments,
)
async def _branch_in_topic(
self, topic: int, *, text: str, attachments: list[dict[str, Any]] | None
) -> None:
"""A fresh topic is named by the first line, a closed one loses its mark."""
current = self._topic_names.get(topic)
previous = None
if current is None:
previous = await self.conversations.find_bound(
frontend=FRONTEND, external_id=self._ext(topic)
)
known = current or (previous.title if previous is not None else None)
title = (self._unmarked(known) if known else "") or self._title_from(text)
await self._branch(topic, title=title, text=text, attachments=attachments)
if current == title:
return
try:
await self._rename(topic, title)
except TelegramAPIError as exc:
_log.warning("topic %s not renamed to %r: %s", topic, title, exc)
async def _rename(self, topic: int, name: str) -> None:
name = name[:128]
await self.bot.edit_forum_topic(self.chat_id, topic, name=name)
self._topic_names[topic] = name
def _unmarked(self, name: str) -> str:
for prefix in (self.texts.closed_prefix, self.texts.idle_prefix):
name = name.removeprefix(prefix)
return name
async def _handle(self, update: Update) -> None:
if update.message is not None:
await self._on_message(update.message)
@@ -592,9 +622,8 @@ class TelegramFrontend(Frontend):
topic = cast("int", thread_id)
conv = await self._live(topic)
if conv is None:
title = self._topic_names.get(topic) or self._title_from(text)
await self._branch(
topic, title=title, text=text, attachments=attachments or None
await self._branch_in_topic(
topic, text=text, attachments=attachments or None
)
return
pending = self.conversations.pending_question(conv.external_id)
@@ -42,6 +42,7 @@ class TelegramTexts:
help_head: str = "General is the master, any other topic is a branch."
branch: str = "branch"
closed_prefix: str = "✅ "
idle_prefix: str = "💤 "
waiting: str = "⏳ thinking"
thinking: str = "🤔 thinking"
writing: str = "✍️ writing"
+35 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Coroutine
@@ -73,6 +73,23 @@ class JobRun:
)
return True
async def inject_into(
self,
public_id: str | None,
text: str,
*,
urgency: Priority = "normal",
origin: str | None = None,
) -> bool:
"""Inject into that conversation, or the master once it is gone or over."""
conv = await self.conversations.get(public_id) if public_id else None
if conv is None or conv.status != "open":
return await self.inject_master(text, urgency=urgency, origin=origin)
await self.conversations.inject(
conv, text, urgency=urgency, origin=origin or self.job.name
)
return True
async def spawn_job(
self,
*,
@@ -113,14 +130,29 @@ class JobRun:
_log.exception("closing idle %s failed", conv.external_id)
return out
async def close_quiet(
self, *, kind: str = "branch", days: int = 3, limit: int | None = None
) -> list[Conversation]:
"""Close chats quiet for ``days`` without a digest, their windows marked."""
out: list[Conversation] = []
for conv in await self.conversations.idle(kind=kind, days=days, limit=limit):
if await self.conversations.busy(
conv
) or await self.conversations.queue.pending(cast("int", conv.id)):
continue
row = await self.conversations.close(conv)
await self.conversations.mark_closed(row)
out.append(row)
return out
async def retry_in(self, delay: timedelta) -> None:
await self.scheduler.trigger(
self.job, self.payload, delay=delay, trigger=self.trigger
)
async def rotate(self) -> list[Conversation]:
async def rotate(self, *, force: str | None = None) -> list[Conversation]:
rotation = self.scheduler.rotation
return await rotation.tick() if rotation is not None else []
return await rotation.tick(force=force) if rotation is not None else []
def background(self, coro: Coroutine[Any, Any, Any]) -> None:
self.scheduler.background(coro)
+19
View File
@@ -0,0 +1,19 @@
"""Secrets the model runs commands with: sealed in the database, handed out to root."""
from beaver_gateway.keystore.server import serve
from beaver_gateway.keystore.store import (
NAME,
SecretBox,
SecretsDisabledError,
SecretStore,
UnknownSecretError,
)
__all__ = [
"NAME",
"SecretBox",
"SecretStore",
"SecretsDisabledError",
"UnknownSecretError",
"serve",
]
+289
View File
@@ -0,0 +1,289 @@
"""``secret ls`` and ``secret run NAME... -- command``: stdlib only, run by path.
The model's uid calls this file; it asks the root half (the same file
through ``sudo``) for the values, which reads them from the gateway's
root-only socket. The command then runs as the model's uid with each
value in its own variable, files in a private tmpfs directory, and the
values masked in what it prints.
"""
from __future__ import annotations
import base64
import contextlib
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any
from urllib.parse import quote
if TYPE_CHECKING:
from io import BufferedReader
SOCKET = Path("/run/beaver-secrets/gateway.sock")
ROOT_HALF = ("sudo", "-n", "/usr/local/libexec/beaver-secret")
SHM = Path("/dev/shm") # noqa: S108
PREFIX = "beaver-secret-"
NAME = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
MIN_MASKED = 6
HOLD = 1 << 16
CHUNK = 1 << 16
DRAIN = 2.0
USAGE = """usage:
secret ls names and descriptions
secret run NAME [NAME...] -- CMD run CMD with each secret in $NAME
A value secret arrives as its text; a file secret as the path to a
private copy that is removed when CMD exits. Values are masked in
CMD's output as <secret:NAME>.
"""
class SecretError(Exception):
pass
def request(payload: dict[str, Any], path: Path = SOCKET) -> dict[str, Any]:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
try:
conn.connect(str(path))
except OSError as exc:
msg = f"the gateway's secrets socket is not reachable ({exc.strerror})"
raise SecretError(msg) from exc
conn.sendall(json.dumps(payload).encode() + b"\n")
chunks = []
while chunk := conn.recv(CHUNK):
chunks.append(chunk)
reply = json.loads(b"".join(chunks) or b"{}")
if not reply.get("ok"):
raise SecretError(reply.get("error") or "the gateway gave no answer")
return reply
def root_half(argv: list[str]) -> int:
if argv[:1] == ["ls"] and len(argv) == 1:
payload: dict[str, Any] = {"op": "ls"}
elif (
argv[:1] == ["get"]
and len(argv) > 1
and all(NAME.fullmatch(a) for a in argv[1:])
):
payload = {"op": "get", "names": argv[1:]}
else:
print("beaver-secret: ls | get NAME...", file=sys.stderr)
return 2
try:
reply = request(payload)
except SecretError as exc:
reply = {"ok": False, "error": str(exc)}
sys.stdout.write(json.dumps(reply))
return 0 if reply.get("ok") else 1
def ask(argv: list[str]) -> dict[str, Any]:
if os.geteuid() == 0:
return request(
{"op": "ls"} if argv == ["ls"] else {"op": "get", "names": argv[1:]}
)
proc = subprocess.run( # noqa: S603
[*ROOT_HALF, *argv], capture_output=True, check=False
)
try:
reply = json.loads(proc.stdout)
except json.JSONDecodeError:
err = proc.stderr.decode(errors="replace").strip()
msg = f"the root helper failed: {err or f'exit {proc.returncode}'}"
raise SecretError(msg) from None
if not reply.get("ok"):
raise SecretError(reply.get("error") or "the root helper gave no answer")
return reply
def list_secrets() -> int:
rows = ask(["ls"])["secrets"]
if not rows:
print("no secrets yet; a human adds them in the admin, System -> Secrets")
return 0
width = max(len(r["name"]) for r in rows)
for r in rows:
kind = f"file {r['filename']}" if r.get("filename") else "value"
line = f"{r['name']:<{width}} {kind}"
if r.get("description"):
line += f" - {r['description']}"
print(line)
return 0
class Masker:
"""Streams bytes through, replacing every needle; never splits one."""
def __init__(self, needles: dict[bytes, bytes]) -> None:
self._needles = needles
self._longest = max((len(n) for n in needles), default=0)
ordered = sorted(needles, key=lambda n: -len(n))
self._pattern = (
re.compile(b"|".join(re.escape(n) for n in ordered)) if ordered else None
)
self._pending = b""
def feed(self, data: bytes) -> bytes:
if self._pattern is None:
return data
self._pending += data
cut = self._pending.rfind(b"\n") + 1
if len(self._pending) - cut > HOLD:
cut = max(cut, len(self._pending) - self._longest + 1)
for match in self._pattern.finditer(self._pending):
if match.start() < cut < match.end():
cut = match.end()
head, self._pending = self._pending[:cut], self._pending[cut:]
return self._mask(head)
def flush(self) -> bytes:
tail, self._pending = self._pending, b""
return self._mask(tail)
def _mask(self, data: bytes) -> bytes:
if self._pattern is None or not data:
return data
return self._pattern.sub(lambda m: self._needles[m[0]], data)
def needles_for(values: dict[str, bytes]) -> dict[bytes, bytes]:
out: dict[bytes, bytes] = {}
for name, data in values.items():
mask = f"<secret:{name}>".encode()
try:
text = data.decode()
except UnicodeDecodeError:
continue
for part in text.splitlines() or [text]:
for form in {part.strip(), quote(part.strip(), safe="")}:
if len(form) >= MIN_MASKED:
out[form.encode()] = mask
return out
def sweep(root: Path) -> None:
for stale in root.glob(f"{PREFIX}*"):
pid = stale.name.removeprefix(PREFIX).split("-", 1)[0]
if not pid.isdigit() or _alive(int(pid)):
continue
shutil.rmtree(stale, ignore_errors=True)
def _alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _pump(src: BufferedReader, dst: IO[bytes], masker: Masker) -> None:
while chunk := src.read1(CHUNK):
dst.write(masker.feed(chunk))
dst.flush()
dst.write(masker.flush())
dst.flush()
def run(names: list[str], command: list[str], *, root: Path | None = None) -> int:
secrets = ask(["get", *names])["secrets"]
base = root or (SHM if SHM.is_dir() else Path(tempfile.gettempdir()))
sweep(base)
workdir = Path(tempfile.mkdtemp(prefix=f"{PREFIX}{os.getpid()}-", dir=base))
env = dict(os.environ)
values: dict[str, bytes] = {}
try:
for name in names:
item = secrets[name]
data = base64.b64decode(item["data"])
if item.get("filename"):
path = workdir / name / item["filename"]
path.parent.mkdir(mode=0o700)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as fh:
fh.write(data)
env[name] = str(path)
else:
env[name] = data.decode(errors="surrogateescape")
values[name] = data
return _spawn(command, env, needles_for(values))
finally:
shutil.rmtree(workdir, ignore_errors=True)
def _spawn(command: list[str], env: dict[str, str], needles: dict[bytes, bytes]) -> int:
try:
child = subprocess.Popen( # noqa: S603
command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
except (OSError, ValueError) as exc:
reason = exc.strerror if isinstance(exc, OSError) else str(exc)
print(f"secret: cannot run {command[0]}: {reason}", file=sys.stderr)
return 127
forwarded = (
(signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
if threading.current_thread() is threading.main_thread()
else ()
)
previous = {
sig: signal.signal(sig, lambda s, _f: child.send_signal(s)) for sig in forwarded
}
pumps = [
threading.Thread(target=_pump, args=(src, dst, Masker(needles)), daemon=True)
for src, dst in (
(child.stdout, sys.stdout.buffer),
(child.stderr, sys.stderr.buffer),
)
]
for pump in pumps:
pump.start()
try:
code = child.wait()
for pump in pumps:
pump.join(DRAIN)
finally:
for sig, handler in previous.items():
signal.signal(sig, handler)
return 128 - code if code < 0 else code
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
if args[:1] == ["--root"]:
return root_half(args[1:])
try:
if args == ["ls"]:
return list_secrets()
if args[:1] == ["run"] and "--" in args:
split = args.index("--")
names, command = args[1:split], args[split + 1 :]
bad = [n for n in names if not NAME.fullmatch(n)]
if names and command and not bad:
return run(names, command)
if bad:
print(f"secret: invalid name {bad[0]!r}", file=sys.stderr)
return 2
except SecretError as exc:
print(f"secret: {exc}", file=sys.stderr)
return 1
print(USAGE, end="", file=sys.stderr)
return 2
if __name__ == "__main__":
with contextlib.suppress(KeyboardInterrupt):
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
"""Root-only unix socket that hands sealed secrets to the ``secret`` helper."""
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import logging
from typing import TYPE_CHECKING, Any
from beaver_gateway.keystore.store import (
SecretsDisabledError,
UnknownSecretError,
check_name,
)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
from beaver_gateway.keystore.store import SecretStore
_log = logging.getLogger("beaver_gateway.keystore")
REQUEST_LIMIT = 1 << 16
async def serve(
store: SecretStore,
path: Path,
*,
on_use: Callable[[Sequence[str]], Awaitable[None]] | None = None,
) -> None:
"""Listen on ``path`` (mode 0600 in a 0700 directory) until cancelled."""
_clear(path)
async def handle(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
line = await reader.readline()
reply = await answer(store, line, on_use=on_use)
writer.write(json.dumps(reply).encode() + b"\n")
await writer.drain()
except Exception: # noqa: BLE001
_log.exception("secret request failed")
finally:
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
server = await asyncio.start_unix_server(
handle, path=str(path), limit=REQUEST_LIMIT
)
_lock(path)
_log.info("secrets socket on %s", path)
async with server:
await server.serve_forever()
async def answer(
store: SecretStore,
line: bytes,
*,
on_use: Callable[[Sequence[str]], Awaitable[None]] | None = None,
) -> dict[str, Any]:
try:
request = json.loads(line)
op = request.get("op") if isinstance(request, dict) else None
if op == "ls":
return {"ok": True, "secrets": [_listed(r) for r in await store.rows()]}
if op == "get":
names = [check_name(str(n)) for n in request.get("names") or ()]
if not names:
return {"ok": False, "error": "no secret names given"}
revealed = await store.reveal(names)
if on_use is not None:
await on_use(names)
return {
"ok": True,
"secrets": {
r.name: {
"data": base64.b64encode(r.data).decode(),
"filename": r.filename,
}
for r in revealed
},
}
except UnknownSecretError as exc:
return {"ok": False, "error": f"unknown secret: {exc}"}
except (SecretsDisabledError, ValueError) as exc:
return {"ok": False, "error": str(exc)}
return {"ok": False, "error": "op must be `ls` or `get`"}
def _clear(path: Path) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
path.parent.chmod(0o700)
path.unlink(missing_ok=True)
def _lock(path: Path) -> None:
path.chmod(0o600)
def _listed(row: Any) -> dict[str, Any]:
return {"name": row.name, "description": row.description, "filename": row.filename}
+193
View File
@@ -0,0 +1,193 @@
"""Seal, list, replace and reveal secrets; the key never leaves this module."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from sqlmodel import col, select
from beaver_gateway.storage.models import Secret
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beaver_gateway.storage import Database
NAME = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
"""A secret's name is the environment variable a command receives it under."""
KEY_BYTES = 32
NONCE_BYTES = 12
MAX_SIZE = 1 << 20
"""Largest value or file the store accepts, in bytes."""
class SecretsDisabledError(RuntimeError):
"""``SECRETS_KEY`` is unset or unusable, so nothing can be sealed or opened."""
class UnknownSecretError(LookupError):
"""A name that is not in the store."""
class SecretBox:
"""AES-256-GCM with the secret's name as associated data."""
__slots__ = ("_aead",)
def __init__(self, key: bytes) -> None:
if len(key) != KEY_BYTES:
msg = f"SECRETS_KEY must be {KEY_BYTES} bytes, got {len(key)}"
raise ValueError(msg)
self._aead = AESGCM(key)
@classmethod
def from_hex(cls, text: str) -> SecretBox:
try:
key = bytes.fromhex(text.strip())
except ValueError as exc:
msg = "SECRETS_KEY must be hex, e.g. `openssl rand -hex 32`"
raise ValueError(msg) from exc
return cls(key)
def seal(self, name: str, data: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
return nonce + self._aead.encrypt(nonce, data, name.encode())
def open(self, name: str, sealed: bytes) -> bytes:
nonce, body = sealed[:NONCE_BYTES], sealed[NONCE_BYTES:]
return self._aead.decrypt(nonce, body, name.encode())
@dataclass(frozen=True, slots=True)
class Revealed:
name: str
data: bytes
filename: str | None
def _now() -> datetime:
return datetime.now(UTC)
def check_name(name: str) -> str:
if not NAME.fullmatch(name):
msg = (
f"invalid secret name {name!r}: use A-Z, 0-9 and _, starting with a letter"
)
raise ValueError(msg)
return name
def clean_filename(filename: str) -> str:
base = os.path.basename(filename.replace("\\", "/")).strip() # noqa: PTH119
if base in ("", ".", ".."):
msg = f"invalid file name {filename!r}"
raise ValueError(msg)
return base
class SecretStore:
"""Rows of :class:`Secret`; ``box`` is ``None`` when no key is configured."""
__slots__ = ("_box", "_db")
def __init__(self, db: Database, box: SecretBox | None) -> None:
self._db = db
self._box = box
@property
def enabled(self) -> bool:
return self._box is not None
def _require_box(self) -> SecretBox:
if self._box is None:
msg = "SECRETS_KEY is not set"
raise SecretsDisabledError(msg)
return self._box
async def rows(self) -> Sequence[Secret]:
async with self._db.session() as session:
result = await session.exec(select(Secret).order_by(col(Secret.name)))
return result.all()
async def put(
self,
name: str,
*,
description: str | None = None,
data: bytes | None = None,
filename: str | None = None,
) -> Secret:
"""Create or replace; ``data=None`` keeps the value, sets the description."""
check_name(name)
if data is not None:
if len(data) > MAX_SIZE:
msg = f"secret {name} is {len(data)} bytes, the limit is {MAX_SIZE}"
raise ValueError(msg)
sealed = self._require_box().seal(name, data)
async with self._db.session() as session:
row = await session.get(Secret, name)
if row is None:
if data is None:
msg = f"secret {name} needs a value or a file"
raise ValueError(msg)
row = Secret(name=name, sealed=sealed)
if data is not None:
row.sealed = sealed
row.size = len(data)
row.filename = clean_filename(filename) if filename else None
if description is not None:
row.description = description.strip()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def delete(self, name: str) -> bool:
async with self._db.session() as session:
row = await session.get(Secret, name)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def reveal(self, names: Iterable[str]) -> list[Revealed]:
box = self._require_box()
wanted = list(dict.fromkeys(names))
async with self._db.session() as session:
rows = {
r.name: r
for r in (
await session.exec(
select(Secret).where(col(Secret.name).in_(wanted))
)
).all()
}
missing = [n for n in wanted if n not in rows]
if missing:
raise UnknownSecretError(", ".join(missing))
out = [_open(box, rows[n]) for n in wanted]
now = _now()
for row in rows.values():
row.used_at = now
session.add(row)
await session.commit()
return out
def _open(box: SecretBox, row: Secret) -> Revealed:
try:
data = box.open(row.name, row.sealed)
except InvalidTag as exc:
msg = f"secret {row.name} does not open: SECRETS_KEY changed since it was set"
raise SecretsDisabledError(msg) from exc
return Revealed(name=row.name, data=data, filename=row.filename)
+3
View File
@@ -28,6 +28,9 @@ KNOWN_KINDS: frozenset[str] = frozenset(
"logout",
"token_create",
"token_revoke",
"secret_set",
"secret_delete",
"secret_use",
}
)
"""Kinds the gateway currently emits; ``AuditLog.kind`` stays free-form."""
+7
View File
@@ -61,6 +61,13 @@ class Settings(BaseSettings):
"""Directories the ``send_file`` tool may read, separated by ``:``. A
frontend always adds the inbox its own attachments land in."""
secrets_key: str | None = None
"""Hex AES-256 key (``openssl rand -hex 32``) sealing the secrets table;
unset leaves secrets off. Never reaches the model's environment."""
secrets_socket: Path = Path("/run/beaver-secrets/gateway.sock")
"""Root-only socket the ``secret`` helper reads values from."""
bootstrap_tokens: str = ""
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
alongside DB-issued tokens; used for first-run setup and recovery."""
+21 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import JSON, Column, Index, UniqueConstraint, text
from sqlalchemy import JSON, Column, Index, LargeBinary, UniqueConstraint, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, SQLModel
@@ -374,6 +374,25 @@ class JobRunRecord(SQLModel, table=True):
)
class Secret(SQLModel, table=True):
"""A value or file the model may run a command with, sealed by ``SECRETS_KEY``.
``sealed`` is nonce + AES-GCM ciphertext bound to ``name``; nothing
here decrypts without the key, so a dump of the table stays closed.
"""
__tablename__ = "secrets"
name: str = Field(primary_key=True)
description: str = Field(default="")
filename: str | None = Field(default=None)
size: int = Field(default=0)
sealed: bytes = Field(sa_column=Column(LargeBinary, nullable=False))
created_at: datetime = Field(default_factory=_utcnow)
updated_at: datetime = Field(default_factory=_utcnow)
used_at: datetime | None = Field(default=None)
__all__ = [
"AuditLog",
"Conversation",
@@ -383,6 +402,7 @@ __all__ = [
"InjectQueueItem",
"JobRunRecord",
"RateLimit",
"Secret",
"TelegramUpdate",
"Token",
"TranscriptEntry",
+47 -1
View File
@@ -16,7 +16,7 @@ from claude_agent_sdk import (
UserMessage,
)
from httpx import ASGITransport, AsyncClient
from test_conversations import ScriptedClient, World
from test_conversations import ScriptedClient, World, _three_turns
from beaver_gateway.frontends.markdown.history import rewrite_messages
from beaver_gateway.security.auth import TokenStore
@@ -444,6 +444,52 @@ async def test_sessions_show_their_processes_and_take_orders(world: World) -> No
assert kinds == ["session_interrupt", "session_close"]
async def test_rewind_lists_turns_and_needs_admin_scope(world: World) -> None:
api = Api(world)
api.store.grant("api-only", "weak", scope="api")
weak = {"Authorization": "Bearer weak"}
sid, entries = await _three_turns(world)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
path = f"/conversations/{conv.external_id}"
listed = (await api.http.get(f"{path}/turns", headers=weak)).json()
assert [t["text"] for t in listed["turns"]] == ["two", "three"]
assert (await api.get(f"{path}/turns", {"limit": 1}))["turns"] == [
listed["turns"][1]
]
body = {"turn": listed["turns"][0]["id"]}
denied = await api.http.post(f"{path}/rewind", json=body, headers=weak)
assert denied.status_code == 403
missing = await api.http.post(
f"{path}/rewind", json={"turn": entries[0]["uuid"]}, headers=HEADERS
)
assert missing.status_code == 404
done = await api.http.post(f"{path}/rewind", json=body, headers=HEADERS)
assert done.status_code == 200, done.text
out = done.json()
assert out["turns"] == 2 and out["previous_session"] == sid
assert (await api.get(path))["session_id"] == out["session_id"] != sid
assert (await api.get(f"{path}/turns"))["turns"] == []
messages = (await api.get(f"{path}/history"))["messages"]
assert [m["content"] for m in messages] == [
"one",
[{"type": "text", "text": "done one"}],
]
(record,) = (await api.get("/audit"))["records"]
assert record["kind"] == "api_rewind"
assert record["detail"]["previous_session"] == sid
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "busy")
await asyncio.sleep(0.2)
busy = await api.http.post(f"{path}/rewind", json=body, headers=HEADERS)
assert busy.status_code == 409
ScriptedClient.hold.set()
async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
api = Api(world)
api.store.grant("api-only", "weak", scope="api")
+5 -1
View File
@@ -250,7 +250,11 @@ async def test_stream_envelope_and_index_rebase(cwd: Path) -> None:
assert opts.strict_mcp_config is True
assert opts.permission_mode == "bypassPermissions"
assert opts.mcp_servers == {
"firefly": {"type": "http", "url": "http://127.0.0.1:1/mcp/firefly/"}
"firefly": {
"type": "http",
"url": "http://127.0.0.1:1/mcp/firefly/",
"headers": {"X-Conversation-Origin": "conv-1"},
}
}
assert opts.session_store is not None
assert opts.resume is None
+99
View File
@@ -504,6 +504,105 @@ async def test_copy_seed_forks_parent_with_window(world: World) -> None:
assert (await world.conversations.get(child.external_id)).flags["seed"] is None
async def _three_turns(world: World) -> tuple[str, list[dict[str, Any]]]:
sid = str(uuid.uuid4())
history = [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "done one"},
{"role": "user", "content": "two"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "t1", "name": "Read", "input": {}}],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}],
},
{"role": "assistant", "content": "done two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "refused"},
]
entries = build_entries(history, session_id=sid, cwd=str(world.root), model="m")
entries[-1]["isApiErrorMessage"] = True
note = {
**entries[2],
"uuid": str(uuid.uuid4()),
"message": {
"role": "user",
"content": [{"type": "text", "text": "[Request interrupted by user]"}],
},
}
del note["promptSource"]
entries.insert(6, note)
await world.store.append(world.key(sid), entries)
return sid, entries
async def test_rewind_moves_the_thread_to_a_fork_that_ends_before_the_turn(
world: World,
) -> None:
sid, entries = await _three_turns(world)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
await world.conversations.post(conv, "live")
await world.settle(conv, 1)
assert world.pool.get(conv.external_id) is not None
turns = await world.conversations.turns(conv)
assert [(t["text"], t["reply"], t["failed"]) for t in turns] == [
("two", "done two", False),
("three", "refused", True),
]
assert [t["id"] for t in turns] == [entries[2]["uuid"], entries[7]["uuid"]]
result = await world.conversations.rewind(conv, turns[1]["id"])
assert result.turns == 1 and result.previous_session == sid
moved = result.conversation.session_id
assert moved != sid
assert (await world.conversations.get(conv.external_id)).session_id == moved
assert await world.store.load(world.key(sid)) == entries
kept = [
e["message"]["content"]
for e in await world.store.load(world.key(moved))
if e["type"] in ("user", "assistant")
]
assert len(kept) == 7 and kept[0] == "one"
assert kept[-1] == [{"type": "text", "text": "[Request interrupted by user]"}]
assert world.pool.get(conv.external_id) is None
await world.conversations.run_text_turn(conv, "after", origin="user")
assert conv.session_id == sid
assert ScriptedClient.instances[-1].options.resume == moved
(two,) = await world.conversations.turns(result.conversation)
assert two["text"] == "two"
again = await world.conversations.rewind(conv, two["id"])
assert again.turns == 1 and again.previous_session == moved
assert await world.conversations.turns(again.conversation) == []
first = (await world.store.load(world.key(again.conversation.session_id)))[0]
with pytest.raises(LookupError):
await world.conversations.rewind(conv, first["uuid"])
with pytest.raises(LookupError):
await world.conversations.rewind(conv, str(uuid.uuid4()))
async def test_rewind_waits_for_no_turn(world: World) -> None:
sid, entries = await _three_turns(world)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "busy")
await asyncio.sleep(0.2)
with pytest.raises(RuntimeError, match="mid-turn"):
await world.conversations.rewind(conv, entries[7]["uuid"])
ScriptedClient.hold.set()
await world.settle(conv, 1)
assert (await world.conversations.get(conv.external_id)).session_id == sid
async def test_merge_injects_summary_into_parent(world: World) -> None:
sid = str(uuid.uuid4())
await world.store.append(
+85
View File
@@ -0,0 +1,85 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from test_conversations import StubFrontend, World, world
from beaver_gateway.jobs.job import Job, JobRun
from beaver_gateway.storage.models import Conversation
__all__ = ["world"]
async def _noop(_: JobRun) -> None:
return None
def _run(world: World) -> JobRun:
return JobRun(
job=Job("test", _noop),
trigger="test",
payload={},
scheduler=cast("Any", SimpleNamespace(conversations=world.conversations)),
)
async def _quiet_since(world: World, conv: Conversation, ago: timedelta) -> None:
async def apply(row: Conversation) -> None:
row.last_activity_at = datetime.now(UTC) - ago
await world.conversations._update(conv, apply) # noqa: SLF001
async def test_close_quiet_closes_and_marks_only_idle_branches(world: World) -> None:
marked: list[tuple[str, str]] = []
class MarkingFrontend(StubFrontend):
async def mark_closed(self, conv: Conversation) -> bool:
marked.append((conv.external_id, conv.status))
return True
tg = MarkingFrontend("tg", ("master", "branch"))
tg.conversations = world.conversations
world.conversations._frontends = [tg] # noqa: SLF001
convs = world.conversations
master = await convs.create(kind="master", agent="a", session_id="m")
idle = await convs.create(kind="branch", agent="a", parent=master, session_id="1")
fresh = await convs.create(kind="branch", agent="a", parent=master, session_id="2")
waiting = await convs.create(
kind="branch", agent="a", parent=master, session_id="3"
)
await _quiet_since(world, idle, timedelta(days=4))
await _quiet_since(world, fresh, timedelta(days=1))
await _quiet_since(world, waiting, timedelta(days=4))
await convs.queue.push(
conversation_id=cast("int", waiting.id),
priority="normal",
origin="cron",
text="later",
)
closed = await _run(world).close_quiet(kind="branch", days=3)
assert [c.external_id for c in closed] == [idle.external_id]
assert marked == [(idle.external_id, "closed")]
assert (await convs.get(fresh.external_id)).status == "open"
assert (await convs.get(waiting.external_id)).status == "open"
async def test_inject_into_falls_back_to_the_master(world: World) -> None:
convs = world.conversations
master = await convs.create(kind="master", agent="a", session_id="m")
branch = await convs.create(kind="branch", agent="a", parent=master)
gone = await convs.create(kind="branch", agent="a", parent=master)
await convs.set_status(gone, "closed")
run = _run(world)
assert await run.inject_into(branch.external_id, "to branch", urgency="normal")
assert await run.inject_into(gone.external_id, "to closed", urgency="normal")
assert await run.inject_into(None, "to nobody", urgency="normal")
assert [i.text for i in await convs.queue.pending(branch.id)] == ["to branch"]
assert [i.text for i in await convs.queue.pending(master.id)] == [
"to closed",
"to nobody",
]
+299
View File
@@ -0,0 +1,299 @@
import asyncio
import base64
import os
import sys
import tempfile
from pathlib import Path
import pytest
from cryptography.exceptions import InvalidTag
from httpx import ASGITransport, AsyncClient
from sqlmodel import select
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 GatewayRuntime
from beaver_gateway.keystore import (
SecretBox,
SecretsDisabledError,
SecretStore,
UnknownSecretError,
cli,
serve,
)
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.storage import Database
from beaver_gateway.storage.models import AuditLog, Secret
KEY = "11" * 32
HEADERS = {"Authorization": "Bearer tok"}
@pytest.fixture
async def db():
root = Path(tempfile.mkdtemp(prefix="beaver-keys-"))
database = Database(f"sqlite:///{root / 'k.db'}")
await database.create_all()
yield database
await database.dispose()
@pytest.fixture
def store(db: Database) -> SecretStore:
return SecretStore(db, SecretBox.from_hex(KEY))
def test_box_binds_the_value_to_its_name() -> None:
box = SecretBox.from_hex(KEY)
sealed = box.seal("A_KEY", b"hunter22")
assert b"hunter22" not in sealed
assert box.open("A_KEY", sealed) == b"hunter22"
with pytest.raises(InvalidTag):
box.open("OTHER", sealed)
with pytest.raises(ValueError, match="32 bytes"):
SecretBox.from_hex("abcd")
async def test_store_keeps_only_ciphertext_and_reveals_by_name(
store: SecretStore, db: Database
) -> None:
await store.put("NODEREAL_BSC", description="bsc rpc", data=b"nr-abcdef123")
await store.put("GCP_SA", data=b'{"k": 1}', filename="../sa.json")
async with db.session() as session:
rows = (await session.exec(select(Secret))).all()
assert all(b"nr-abcdef123" not in r.sealed for r in rows)
assert {r.name: r.filename for r in rows} == {
"GCP_SA": "sa.json",
"NODEREAL_BSC": None,
}
await store.put("NODEREAL_BSC", description=" rpc key ")
listed = {r.name: r for r in await store.rows()}
assert listed["NODEREAL_BSC"].description == "rpc key"
assert listed["NODEREAL_BSC"].used_at is None
revealed = await store.reveal(["NODEREAL_BSC", "GCP_SA"])
assert [(r.name, r.data) for r in revealed] == [
("NODEREAL_BSC", b"nr-abcdef123"),
("GCP_SA", b'{"k": 1}'),
]
assert all(r.used_at is not None for r in await store.rows())
with pytest.raises(UnknownSecretError):
await store.reveal(["NODEREAL_BSC", "MISSING"])
with pytest.raises(ValueError, match="invalid secret name"):
await store.put("lower", data=b"x")
with pytest.raises(ValueError, match="needs a value"):
await store.put("EMPTY", description="no value")
assert await store.delete("GCP_SA")
assert not await store.delete("GCP_SA")
async def test_another_key_or_no_key_opens_nothing(
store: SecretStore, db: Database
) -> None:
await store.put("A_KEY", data=b"value-123")
with pytest.raises(SecretsDisabledError, match="SECRETS_KEY changed"):
await SecretStore(db, SecretBox.from_hex("22" * 32)).reveal(["A_KEY"])
off = SecretStore(db, None)
assert not off.enabled
assert [r.name for r in await off.rows()] == ["A_KEY"]
with pytest.raises(SecretsDisabledError):
await off.reveal(["A_KEY"])
with pytest.raises(SecretsDisabledError):
await off.put("B_KEY", data=b"x")
@pytest.fixture
async def socket_path(store: SecretStore):
path = Path(tempfile.mkdtemp(prefix="bk-", dir="/tmp")) / "run" / "gw.sock" # noqa: S108
used: list[list[str]] = []
async def on_use(names):
used.append(list(names))
task = asyncio.create_task(serve(store, path, on_use=on_use))
for _ in range(100):
if path.exists():
break
await asyncio.sleep(0.01)
yield path, used
task.cancel()
async def test_socket_is_root_only_and_answers_ls_and_get(
store: SecretStore, socket_path
) -> None:
path, used = socket_path
assert path.stat().st_mode & 0o777 == 0o600
assert path.parent.stat().st_mode & 0o777 == 0o700
await store.put("API_TOKEN", description="the api", data=b"tok-123456")
ls = await asyncio.to_thread(cli.request, {"op": "ls"}, path)
assert ls["secrets"] == [
{"name": "API_TOKEN", "description": "the api", "filename": None}
]
got = await asyncio.to_thread(
cli.request, {"op": "get", "names": ["API_TOKEN"]}, path
)
assert base64.b64decode(got["secrets"]["API_TOKEN"]["data"]) == b"tok-123456"
assert used == [["API_TOKEN"]]
with pytest.raises(cli.SecretError, match="unknown secret: NOPE"):
await asyncio.to_thread(cli.request, {"op": "get", "names": ["NOPE"]}, path)
with pytest.raises(cli.SecretError, match="invalid secret name"):
await asyncio.to_thread(cli.request, {"op": "get", "names": ["../x"]}, path)
async def test_run_hands_values_and_files_and_masks_the_output(
store: SecretStore, socket_path, monkeypatch, capfd
) -> None:
path, _ = socket_path
await store.put("API_TOKEN", data=b"tok/123456")
await store.put("SA_JSON", data=b'{"private": "pem"}', filename="sa.json")
monkeypatch.setattr(
cli, "ask", lambda argv: cli.request({"op": "get", "names": argv[1:]}, path)
)
shm = Path(tempfile.mkdtemp(prefix="bk-shm-"))
stale = shm / f"{cli.PREFIX}999999-old"
stale.mkdir()
script = (
"import os, sys, pathlib;"
"p = pathlib.Path(os.environ['SA_JSON']);"
"print(p.read_text(), oct(p.stat().st_mode & 0o777), p.parent.parent.name.startswith('beaver-secret-'));"
"print('url', 'https://x/?key=' + os.environ['API_TOKEN'].replace('/', '%2F'));"
"sys.stderr.write('boom ' + os.environ['API_TOKEN'] + '\\n');"
"print(p, flush=True); sys.exit(3)"
)
code = await asyncio.to_thread(
cli.run, ["API_TOKEN", "SA_JSON"], [sys.executable, "-c", script], root=shm
)
out, err = capfd.readouterr()
assert code == 3
lines = out.splitlines()
assert lines[0] == '{"private": "pem"} 0o600 True'
assert lines[1] == "url https://x/?key=<secret:API_TOKEN>"
assert "tok/123456" not in out + err
assert err == "boom <secret:API_TOKEN>\n"
assert not Path(lines[2]).exists()
assert list(shm.iterdir()) == []
def test_masker_never_splits_a_value_across_chunks() -> None:
masker = cli.Masker(cli.needles_for({"K": b"abcdef123"}))
out = b"".join(
[
masker.feed(b"one abc"),
masker.feed(b"def1"),
masker.feed(b"23 two\nthr"),
masker.flush(),
]
)
assert out == b"one <secret:K> two\nthr"
assert cli.Masker({}).feed(b"as is") == b"as is"
blob = b"x" * (cli.HOLD + 10) + b"abcdef"
masker = cli.Masker(cli.needles_for({"K": b"abcdef123"}))
head = masker.feed(blob)
assert (
head + masker.feed(b"123") + masker.flush()
== b"x" * (cli.HOLD + 10) + b"<secret:K>"
)
assert cli.needles_for({"S": b"abc"}) == {}
def test_cli_rejects_bad_shapes(capsys) -> None:
assert cli.main(["run", "A_KEY"]) == 2
assert cli.main(["run", "bad-name", "--", "true"]) == 2
assert cli.main(["--root", "get", "../etc"]) == 2
assert cli.main(["--root", "rm"]) == 2
assert "usage" in capsys.readouterr().err
class _Runtime:
def __init__(self, db: Database, store: SecretStore | None) -> None:
self.tokens = TokenStore(db, bootstrap={"t": "tok"})
self.tokens.grant("weak", "weak", scope="api")
self.runtime = GatewayRuntime(
agents=AgentRegistry([]),
mcps=McpRegistry([]),
backends={},
token_store=self.tokens,
db=db,
conversations=object(),
bus=object(),
secrets=store,
)
self.http = AsyncClient(
transport=ASGITransport(app=build_api(self.runtime)), base_url="http://api"
)
async def test_api_writes_secrets_but_never_shows_a_value(
store: SecretStore, db: Database
) -> None:
api = _Runtime(db, store).http
assert (
await api.get("/secrets", headers={"Authorization": "Bearer weak"})
).status_code == 403
put = await api.put(
"/secrets/NODEREAL_BSC",
json={"description": "bsc", "value": "nr-secret-1"},
headers=HEADERS,
)
assert put.status_code == 200, put.text
upload = await api.put(
"/secrets/SA_JSON",
json={"file": {"name": "sa.json", "content": base64.b64encode(b"{}").decode()}},
headers=HEADERS,
)
assert upload.json()["secret"]["kind"] == "file"
rename = await api.put(
"/secrets/NODEREAL_BSC", json={"description": "rpc"}, headers=HEADERS
)
assert rename.json()["secret"]["size"] == len("nr-secret-1")
listed = await api.get("/secrets", headers=HEADERS)
assert listed.json()["enabled"] is True
assert "nr-secret-1" not in listed.text
assert [(s["name"], s["description"]) for s in listed.json()["secrets"]] == [
("NODEREAL_BSC", "rpc"),
("SA_JSON", ""),
]
assert (await store.reveal(["NODEREAL_BSC"]))[0].data == b"nr-secret-1"
bad = await api.put("/secrets/bad name", json={"value": "x"}, headers=HEADERS)
assert bad.status_code == 400
both = await api.put(
"/secrets/X_KEY",
json={"value": "x", "file": {"name": "a", "content": ""}},
headers=HEADERS,
)
assert both.status_code == 400
assert (await api.delete("/secrets/SA_JSON", headers=HEADERS)).status_code == 200
assert (await api.delete("/secrets/SA_JSON", headers=HEADERS)).status_code == 404
async with db.session() as session:
audit = (await session.exec(select(AuditLog))).all()
assert [a.kind for a in audit] == [
"secret_set",
"secret_set",
"secret_set",
"secret_delete",
]
assert all("nr-secret-1" not in a.detail_json for a in audit)
async def test_api_without_a_key_lists_but_refuses_writes(db: Database) -> None:
api = _Runtime(db, SecretStore(db, None)).http
assert (await api.get("/secrets", headers=HEADERS)).json() == {
"enabled": False,
"secrets": [],
}
put = await api.put("/secrets/A_KEY", json={"value": "x"}, headers=HEADERS)
assert put.status_code == 503
def test_cli_runs_with_isolated_stdlib_python() -> None:
source = Path(cli.__file__).read_text(encoding="utf-8")
imports = {
line.split()[1].split(".")[0]
for line in source.splitlines()
if line.startswith(("import ", "from "))
}
assert imports <= set(sys.stdlib_module_names) | {"__future__"}
assert os.access(cli.__file__, os.R_OK)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import asyncio
import contextlib
import socket
from typing import TYPE_CHECKING, Any
import pytest
import uvicorn
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.dependencies import get_http_headers
from beaver_gateway.backends.claude_sdk import ORIGIN_HEADER
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.mcp.types import McpServer
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@contextlib.asynccontextmanager
async def _serving(app: Any, port: int) -> AsyncIterator[None]:
server = uvicorn.Server(
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
)
task = asyncio.create_task(server.serve())
while not server.started:
await asyncio.sleep(0.02)
try:
yield
finally:
server.should_exit = True
await task
@pytest.mark.anyio
async def test_the_origin_header_reaches_the_upstream_mcp() -> None:
upstream = FastMCP("upstream")
@upstream.tool
def caller() -> str:
return get_http_headers().get(ORIGIN_HEADER.lower(), "")
up, gateway = _free_port(), _free_port()
app, urls, _ = build_internal_app(
[McpServer.http(name="t3", url=f"http://127.0.0.1:{up}/mcp")],
host="127.0.0.1",
port=gateway,
)
seen = []
async with (
_serving(upstream.http_app(transport="http", path="/mcp"), up),
_serving(app, gateway),
):
for conversation in ("conv-a", "conv-b"):
transport = StreamableHttpTransport(
urls["t3"], headers={ORIGIN_HEADER: conversation}
)
async with Client(transport) as client:
result = await client.call_tool("caller", {})
seen.append(result.data)
assert seen == ["conv-a", "conv-b"]
+11
View File
@@ -84,6 +84,17 @@ async def test_rotation_does_not_touch_a_master_mid_turn(world: World) -> None:
await world.settle(conv, 1)
async def test_force_rotates_a_master_that_is_not_due(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
rotation = Rotation(world.conversations, POLICY)
assert await rotation.tick() == []
rotated = await rotation.tick(force="manual")
assert len(rotated) == 1
assert rotated[0].external_id != conv.external_id
assert (await world.conversations.get(conv.external_id)).status == "closed"
assert len(await world.conversations.find(kind="master", status="open")) == 1
async def test_rotation_order_handout_close_marks_moves_and_new_day(
world: World,
) -> None:
+33 -2
View File
@@ -519,10 +519,10 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None:
frontend="telegram", external_id=f"{USER}/11"
)
assert branch.status == "merged"
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"]
assert stack.bot.topics == ["🦫 General", "edit:11:work", "edit:11:✅ work"]
stack.bot.message("/new отчёт")
await stack.until(lambda: stack.sent_with("in a new topic"), what="new topic")
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"]
assert stack.bot.topics[-1] == "отчёт"
child = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/902"
)
@@ -1298,3 +1298,34 @@ def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
)
feed(_thinking_delta("now writing it back"))
assert draft.text == "reading the note\n\nnow writing it back"
async def test_a_closed_topic_is_marked_asleep_and_wakes_with_a_new_branch(
stack: Stack,
) -> None:
stack.bot.message("кухня на неделю\nи ещё строка", thread=12)
await stack.until(lambda: stack.sent_with("кухня на неделю"), what="reply")
assert stack.bot.topics[-1] == "edit:12:кухня на неделю"
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/12"
)
closed = await stack.world.conversations.close(branch)
assert await stack.world.conversations.mark_closed(closed)
assert stack.bot.topics[-1] == "edit:12:💤 кухня на неделю"
stack.tg._topic_names.clear() # noqa: SLF001
stack.bot.message("а в четверг?", thread=12)
await stack.until(lambda: stack.sent_with("а в четверг?"), what="new branch")
assert stack.bot.topics[-1] == "edit:12:кухня на неделю"
fresh = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/12"
)
assert fresh.id != branch.id
assert fresh.status == "open"
assert fresh.title == "кухня на неделю"
async def test_a_named_topic_keeps_its_name(stack: Stack) -> None:
stack.bot.topic_created("план", 13)
stack.bot.message("hello", thread=13)
await stack.until(lambda: stack.sent_with("hello"), what="reply")
assert not any(t.startswith("edit:13:") for t in stack.bot.topics)
+32
View File
@@ -14,8 +14,12 @@ import type {
MemoryTree,
ScopeRow,
SearchResponse,
SecretRow,
SecretsResponse,
SecretWrite,
SessionsResponse,
TokenRow,
TurnSummary,
UsageGroup,
UsageResponse,
VaultGraph,
@@ -146,6 +150,14 @@ export class ApiClient {
return this.request<T>("PATCH", path, body ?? {});
}
put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PUT", path, body ?? {});
}
delete<T>(path: string): Promise<T> {
return this.request<T>("DELETE", path);
}
async stream(
path: string,
onEvent: (event: BusEvent) => void,
@@ -187,6 +199,14 @@ export class ApiClient {
return this.get(`/api/conversations/${id}/history`);
}
turns(id: string, limit: number): Promise<{ turns: TurnSummary[] }> {
return this.get(`/api/conversations/${id}/turns`, { limit });
}
rewind(id: string, turn: string): Promise<{ turns: number }> {
return this.post(`/api/conversations/${id}/rewind`, { turn });
}
context(id: string, prompt = false): Promise<ContextResponse> {
return this.get(`/api/conversations/${id}/context`, {
prompt: prompt ? 1 : undefined,
@@ -383,6 +403,18 @@ export class ApiClient {
return this.post(`/api/tokens/${id}/revoke`);
}
secrets(): Promise<SecretsResponse> {
return this.get("/api/secrets");
}
putSecret(name: string, body: SecretWrite): Promise<{ secret: SecretRow }> {
return this.put(`/api/secrets/${encodeURIComponent(name)}`, body);
}
deleteSecret(name: string): Promise<{ name: string; deleted: boolean }> {
return this.delete(`/api/secrets/${encodeURIComponent(name)}`);
}
audit(params?: { before?: number; limit?: number }): Promise<AuditPage> {
return this.get("/api/audit", params);
}
+30
View File
@@ -113,6 +113,14 @@ export interface HistoryMessage {
ts?: string | null;
}
export interface TurnSummary {
failed: boolean;
id: string;
reply: string;
text: string;
ts: string | null;
}
export interface EntriesPage {
entries: Record<string, unknown>[];
id: string;
@@ -279,6 +287,28 @@ export interface TokenRow {
scope: string;
}
export interface SecretRow {
created_at: string;
description: string;
filename: string | null;
kind: "file" | "value";
name: string;
size: number;
updated_at: string;
used_at: string | null;
}
export interface SecretsResponse {
enabled: boolean;
secrets: SecretRow[];
}
export interface SecretWrite {
description?: string;
file?: { content: string; name: string };
value?: string;
}
export interface AuditRecord {
actor: string;
agent: string | null;
+7
View File
@@ -109,6 +109,13 @@ export function fmtTime(iso: string | null | undefined): string {
: "–";
}
export function fmtMinute(iso: string | null | undefined): string {
const date = parseDate(iso);
return date
? date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
: "–";
}
export function fmtDateTime(iso: string | null | undefined): string {
const date = parseDate(iso);
return date
+1
View File
@@ -20,6 +20,7 @@ export const SYSTEM_PAGES: NavItem[] = [
{ href: "/system/agents", key: "", label: "Agents & endpoints" },
{ href: "/system/jobs", key: "", label: "Jobs" },
{ href: "/system/tokens", key: "", label: "Tokens" },
{ href: "/system/secrets", key: "", label: "Secrets" },
{ href: "/system/audit", key: "", label: "Audit" },
];
+8
View File
@@ -9,6 +9,7 @@ import FileTextIcon from "@lucide/svelte/icons/file-text";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
import PencilIcon from "@lucide/svelte/icons/pencil";
import RewindIcon from "@lucide/svelte/icons/rewind";
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
import SendIcon from "@lucide/svelte/icons/send";
import type { Component } from "svelte";
@@ -38,6 +39,7 @@ export interface ActionScope {
onChanged: () => void;
onOpen: (id: string) => void;
onRename: () => void;
onRewind: () => void;
}
const TELEGRAM = "telegram";
@@ -103,6 +105,12 @@ export function conversationActions(
label: "Branch…",
run: scope.onBranch,
});
actions.push({
disabled: !info.session_id,
icon: RewindIcon,
label: "Rewind…",
run: scope.onRewind,
});
actions.push({
checked: remembers,
gap: true,
@@ -13,6 +13,7 @@
import { usePanelHost } from "./host";
import MenuItems, { type MenuKit } from "./menu-items.svelte";
import RenameDialog from "./rename-dialog.svelte";
import RewindDialog from "./rewind-dialog.svelte";
let {
client,
@@ -47,6 +48,7 @@
let failure = $state<string | null>(null);
let branchOpen = $state(false);
let renameOpen = $state(false);
let rewindOpen = $state(false);
const loaded = $derived(info ?? fetched);
@@ -77,6 +79,9 @@
onRename: () => {
renameOpen = true;
},
onRewind: () => {
rewindOpen = true;
},
});
const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
</script>
@@ -141,4 +146,5 @@
bind:open={branchOpen}
/>
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
<RewindDialog {client} info={loaded} {onChanged} bind:open={rewindOpen} />
{/if}
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary, TurnSummary } 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";
import * as Dialog from "$lib/components/ui/dialog";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtDateTime, fmtMinute, shortId } from "$lib/format";
import { cn } from "$lib/utils";
let {
client,
info,
open = $bindable(false),
onChanged,
}: {
client: ApiClient;
info: Pick<ConversationSummary, "id" | "kind" | "running_turn" | "title">;
open?: boolean;
onChanged: () => void;
} = $props();
const LIMIT = 30;
const LINE = 160;
let turns = $state<TurnSummary[] | null>(null);
let failure = $state<string | null>(null);
let picked = $state<number | null>(null);
let busy = $state(false);
let list = $state<HTMLDivElement | null>(null);
const name = $derived(info.title || `${info.kind} ${shortId(info.id)}`);
const count = $derived(
turns === null || picked === null ? 0 : turns.length - picked
);
function plural(n: number): string {
return n === 1 ? "1 turn" : `${n} turns`;
}
function brokenTail(rows: TurnSummary[]): number | null {
let at = rows.length;
while (at > 0 && rows[at - 1].failed) {
at -= 1;
}
return at < rows.length ? at : null;
}
async function load() {
turns = null;
failure = null;
picked = null;
try {
({ turns } = await client.turns(info.id, LIMIT));
picked = brokenTail(turns);
await tick();
if (list) {
list.scrollTop = list.scrollHeight;
list
.querySelector(":checked")
?.closest("label")
?.scrollIntoView({ block: "nearest" });
}
} catch (error) {
failure = error instanceof Error ? error.message : String(error);
}
}
async function rewind() {
const turn = turns?.[picked ?? -1];
if (!turn) {
return;
}
busy = true;
try {
const result = await client.rewind(info.id, turn.id);
open = false;
toast.success(`Cut ${plural(result.turns)}`);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
$effect(() => {
if (open) {
untrack(() => load());
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rewind {name}</Dialog.Title>
<Dialog.Description>
Pick the turn to cut from. The model forgets it and every turn after it.
The old session stays in the database.
</Dialog.Description>
</Dialog.Header>
<div class="flex min-w-0 flex-col gap-3">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if turns === null}
<div class="flex flex-col gap-3 py-2">
{#each [0, 1, 2] as row (row)}
<div class="flex flex-col gap-1.5">
<Skeleton class="h-3.5 w-3/5" />
<Skeleton class="h-3 w-4/5" />
</div>
{/each}
</div>
{:else if turns.length === 0}
<EmptyState
hint="The first turn of a thread stays, and this thread has no other."
title="Nothing to cut"
/>
{:else}
<div
aria-label="Turn to cut from"
class="-mx-2 grid max-h-[min(22rem,50dvh)] grid-cols-[auto_minmax(0,1fr)] gap-x-3 overflow-y-auto rounded-md pt-2"
role="radiogroup"
bind:this={list}
>
{#each turns as turn, index (turn.id)}
{@const gone = picked !== null && index >= picked}
<label
class={cn(
"relative col-span-2 grid cursor-pointer grid-cols-subgrid items-baseline border-t px-2 py-2 first:border-t-transparent has-focus-visible:ring-1 has-focus-visible:ring-ring has-focus-visible:ring-inset",
gone ? "bg-destructive/5" : "row-hover",
index === picked &&
"border-t-destructive/40 first:border-t-destructive/40"
)}
>
<input
checked={index === picked}
class="sr-only"
name="rewind-turn"
onchange={() => {
picked = index;
}}
type="radio"
>
<span
class="tabular text-muted-foreground text-xs"
title={fmtDateTime(turn.ts)}
>
{fmtMinute(turn.ts)}
</span>
<span class="flex min-w-0 flex-col">
<span
class={cn(
"truncate font-medium",
gone && "text-muted-foreground"
)}
>
{clip(turn.text, LINE) || "empty prompt"}
</span>
<span
class={cn(
"truncate text-xs",
turn.failed ? "text-destructive" : "text-muted-foreground"
)}
>
{clip(turn.reply, LINE) ||
(turn.failed ? "failed" : "no reply")}
</span>
</span>
{#if index === picked}
<span
class="-top-2 absolute right-2 rounded-md bg-[color-mix(in_oklab,var(--destructive)_12%,var(--popover))] px-1.5 font-medium text-destructive text-xs leading-4"
>
cut from here
</span>
{/if}
</label>
{/each}
</div>
{/if}
{#if info.running_turn}
<p class="text-warn text-xs">A turn is running. Interrupt it first.</p>
{/if}
</div>
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
{#if turns?.length}
<Button
disabled={busy || count === 0 || Boolean(info.running_turn)}
onclick={rewind}
variant="destructive"
>
{count === 0 ? "Cut" : `Cut ${plural(count)}`}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+426
View File
@@ -0,0 +1,426 @@
<script lang="ts">
import FileIcon from "@lucide/svelte/icons/file";
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import type { SecretRow, SecretWrite } 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";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Tabs from "$lib/components/ui/tabs";
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte";
const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_SIZE = 1024 * 1024;
let secrets = $state<SecretRow[] | null>(null);
let enabled = $state(true);
let failure = $state<string | null>(null);
let editOpen = $state(false);
let editing = $state<SecretRow | null>(null);
let secretName = $state("");
let description = $state("");
let mode = $state<"value" | "file">("value");
let value = $state("");
let files = $state<FileList | undefined>();
let busy = $state(false);
let doomed = $state<SecretRow | null>(null);
const file = $derived(files?.[0]);
const nameError = $derived(
!editing && secretName && !NAME.test(secretName)
? "Capital letters, digits and _, starting with a letter."
: null
);
const fileError = $derived(
file && file.size > MAX_SIZE ? "Files are capped at 1 MB." : null
);
const hasPayload = $derived(mode === "value" ? value !== "" : !!file);
const canSave = $derived(
!(busy || nameError || fileError) &&
(editing ? true : NAME.test(secretName) && hasPayload)
);
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
({ secrets, enabled } = await client.secrets());
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
$effect(() => {
load();
});
function openEditor(row: SecretRow | null) {
editing = row;
secretName = row?.name ?? "";
description = row?.description ?? "";
mode = row?.kind ?? "value";
value = "";
files = undefined;
editOpen = true;
}
function base64Of(blob: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const url = String(reader.result);
resolve(url.slice(url.indexOf(",") + 1));
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
async function save() {
const { client } = session;
if (!client) {
return;
}
busy = true;
try {
const body: SecretWrite = { description };
if (mode === "value" && value) {
body.value = value;
}
if (mode === "file" && file) {
body.file = { content: await base64Of(file), name: file.name };
}
const target = editing?.name ?? secretName;
await client.putSecret(target, body);
toast.success(editing ? `Updated ${target}` : `Saved ${target}`);
editOpen = false;
value = "";
files = undefined;
await load();
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause));
} finally {
busy = false;
}
}
async function remove(row: SecretRow) {
const { client } = session;
if (!client) {
return;
}
busy = true;
try {
await client.deleteSecret(row.name);
toast.success(`Deleted ${row.name}`);
doomed = null;
await load();
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause));
} finally {
busy = false;
}
}
</script>
<div class="flex flex-wrap items-center gap-4">
<h2 class="font-medium text-sm">Secrets</h2>
<span class="text-muted-foreground text-xs">
write-only · the agent runs
<code class="rounded bg-muted px-1 py-0.5">secret run NAME -- cmd</code>
</span>
<Button
class="ml-auto"
disabled={!enabled}
onclick={() => openEditor(null)}
size="sm"
variant="outline"
>
<PlusIcon class="size-4" />
New secret
</Button>
</div>
<div class="flex flex-col gap-6">
{#if !enabled}
<div
class="flex flex-col gap-1 rounded-lg border border-warn/40 bg-warn/5 p-3 text-sm"
role="status"
>
<p class="font-medium">Secrets are off: SECRETS_KEY is not set.</p>
<p class="text-muted-foreground">
Put a key in the gateway's environment (<code
class="rounded bg-muted px-1 py-0.5"
>openssl rand -hex 32</code
>) and recreate the container. Existing names stay listed; values cannot
be set or read until then.
</p>
</div>
{/if}
{#if failure}
<ErrorNote message={failure} retry={() => load()} />
{:else if secrets === null}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else if secrets.length === 0}
<EmptyState
hint="Keys and files the agent runs commands with. They are sealed with SECRETS_KEY in the gateway's database, reach a command as environment variables, and are never shown again after saving."
title="No secrets"
>
{#if enabled}
<Button onclick={() => openEditor(null)} size="sm" variant="outline">
Add the first secret
</Button>
{/if}
</EmptyState>
{:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="px-3 py-2 font-medium">name</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">
description
</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">kind</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">changed</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">
last used
</th>
<th class="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{#each secrets as row (row.name)}
<tr class="row-hover border-b last:border-b-0">
<td class="px-3 py-1.5">
<code class="break-all font-medium">{row.name}</code>
<div
class="flex flex-col gap-0.5 pt-0.5 text-muted-foreground text-xs sm:hidden"
>
{#if row.description}
<span class="text-foreground/80">{row.description}</span>
{/if}
<span class="tabular">
{row.filename
? `${row.filename} · ${fmtBytes(row.size)}`
: "value"}
· changed {fmtRelative(row.updated_at)} ·
{row.used_at
? `used ${fmtRelative(row.used_at)}`
: "never used"}
</span>
</div>
</td>
<td class="hidden min-w-48 px-3 py-1.5 sm:table-cell">
{#if row.description}
{row.description}
{:else}
<span class="text-muted-foreground">–</span>
{/if}
</td>
<td
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
>
{#if row.filename}
<span class="inline-flex items-center gap-1">
<FileIcon class="size-3.5 text-icon" />
{row.filename}
<span class="tabular text-muted-foreground"
>{fmtBytes(row.size)}</span
>
</span>
{:else}
<span class="text-muted-foreground">value</span>
{/if}
</td>
<td
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
title={fmtDateTime(row.updated_at)}
>
{fmtRelative(row.updated_at)}
</td>
<td
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
title={row.used_at ? fmtDateTime(row.used_at) : undefined}
>
{#if row.used_at}
{fmtRelative(row.used_at)}
{:else}
<span class="text-muted-foreground">never</span>
{/if}
</td>
<td
class="flex flex-col items-end px-1 py-1.5 sm:table-cell sm:whitespace-nowrap sm:px-3 sm:text-right"
>
<Button
disabled={!enabled}
onclick={() => openEditor(row)}
size="sm"
variant="ghost"
>
Replace
</Button>
<Button
class="text-destructive hover:text-destructive"
onclick={() => {
doomed = row;
}}
size="sm"
variant="ghost"
>
Delete
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{editing ? `Replace ${editing.name}` : "New secret"}
</Dialog.Title>
<Dialog.Description>
{editing
? "Leave the value empty to change only the description."
: "Stored encrypted. After saving, the value is not shown anywhere."}
</Dialog.Description>
</Dialog.Header>
<form
autocomplete="off"
class="flex flex-col gap-3"
onsubmit={(event) => {
event.preventDefault();
save();
}}
>
{#if !editing}
<div class="flex flex-col gap-1.5">
<Label for="secret-name">Name</Label>
<Input
aria-invalid={nameError ? true : undefined}
autocapitalize="characters"
class="font-mono"
id="secret-name"
oninput={() => {
secretName = secretName.toUpperCase().replaceAll(/[\s-]/g, "_");
}}
placeholder="NODEREAL_BSC"
required
spellcheck={false}
bind:value={secretName}
/>
<p class="text-muted-foreground text-xs">
{nameError ?? `The command reads it as $${secretName || "NAME"}.`}
</p>
</div>
{/if}
<div class="flex flex-col gap-1.5">
<Label for="secret-description">Description</Label>
<Input
id="secret-description"
placeholder="What it opens and who issued it"
bind:value={description}
/>
</div>
<Tabs.Root bind:value={mode}>
<Tabs.List class="w-full">
<Tabs.Trigger value="value">Value</Tabs.Trigger>
<Tabs.Trigger value="file">File</Tabs.Trigger>
</Tabs.List>
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="value">
<Label class="sr-only" for="secret-value">Value</Label>
<Input
autocomplete="new-password"
class="font-mono"
id="secret-value"
placeholder={editing ? "New value" : "Paste the value"}
spellcheck={false}
type="password"
bind:value
/>
<p class="text-muted-foreground text-xs">
One line. Multi-line keys and JSON go in as a file.
</p>
</Tabs.Content>
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="file">
<Label class="sr-only" for="secret-file">File</Label>
<Input
aria-invalid={fileError ? true : undefined}
id="secret-file"
type="file"
bind:files
/>
<p class="text-muted-foreground text-xs">
{fileError ??
"Up to 1 MB. The command gets $NAME as the path to a private copy, removed when it exits."}
</p>
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer>
<Button
onclick={() => {
editOpen = false;
}}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button disabled={!canSave} type="submit">
{editing && !hasPayload ? "Save description" : "Save"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
onOpenChange={(open) => {
if (!open) {
doomed = null;
}
}}
open={doomed !== null}
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete {doomed?.name}?</Dialog.Title>
<Dialog.Description>
Commands that ask for it will fail with “unknown secret”. The value
cannot be recovered.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
onclick={() => {
doomed = null;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() => doomed && remove(doomed)}
variant="destructive"
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
Generated
+2
View File
@@ -296,6 +296,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "claude-agent-sdk" },
{ name = "croniter" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "greenlet" },
@@ -340,6 +341,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
{ name = "croniter", specifier = ">=6.2.4" },
{ name = "cryptography", specifier = ">=48.0.0" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "fastmcp", specifier = ">=3.3.1" },
{ name = "greenlet", specifier = ">=3.5.0" },