Compare commits
5
Commits
b0e5aaf599
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944b68e275 | ||
|
|
7d812c7962 | ||
|
|
8f26263d4a | ||
|
|
7b36cb2156 | ||
|
|
2a47c6771d |
No files matched your search
+5
-1
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/local/bin/python3 -I /app/src/beaver_gateway/keystore/cli.py --root "$@"
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/local/bin/python3 -I /app/src/beaver_gateway/keystore/cli.py "$@"
|
||||
@@ -0,0 +1,2 @@
|
||||
Defaults:beaver-runner !syslog
|
||||
beaver-runner ALL=(root) NOPASSWD: /usr/local/libexec/beaver-secret
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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``.
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user