diff --git a/.gitignore b/.gitignore index 634f6fc8..b6f534b4 100644 --- a/.gitignore +++ b/.gitignore @@ -281,3 +281,6 @@ desktop/EvoFlux.key.pub MiMo-Code/ # Local dev state. /.local/ + +.superpowers/ +.worktrees/ diff --git a/app/agent/outbound_redaction.py b/app/agent/outbound_redaction.py index 6f1e783f..c424ba23 100644 --- a/app/agent/outbound_redaction.py +++ b/app/agent/outbound_redaction.py @@ -28,7 +28,11 @@ OutboundDataPolicy = Literal["block", "redact", "off"] OutboundPiiPolicy = Literal["off", "standard", "strict"] -OutboundChannel = Literal["model", "web", "mcp", "other"] +#: ``remote`` is the phone-facing Telegram channel (a later task). Its +#: policy is selected explicitly by the caller rather than inherited from +#: the ambient sandbox state the other channels default to, because a +#: remote message is addressed to a device outside the sandboxed run. +OutboundChannel = Literal["model", "web", "mcp", "other", "remote"] @dataclass(frozen=True) diff --git a/app/api/app.py b/app/api/app.py index 2a91dd40..34e989b5 100644 --- a/app/api/app.py +++ b/app/api/app.py @@ -26,6 +26,7 @@ from app.api.routes.observability import router as observability_router from app.api.routes.plugins import router as plugins_router from app.api.routes.quote import router as quote_router +from app.api.routes.remote import router as remote_router from app.api.routes.scheduler import router as scheduler_router from app.api.routes.settings import router as settings_router from app.api.routes.skills import router as skills_router @@ -147,6 +148,18 @@ async def _start_optional_services(app: FastAPI, process_started: float) -> None logger.error("optional_service_start_failed service=scheduler error={}", exc) _log_startup_timing("scheduler", phase_started, process_started) + phase_started = perf_counter() + try: + # Lazy by design (AC-1): with no enabled, credentialed remote + # connection this imports nothing under app.remote.telegram and + # starts no task — see app/remote/runtime.py. + from app.remote.runtime import remote_runtime + + await remote_runtime.start() + except Exception as exc: # noqa: BLE001 + logger.error("optional_service_start_failed service=remote error={}", exc) + _log_startup_timing("remote", phase_started, process_started) + phase_started = perf_counter() try: from app.core.db import async_session_factory @@ -297,6 +310,9 @@ async def lifespan(app: FastAPI): await dream_scheduler.stop() await task_scheduler.stop() await team_manager.stop() + from app.remote.runtime import remote_runtime + + await remote_runtime.stop() from app.conductor import conductor_service await conductor_service.stop() @@ -391,6 +407,7 @@ def create_app() -> FastAPI: app.include_router(mcp_router, prefix="/api/mcp", tags=["mcp"]) app.include_router(plugins_router, prefix="/api/plugins", tags=["plugins"]) app.include_router(settings_router, prefix="/api/settings", tags=["settings"]) + app.include_router(remote_router, prefix="/api/remote", tags=["remote"]) app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(dream_router, prefix="/api", tags=["dream"]) app.include_router( diff --git a/app/api/routes/remote.py b/app/api/routes/remote.py new file mode 100644 index 00000000..8f60c5a8 --- /dev/null +++ b/app/api/routes/remote.py @@ -0,0 +1,362 @@ +"""``/api/remote/*`` — desktop HTTP API for the (v1 single) remote connection. + +Every route here uses the existing desktop authentication +(``DesktopTokenMiddleware`` — wired in ``app/api/app.py`` like every other +route) and no custom credential type (AC-2, AC-33). Handlers only parse/ +validate HTTP shape, call :class:`~app.remote.connection_service.RemoteConnectionService`, +:class:`~app.remote.pairing.PairingService`, and +:data:`~app.remote.runtime.remote_runtime`, and translate the domain errors +those already raise into HTTP responses. No business logic is duplicated +here — connection limits, credential custody, and pairing rules all stay in +their owning service. +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.schemas.remote import ( + PairingCodeBody, + RemoteConnectionCreateRequest, + RemoteConnectionPatchRequest, + RemoteConnectionResponse, + RemoteConnectionStatusBody, + RemoteConnectionTokenReplaceRequest, + RemotePairingLinkResponse, + RemotePairingResponse, +) +from app.core.credential_store import CredentialStoreError +from app.core.db import get_session +from app.models.remote import RemoteConnection, RemotePairing +from app.remote.connection_service import ( + CredentialStoreFactory, + RemoteConnectionConflictError, + RemoteConnectionNotFoundError, + RemoteConnectionService, + RemoteCredentialError, + default_credential_store_factory, +) +from loguru import logger + +from app.remote.contracts import RemoteAdapterValidationError +from app.remote.pairing import PairingService, pairing_service as _pairing_service +from app.remote.runtime import TelegramAdapterFactory, remote_runtime + +router = APIRouter() + +# ── Dependencies ────────────────────────────────────────────────────────── +# +# Plain functions (not classes) so tests can override them via FastAPI's +# ``app.dependency_overrides`` — the same pattern ``app/api/routes/scheduler.py`` +# uses for its singleton. ``_credential_store_factory`` is a module-level +# variable rather than a dependency because it is also used outside any +# request (``_token_configured``) — tests monkeypatch it directly. +# +# ``_pairing_service`` is re-exported from ``app.remote.pairing`` rather than +# constructed here: it must be the exact same process-wide instance the +# Telegram adapter's inbound dispatch consumes tokens against +# (``app/remote/runtime.py``), since pairing tokens live only in that +# instance's memory. A second, locally-constructed ``PairingService()`` +# would silently never see a token this route mints. + +_credential_store_factory: CredentialStoreFactory = default_credential_store_factory + + +def get_connection_service() -> RemoteConnectionService: + return RemoteConnectionService(adapter_factory=TelegramAdapterFactory()) + + +def get_pairing_service() -> PairingService: + return _pairing_service + + +# ── Helpers ─────────────────────────────────────────────────────────────── + + +def _token_configured(connection_id: uuid.UUID) -> bool: + store = _credential_store_factory(connection_id) + try: + return store.load() is not None + except CredentialStoreError: + return False + + +async def _get_pairing( + session: AsyncSession, connection_id: uuid.UUID +) -> RemotePairing | None: + result = await session.exec( + select(RemotePairing).where(RemotePairing.connection_id == connection_id) + ) + return result.first() + + +async def _status_body( + session: AsyncSession, connection_id: uuid.UUID +) -> RemoteConnectionStatusBody: + status = remote_runtime.status(connection_id) + pairing = await _get_pairing(session, connection_id) + return RemoteConnectionStatusBody( + connection_id=status.connection_id, + state=status.state, + last_error_class=status.last_error_class, + last_successful_poll_at=status.last_successful_poll_at, + paired=pairing is not None, + phone_reachable=status.phone_reachable, + informational_drop_count=status.informational_drop_count, + high_priority_drop_count=status.high_priority_drop_count, + ) + + +async def _connection_response( + session: AsyncSession, connection: RemoteConnection +) -> RemoteConnectionResponse: + return RemoteConnectionResponse( + id=connection.id, + adapter=connection.adapter, + label=connection.label, + enabled=connection.enabled, + adapter_principal_id=connection.adapter_principal_id, + adapter_username=connection.adapter_username, + token_configured=_token_configured(connection.id), + created_at=connection.created_at, + updated_at=connection.updated_at, + status=await _status_body(session, connection.id), + ) + + +async def _connection_or_404( + session: AsyncSession, service: RemoteConnectionService, connection_id: uuid.UUID +) -> RemoteConnection: + connection = await service.get(session, connection_id) + if connection is None: + raise HTTPException(status_code=404, detail="Remote connection not found.") + return connection + + +# ── Connections ─────────────────────────────────────────────────────────── + + +@router.get("/connections") +async def list_connections( + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> list[RemoteConnectionResponse]: + """Zero-or-one v1 connection summary and safe runtime state.""" + connections = await service.list(session) + return [await _connection_response(session, c) for c in connections] + + +@router.post("/connections", status_code=201) +async def create_connection( + body: RemoteConnectionCreateRequest, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> RemoteConnectionResponse: + """Validate a write-only token, vault it, and create the connection. + + ``409`` when one already exists (AC-3, v1's one-connection limit). + """ + try: + connection = await service.create_connection( + session, token=body.token, label=body.label + ) + except RemoteConnectionConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except RemoteAdapterValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except RemoteCredentialError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + await remote_runtime.reconcile_connection(connection.id) + # ``reconcile_connection`` uses the read pool internally, so it never + # contends with the request's write session under SQLite's pool-size-1. + return await _connection_response(session, connection) + + +@router.patch("/connections/{connection_id}") +async def patch_connection( + connection_id: uuid.UUID, + body: RemoteConnectionPatchRequest, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> RemoteConnectionResponse: + """Change label and/or enabled state. Adapter kind and bot identity are + immutable here — the request schema simply has no field for them.""" + connection = await _connection_or_404(session, service, connection_id) + + if body.label is not None: + connection = await service.set_label(session, connection_id, label=body.label) + if body.enabled is not None: + connection = await service.set_enabled( + session, connection_id, enabled=body.enabled + ) + + await remote_runtime.reconcile_connection(connection_id) + return await _connection_response(session, connection) + + +@router.put("/connections/{connection_id}/token") +async def replace_token( + connection_id: uuid.UUID, + body: RemoteConnectionTokenReplaceRequest, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> RemoteConnectionResponse: + """Validate and atomically replace the write-only bot token. + + Re-pairs (invalidates the existing pairing) when the new token resolves + to a different bot — handled entirely inside + ``RemoteConnectionService.update_token`` (AC-6, AC-10). + """ + try: + connection = await service.update_token( + session, connection_id, token=body.token + ) + except RemoteConnectionNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except RemoteAdapterValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except RemoteCredentialError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + await remote_runtime.reconcile_connection(connection_id) + return await _connection_response(session, connection) + + +@router.delete("/connections/{connection_id}", status_code=204) +async def remove_connection( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> None: + """Stop and remove the connection, pairing, tokens, and vault credential.""" + try: + await service.remove(session, connection_id) + except RemoteConnectionNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except RemoteCredentialError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + await remote_runtime.reconcile_connection(connection_id) + + +# ── Pairing ─────────────────────────────────────────────────────────────── + + +@router.post("/connections/{connection_id}/pairing-links") +async def issue_pairing_link( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), + pairing_service: PairingService = Depends(get_pairing_service), +) -> RemotePairingLinkResponse: + """Mint a single-use deep link and return link, QR payload, and expiry.""" + connection = await _connection_or_404(session, service, connection_id) + link = pairing_service.issue_link(connection) + return RemotePairingLinkResponse( + url=link.url, qr_payload=link.qr_payload, expires_at=link.expires_at + ) + + +@router.get("/connections/{connection_id}/pairing") +async def get_pairing( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> RemotePairingResponse | None: + """Safe paired-account decoration, or ``null`` when unpaired.""" + await _connection_or_404(session, service, connection_id) + pairing = await _get_pairing(session, connection_id) + if pairing is None: + return None + return RemotePairingResponse( + id=pairing.id, + label=pairing.label, + display=pairing.display, + created_at=pairing.created_at, + last_seen_at=pairing.last_seen_at, + ) + + +@router.delete("/connections/{connection_id}/pairing", status_code=204) +async def revoke_pairing( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), + pairing_service: PairingService = Depends(get_pairing_service), +) -> None: + """Revoke the paired account (AC-10) — a later ``authorize`` call for + the former principal fails immediately, with no cache to invalidate. + + Sends a notification message to the phone *before* removing the pairing + so the user sees an immediate explanation instead of a silent disconnect. + """ + await _connection_or_404(session, service, connection_id) + pairing = await _get_pairing(session, connection_id) + # Best-effort notification — if the adapter is stopped or send fails we + # still proceed with the unpair so the user isn't stuck. + if pairing is not None: + try: + adapter = remote_runtime.adapter + if adapter is not None: + from app.remote.contracts import ( + RemoteOutboundMessage, + RemoteOutboundPriority, + ) + + await adapter.send( + RemoteOutboundMessage( + connection_id=connection_id, + destination_id=pairing.destination_id, + text="\u26a0\ufe0f This device has been unpaired from EvoFlux. Send /start to pair again.", + buttons=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + except Exception: + logger.debug("remote_unpair_notify_failed connection_id={}", connection_id) + await pairing_service.unpair(session, connection_id) + + +@router.post( + "/connections/{connection_id}/pairing-codes", + response_model=PairingCodeBody, + status_code=201, +) +async def issue_pairing_code( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), + pairing_service: PairingService = Depends(get_pairing_service), +) -> PairingCodeBody: + """Generate a one-time8-digit pairing code for a connection. + + The phone user then types ``/pair `` in the Telegram bot to + complete pairing. Any previously-pending code for this connection is + replaced. + """ + await _connection_or_404(session, service, connection_id) + result = await pairing_service.issue_pair_code(session, connection_id) + return PairingCodeBody( + code_display=result.display_code, + expires_at=result.expires_at, + ) + + +# ── Status ──────────────────────────────────────────────────────────────── + + +@router.get("/connections/{connection_id}/status") +async def get_connection_status( + connection_id: uuid.UUID, + session: AsyncSession = Depends(get_session), + service: RemoteConnectionService = Depends(get_connection_service), +) -> RemoteConnectionStatusBody: + """Adapter lifecycle, last safe error, poll time, pairing state, + reachability, and drop counts (AC-34).""" + await _connection_or_404(session, service, connection_id) + return await _status_body(session, connection_id) diff --git a/app/api/routes/settings.py b/app/api/routes/settings.py index 59b7f1d3..878920a6 100644 --- a/app/api/routes/settings.py +++ b/app/api/routes/settings.py @@ -25,6 +25,7 @@ ConductorSettings, ContextSettings, GitSettings, + RemoteSettings, WebBridgeSettings, load_runtime_settings, load_runtime_settings_report, @@ -54,6 +55,7 @@ TeamSpawnSettingsBody, ProviderVisibleModelsResponse, ProvidersListBody, + RemoteSettingsBody, SandboxSettingsBody, SeedInstallRequest, SeedInstallResponse, @@ -631,6 +633,39 @@ async def save_follow_up_settings(body: FollowUpSettingsBody) -> FollowUpSetting return FollowUpSettingsBody(delivery=cfg.follow_up.delivery) +# Remote access (Settings -> Remote access tab) + + +def _remote_settings_body() -> RemoteSettingsBody: + cfg = load_runtime_settings() + return RemoteSettingsBody( + outbound_data_policy=cfg.remote.outbound_data_policy, + outbound_pii_policy=cfg.remote.outbound_pii_policy, + ) + + +@router.get("/remote") +async def get_remote_settings() -> RemoteSettingsBody: + try: + return _remote_settings_body() + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.put("/remote") +async def update_remote_settings(body: RemoteSettingsBody) -> RemoteSettingsBody: + try: + cfg = load_runtime_settings() + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + cfg.remote = RemoteSettings( + outbound_data_policy=body.outbound_data_policy, + outbound_pii_policy=body.outbound_pii_policy, + ) + save_runtime_settings(cfg) + return _remote_settings_body() + + # Providers (Settings -> Providers tab) diff --git a/app/api/schemas/remote.py b/app/api/schemas/remote.py new file mode 100644 index 00000000..db46e89c --- /dev/null +++ b/app/api/schemas/remote.py @@ -0,0 +1,160 @@ +"""Pydantic request/response models for ``/api/remote/*``. + +Secret-bearing request models use write-only fields: the ``token`` on +:class:`RemoteConnectionCreateRequest` and +:class:`RemoteConnectionTokenReplaceRequest` is accepted but never echoed +back. Every response model instead reports ``token_configured: bool`` — +never the credential or a fingerprint of it (spec: "Desktop HTTP API"). +None of these models carry an example that looks like a real bot token. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.remote.contracts import RemoteConnectionState, RemoteErrorClass + +__all__ = [ + "PairingCodeBody", + "RemoteConnectionCreateRequest", + "RemoteConnectionPatchRequest", + "RemoteConnectionResponse", + "RemoteConnectionStatusBody", + "RemoteConnectionTokenReplaceRequest", + "RemotePairingLinkResponse", + "RemotePairingResponse", +] + +#: A clearly-fake placeholder — never a shape resembling a real Telegram +#: bot token — so OpenAPI examples cannot be mistaken for a live credential. +_TOKEN_EXAMPLE = "" + + +class RemoteConnectionCreateRequest(BaseModel): + """``POST /api/remote/connections`` body.""" + + model_config = ConfigDict(extra="forbid") + + label: str = Field(min_length=1, max_length=120) + token: str = Field( + min_length=1, + repr=False, + description="Bot token from @BotFather. Write-only — never returned by the API.", + json_schema_extra={"example": _TOKEN_EXAMPLE}, + ) + + +class RemoteConnectionPatchRequest(BaseModel): + """``PATCH /api/remote/connections/{id}`` body. + + Only ``label`` and ``enabled`` are accepted — adapter kind and bot + identity are immutable outside of :class:`RemoteConnectionTokenReplaceRequest` + (``extra="forbid"`` rejects any other field with a clean 422 rather than + silently ignoring it). + """ + + model_config = ConfigDict(extra="forbid") + + label: str | None = Field(default=None, min_length=1, max_length=120) + enabled: bool | None = None + + +class RemoteConnectionTokenReplaceRequest(BaseModel): + """``PUT /api/remote/connections/{id}/token`` body.""" + + model_config = ConfigDict(extra="forbid") + + token: str = Field( + min_length=1, + repr=False, + description="Replacement bot token. Write-only — never returned by the API.", + json_schema_extra={"example": _TOKEN_EXAMPLE}, + ) + + +class RemoteConnectionStatusBody(BaseModel): + """Safe, diagnosable connection status (AC-34). + + Mirrors :class:`app.remote.contracts.RemoteAdapterStatus` field-for-field + but is this module's own response shape — never the dataclass directly — + so a later change to the internal contract cannot silently change the + wire shape. + """ + + model_config = ConfigDict(extra="forbid") + + connection_id: UUID + state: RemoteConnectionState + last_error_class: RemoteErrorClass + last_successful_poll_at: datetime | None + paired: bool + phone_reachable: bool | None + informational_drop_count: int + high_priority_drop_count: int + + +class RemoteConnectionResponse(BaseModel): + """``GET/POST/PATCH/PUT .../connections[...]`` response shape. + + Deliberately has no ``token`` field at all — only ``token_configured``. + """ + + model_config = ConfigDict(extra="forbid") + + id: UUID + adapter: str + label: str + enabled: bool + adapter_principal_id: str + adapter_username: str + token_configured: bool + created_at: datetime + updated_at: datetime + status: RemoteConnectionStatusBody + + +class PairingCodeBody(BaseModel): + """``POST /api/remote/connections/{id}/pairing-codes`` response. + + ``code_display`` is the user-facing8-digit code with a space separator + (e.g. ``"1234 5678"``). ``expires_at`` is a wall-clock UTC timestamp + so the UI can render a countdown timer. + """ + + model_config = ConfigDict(extra="forbid") + + code_display: str = Field( + description="User-facing pairing code with digit grouping, e.g. '1234 5678'.", + examples=["1234 5678"], + ) + expires_at: datetime + + +class RemotePairingLinkResponse(BaseModel): + """``POST /api/remote/connections/{id}/pairing-links`` response.""" + + model_config = ConfigDict(extra="forbid") + + url: str + qr_payload: str + expires_at: datetime + + +class RemotePairingResponse(BaseModel): + """``GET /api/remote/connections/{id}/pairing`` response when paired. + + Only safe, untrusted decoration — never the raw provider chat/user ID + beyond what is already the ``principal``/``destination`` the rest of the + system treats as opaque identifiers. + """ + + model_config = ConfigDict(extra="forbid") + + id: UUID + label: str + display: str + created_at: datetime + last_seen_at: datetime diff --git a/app/api/schemas/settings.py b/app/api/schemas/settings.py index 01866980..f013b0fb 100644 --- a/app/api/schemas/settings.py +++ b/app/api/schemas/settings.py @@ -37,6 +37,18 @@ class SandboxSettingsBody(BaseModel): max_output_bytes: int = Field(default=131072, ge=4096, le=1048576) +class RemoteSettingsBody(BaseModel): + """``GET/PUT /api/settings/remote`` — the ``remote`` outbound channel's + redaction policy. Connection enablement, adapter, and pairing state are + not part of this section; they live on the connection record exposed by + ``/api/remote/connections`` (a later task).""" + + model_config = ConfigDict(extra="forbid") + + outbound_data_policy: Literal["block", "redact", "off"] = "redact" + outbound_pii_policy: Literal["off", "standard", "strict"] = "standard" + + class IgnoredSettingBody(BaseModel): """A hand-edited ``settings.yaml`` value that failed validation.""" diff --git a/app/core/credential_store.py b/app/core/credential_store.py new file mode 100644 index 00000000..5468bd93 --- /dev/null +++ b/app/core/credential_store.py @@ -0,0 +1,68 @@ +"""Keyed storage for integration credentials in the operating-system vault.""" + +from __future__ import annotations + +from typing import Protocol + + +class CredentialStoreProtocol(Protocol): + def load(self) -> str | None: ... + + def save(self, credential: str) -> None: ... + + def delete(self) -> None: ... + + +class CredentialStoreError(RuntimeError): + """The operating-system credential vault could not complete an operation.""" + + +class CredentialStore: + """Store one credential under an explicit service and account key.""" + + def __init__(self, *, service: str, account: str) -> None: + if not service.strip() or not account.strip(): + raise ValueError("Credential service and account must not be empty.") + self._service = service + self._account = account + + def load(self) -> str | None: + try: + import keyring + + return keyring.get_password(self._service, self._account) + except Exception as exc: + raise CredentialStoreError( + "The operating system credential vault is unavailable." + ) from exc + + def save(self, credential: str) -> None: + try: + import keyring + + keyring.set_password(self._service, self._account, credential) + except Exception as exc: + raise CredentialStoreError( + "The credential could not be saved to the operating system credential vault." + ) from exc + + def delete(self) -> None: + try: + import keyring + from keyring.errors import PasswordDeleteError + except Exception as exc: + raise CredentialStoreError( + "The operating system credential vault is unavailable." + ) from exc + + try: + keyring.delete_password(self._service, self._account) + except PasswordDeleteError: + return + except Exception as exc: + raise CredentialStoreError( + "The credential could not be deleted from the operating system credential vault." + ) from exc + + +__all__ = ["CredentialStore", "CredentialStoreError", "CredentialStoreProtocol"] diff --git a/app/core/runtime_settings.py b/app/core/runtime_settings.py index 642376c9..baee476a 100644 --- a/app/core/runtime_settings.py +++ b/app/core/runtime_settings.py @@ -206,6 +206,23 @@ class BuiltInBrowserSettings(BaseModel): allow_agent_permission_accept: bool = False +class RemoteSettings(BaseModel): + """Explicit outbound-data policy for the ``remote`` channel. + + Connection enablement, adapter kind, and bot identity live on the + ``RemoteConnection`` database record (``app/models/remote.py``), not + here — this section only carries the redaction policy applied to every + user- and agent-derived field before it reaches a paired phone. The + default is the safe one: redaction and standard PII masking are on, + unlike the sandbox's own outbound policy, which defaults off. + """ + + model_config = ConfigDict(extra="ignore") + + outbound_data_policy: Literal["block", "redact", "off"] = "redact" + outbound_pii_policy: Literal["off", "standard", "strict"] = "standard" + + class ConductorSettings(BaseModel): """Connection and enforcement policy for the organization control plane.""" @@ -294,6 +311,7 @@ class RuntimeSettings(BaseModel): conductor: ConductorSettings = Field(default_factory=ConductorSettings) team_spawn: TeamSpawnModeSettings = Field(default_factory=TeamSpawnModeSettings) follow_up: FollowUpSettings = Field(default_factory=FollowUpSettings) + remote: RemoteSettings = Field(default_factory=RemoteSettings) def follow_up_delivery_default() -> str: diff --git a/app/core/schema_version.py b/app/core/schema_version.py index aa70c301..da6509e2 100644 --- a/app/core/schema_version.py +++ b/app/core/schema_version.py @@ -13,7 +13,7 @@ # Keep this in sync with the single Alembic head. The migration tests and the # sidecar build validate the value, so a release cannot silently ship a stale # marker. -SCHEMA_HEAD = "00000066" +SCHEMA_HEAD = "00000070" #: Revisions this build no longer ships, mapped to the newest ancestor it does. #: diff --git a/app/migrations/env.py b/app/migrations/env.py index 2d5e17cc..59a85af1 100644 --- a/app/migrations/env.py +++ b/app/migrations/env.py @@ -14,6 +14,7 @@ from app.models import SessionPrefixSnapshot # noqa: F401 from app.models import DelegationTask, GitServerConnection # noqa: F401 from app.models import MemoryExtractionState, MemoryFact, MemoryFactEvidence # noqa: F401 +from app.models import RemoteConnection, RemotePairing # noqa: F401 from app.models.chat import TZDateTime # noqa: F401 — used by render_item from app.scheduler.models import ScheduledTask # noqa: F401 diff --git a/app/migrations/versions/00000067_create_remote_pairings.py b/app/migrations/versions/00000067_create_remote_pairings.py new file mode 100644 index 00000000..8aa5cd92 --- /dev/null +++ b/app/migrations/versions/00000067_create_remote_pairings.py @@ -0,0 +1,80 @@ +"""create remote connection and pairing tables + +Revision ID: 00000067 +Revises: 00000066 +Create Date: 2026-09-14 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from app.models.chat import TZDateTime + +revision: str = "00000067" +down_revision: Union[str, Sequence[str], None] = "00000066" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "remote_connections", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("adapter", sa.String(length=32), nullable=False), + sa.Column("label", sa.String(length=120), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("adapter_principal_id", sa.String(length=128), nullable=False), + sa.Column( + "adapter_username", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column("created_at", TZDateTime(), nullable=False), + sa.Column("updated_at", TZDateTime(), nullable=False), + ) + op.create_index( + "ix_remote_connections_enabled", "remote_connections", ["enabled"] + ) + + op.create_table( + "remote_pairings", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column( + "connection_id", + sa.Uuid(), + sa.ForeignKey("remote_connections.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("principal_id", sa.String(length=128), nullable=False), + sa.Column("destination_id", sa.String(length=128), nullable=False), + sa.Column("label", sa.String(length=120), nullable=False), + sa.Column("display", sa.String(length=120), nullable=False, server_default=""), + sa.Column( + "active_session_id", + sa.Uuid(), + sa.ForeignKey("chat_sessions.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", TZDateTime(), nullable=False), + sa.Column("last_seen_at", TZDateTime(), nullable=False), + sa.UniqueConstraint( + "connection_id", + "principal_id", + name="uq_remote_pairings_connection_principal", + ), + sa.UniqueConstraint( + "connection_id", + "destination_id", + name="uq_remote_pairings_connection_destination", + ), + ) + op.create_index( + "ix_remote_pairings_connection", "remote_pairings", ["connection_id"] + ) + + +def downgrade() -> None: + op.drop_index("ix_remote_pairings_connection", table_name="remote_pairings") + op.drop_table("remote_pairings") + op.drop_index("ix_remote_connections_enabled", table_name="remote_connections") + op.drop_table("remote_connections") diff --git a/app/migrations/versions/00000068_add_remote_pairing_notify_scope.py b/app/migrations/versions/00000068_add_remote_pairing_notify_scope.py new file mode 100644 index 00000000..26827d26 --- /dev/null +++ b/app/migrations/versions/00000068_add_remote_pairing_notify_scope.py @@ -0,0 +1,33 @@ +"""Add remote_pairings.notify_scope + +Revision ID: 00000068 +Revises: 00000067 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000068" +down_revision: str | Sequence[str] | None = "00000067" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "remote_pairings", + sa.Column( + "notify_scope", + sa.String(20), + nullable=False, + server_default="all", + ), + ) + + +def downgrade() -> None: + op.drop_column("remote_pairings", "notify_scope") diff --git a/app/migrations/versions/00000069_add_remote_pairing_response_mode.py b/app/migrations/versions/00000069_add_remote_pairing_response_mode.py new file mode 100644 index 00000000..85e1220c --- /dev/null +++ b/app/migrations/versions/00000069_add_remote_pairing_response_mode.py @@ -0,0 +1,33 @@ +"""Add remote_pairings.response_mode + +Revision ID: 00000069 +Revises: 00000068 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000069" +down_revision: str | Sequence[str] | None = "00000068" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "remote_pairings", + sa.Column( + "response_mode", + sa.String(20), + nullable=False, + server_default="live", + ), + ) + + +def downgrade() -> None: + op.drop_column("remote_pairings", "response_mode") diff --git a/app/migrations/versions/00000070_add_remote_pairing_code_fields.py b/app/migrations/versions/00000070_add_remote_pairing_code_fields.py new file mode 100644 index 00000000..22290ed1 --- /dev/null +++ b/app/migrations/versions/00000070_add_remote_pairing_code_fields.py @@ -0,0 +1,33 @@ +"""Add remote_pairings pair_code_hash and pair_code_expires_at + +Revision ID: 00000070 +Revises: 00000069 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000070" +down_revision: str | Sequence[str] | None = "00000069" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "remote_pairings", + sa.Column("pair_code_hash", sa.String(128), nullable=True), + ) + op.add_column( + "remote_pairings", + sa.Column("pair_code_expires_at", sa.DateTime(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("remote_pairings", "pair_code_expires_at") + op.drop_column("remote_pairings", "pair_code_hash") diff --git a/app/models/__init__.py b/app/models/__init__.py index 4287cec4..e303f497 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -8,6 +8,7 @@ from .goal import SessionGoal from .memory import MemoryExtractionState, MemoryFact, MemoryFactEvidence from .prompt_cache import SessionPrefixSnapshot +from .remote import RemoteConnection, RemotePairing from .suggested_task import SessionSuggestedTask from .team import DelegationTask from .workflow import ( @@ -34,6 +35,8 @@ "MemoryExtractionState", "MemoryFact", "MemoryFactEvidence", + "RemoteConnection", + "RemotePairing", "SessionMessage", "SessionPrefixSnapshot", "ScheduledTask", diff --git a/app/models/remote.py b/app/models/remote.py new file mode 100644 index 00000000..ce6bb65a --- /dev/null +++ b/app/models/remote.py @@ -0,0 +1,105 @@ +"""Durable, non-secret metadata for remote connections and pairings.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +import sqlalchemy as sa +from sqlalchemy import Column, ForeignKey +from sqlmodel import Field, SQLModel + +from app.models.chat import TZDateTime, _utcnow + + +class RemoteConnection(SQLModel, table=True): + __tablename__: str = "remote_connections" # type: ignore[reportIncompatibleVariableOverride] + __table_args__ = (sa.Index("ix_remote_connections_enabled", "enabled"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + adapter: str = Field(sa_column=Column(sa.String(32), nullable=False)) + label: str = Field(sa_column=Column(sa.String(120), nullable=False)) + enabled: bool = Field( + default=False, + sa_column=Column(sa.Boolean(), nullable=False, server_default=sa.false()), + ) + adapter_principal_id: str = Field(sa_column=Column(sa.String(128), nullable=False)) + adapter_username: str = Field( + default="", + sa_column=Column(sa.String(128), nullable=False, server_default=""), + ) + created_at: datetime = Field( + default_factory=_utcnow, sa_column=Column(TZDateTime(), nullable=False) + ) + updated_at: datetime = Field( + default_factory=_utcnow, + sa_column=Column(TZDateTime(), nullable=False, onupdate=_utcnow), + ) + + +class RemotePairing(SQLModel, table=True): + __tablename__: str = "remote_pairings" # type: ignore[reportIncompatibleVariableOverride] + __table_args__ = ( + sa.UniqueConstraint( + "connection_id", + "principal_id", + name="uq_remote_pairings_connection_principal", + ), + sa.UniqueConstraint( + "connection_id", + "destination_id", + name="uq_remote_pairings_connection_destination", + ), + sa.Index("ix_remote_pairings_connection", "connection_id"), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + connection_id: UUID = Field( + sa_column=Column( + sa.Uuid(), + ForeignKey("remote_connections.id", ondelete="CASCADE"), + nullable=False, + ) + ) + principal_id: str = Field(sa_column=Column(sa.String(128), nullable=False)) + destination_id: str = Field(sa_column=Column(sa.String(128), nullable=False)) + label: str = Field(sa_column=Column(sa.String(120), nullable=False)) + display: str = Field( + default="", + sa_column=Column(sa.String(120), nullable=False, server_default=""), + ) + notify_scope: str = Field( + default="all", + sa_column=Column(sa.String(20), nullable=False, server_default="all"), + ) + response_mode: str = Field( + default="live", + sa_column=Column(sa.String(20), nullable=False, server_default="live"), + ) + active_session_id: UUID | None = Field( + default=None, + sa_column=Column( + sa.Uuid(), + ForeignKey("chat_sessions.id", ondelete="SET NULL"), + nullable=True, + ), + ) + created_at: datetime = Field( + default_factory=_utcnow, sa_column=Column(TZDateTime(), nullable=False) + ) + #: Optional phone-first pairing code hash (bcrypt). Nullable so existing + #: deep-link pairings and rows created before the phone-first flow are + #: unaffected. + pair_code_hash: str | None = Field( + default=None, + sa_column=Column(sa.String(128), nullable=True), + ) + #: Wall-clock UTC expiry for the pairing code. Nullable for the same + #: reason as ``pair_code_hash``. + pair_code_expires_at: datetime | None = Field( + default=None, + sa_column=Column(TZDateTime(), nullable=True), + ) + last_seen_at: datetime = Field( + default_factory=_utcnow, sa_column=Column(TZDateTime(), nullable=False) + ) diff --git a/app/remote/__init__.py b/app/remote/__init__.py new file mode 100644 index 00000000..0f613f91 --- /dev/null +++ b/app/remote/__init__.py @@ -0,0 +1,8 @@ +"""Connection-aware core for outbound-only remote access (e.g. Telegram). + +Nothing in this package is imported unless a connection is configured and +enabled. Adapter-specific modules (``app/remote/telegram/``, added in a later +task) own provider payload translation; everything here is provider-neutral. +""" + +from __future__ import annotations diff --git a/app/remote/actions.py b/app/remote/actions.py new file mode 100644 index 00000000..bf86ef40 --- /dev/null +++ b/app/remote/actions.py @@ -0,0 +1,1498 @@ +"""Remote secondary actions — slash commands and More-actions menus. + +Handles ``/help``, ``/status``, ``/new``, ``/stop``, ``/unpair``, and the +**More actions** dispatch for Workflows, Coding projects, EASD runs, and +Scheduler tasks. Each menu loader returns bounded +:class:`RemoteMenuItem` entries with opaque callback tokens; each action +calls one existing service entry point and translates owning exceptions +into safe remote messages. + +Design constraints (from spec): +- Do not copy owner validation into this module. +- Every callback token is opaque, connection/principal-bound, and <=64 bytes. +- No command accepts credentials, repository paths, settings changes, or + arbitrary identifiers. +""" + +from __future__ import annotations + +import secrets +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING +from uuid import UUID + +from loguru import logger + +from app.remote import formatting +from app.remote.contracts import ( + RemoteAdapter, + RemoteButton, + RemoteInboundAction, + RemoteOutboundMessage, + RemoteOutboundPriority, +) +from app.remote.pairing import ( + PairingCodeExpired, + PairingCodeMismatch, + PairingCodeRateLimited, + PairingService, +) + +if TYPE_CHECKING: + from app.remote import control + from app.remote.contracts import RemoteAdapterStatus + from app.remote.outbound import RemoteProjection + +__all__ = ["RemoteActionResult", "RemoteActionService", "RemoteMenuItem"] + +_MAX_CALLBACK_TOKEN_BYTES = 64 +_CAPABILITY_TTL_SECONDS = 600 +_TELEGRAM_MAX_MESSAGE_LENGTH = 4096 + + +@dataclass(frozen=True) +class RemoteActionResult: + """Bounded, adapter-neutral outcome for one slash command.""" + + status: str + text: str = "" + + +@dataclass(frozen=True) +class RemoteMenuItem: + """One bounded menu item for the More-actions list.""" + + token: str + label: str + description: str = "" + + +@dataclass +class _ActionCapability: + """Opaque capability record for a menu action.""" + + token: str + connection_id: UUID + principal_id: str + destination_id: str + session_id: str + action_kind: str + action_target: str + created_at: float = field(default_factory=time.monotonic) + + +# ── Known commands ──────────────────────────────────────────────────────────── + +_SLASH_COMMANDS: frozenset[str] = frozenset( + { + "start", + "help", + "status", + "new", + "stop", + "unpair", + "actions", + "settings", + "health", + "changes", + "clear", + "pair", + "history", + } +) + + +# ── Help text ───────────────────────────────────────────────────────────────── + +_HELP_TEXT = """\U0001f44b You're paired with EvoFlux. Just type a message to send it to \ +your agent — no command needed. It'll pick up your current task, or start \ +a new one if there isn't one yet. + +Commands: +/status — What's my agent doing right now? +/new — Set aside the current task and start a fresh one +/stop — Interrupt the agent while it's working on your last message +/settings — Change mode, model, agent, or response style +/health — Check system health +/history — Browse recent chat sessions +/changes — See this task's file changes +/actions — Run a saved workflow, open a coding project, or fire a \ +scheduled task +/clear — Delete my recent messages in this chat (not yours) +/unpair — Disconnect this phone from EvoFlux +/pair — Connect this phone with a code from EvoFlux + +Send /help any time to see this again.""" + + +class RemoteActionService: + """Handles slash commands and More-actions dispatch for remote sessions. + + Owned by the runtime. Each method is called from the inbound handler + after authorization. + """ + + def __init__( + self, + *, + pairing_service: PairingService | None = None, + adapter: RemoteAdapter | None = None, + status_provider: "Callable[[], RemoteAdapterStatus] | None" = None, + ) -> None: + self._pairing_service = pairing_service or PairingService() + self._adapter = adapter + self._status_provider = status_provider + self._capabilities: dict[str, _ActionCapability] = {} + self._pending_by_token: dict[str, str] = {} + self._projection: "RemoteProjection | None" = None + + def set_adapter(self, adapter: RemoteAdapter | None) -> None: + self._adapter = adapter + + def set_status_provider( + self, provider: "Callable[[], RemoteAdapterStatus]" + ) -> None: + self._status_provider = provider + + def set_projection(self, projection: "RemoteProjection | None") -> None: + """Bind the outbound projection so ``/unpair`` can immediately clear + its active-pairing cache (AC-10: unpair revokes access right away, + not just callback/menu tokens).""" + self._projection = projection + + def register_capability( + self, + *, + connection_id: UUID | str, + principal_id: str, + destination_id: str, + session_id: str, + action_kind: str, + action_target: str, + ) -> str: + """Register one short-lived, principal-bound detail action.""" + return self._issue_token( + connection_id=UUID(str(connection_id)), + principal_id=principal_id, + destination_id=destination_id, + session_id=session_id, + action_kind=action_kind, + action_target=action_target, + ) + + # ── Command dispatch ────────────────────────────────────────────────── + + async def dispatch_command( + self, + db: AsyncSession, + action: RemoteInboundAction, + ) -> RemoteActionResult: + """Dispatch a slash command from a remote inbound action. + + ``action.text`` must start with ``/``. Unknown commands return + bounded help. + """ + text = (action.text or "").strip() + if not text.startswith("/"): + return RemoteActionResult(status="not_a_command") + + parts = text.split(maxsplit=1) + command = parts[0][1:].lower() # strip leading / + arg = parts[1].strip() if len(parts) > 1 else "" + + if command == "help" or command == "start": + return await self._cmd_help(db, action) + elif command == "status": + return await self._cmd_status(db, action) + elif command == "new": + return await self._cmd_new(db, action) + elif command == "stop": + return await self._cmd_stop(db, action) + elif command == "unpair": + return await self._cmd_unpair(db, action) + elif command == "pair": + return await self._cmd_pair(db, action, arg) + elif command == "actions": + return await self._cmd_actions(db, action, arg) + elif command == "settings": + return await self._cmd_settings(db, action) + elif command == "health": + return await self._cmd_health(db, action) + elif command == "changes": + return await self._cmd_changes(db, action) + elif command == "clear": + return await self._cmd_clear(db, action) + elif command == "history": + return await self._cmd_history(db, action) + else: + # Unknown command — return bounded help. + return await self._cmd_help(db, action) + + async def handle_action_callback( + self, + action: RemoteInboundAction, + db: AsyncSession, + ) -> bool: + """Handle a callback from a More-actions menu. + + Returns True if the callback was resolved, False if unknown. + """ + token = action.callback_token + if not token: + return False + + cap = self._capabilities.get(token) + if cap is None: + return False + + if ( + cap.connection_id != action.connection_id + or cap.principal_id != action.principal.principal_id + or cap.destination_id != action.principal.destination_id + ): + return False + + # Acknowledge an authorized callback before any follow-up work so the + # provider stops showing its loading state even when the token expired. + if self._adapter is not None: + await self._adapter.answer_callback(token) + + if time.monotonic() - cap.created_at > _CAPABILITY_TTL_SECONDS: + self._discard(cap) + if cap.action_kind in {"diff", "toollog"}: + await self._send( + cap.destination_id, + "This expired. Ask me again and I'll fetch it fresh.", + connection_id=cap.connection_id, + ) + return True + return False + + if cap.action_kind in {"diff", "toollog"}: + self._discard(cap) + await self._send_detail(cap) + return True + + # Dispatch the action. + resolved = await self._execute_action(cap, action, db) + if resolved: + self._discard(cap) + return resolved + + # ── Command implementations ──────────────────────────────────────────── + + async def _cmd_help( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + return RemoteActionResult(status="ok", text=_HELP_TEXT) + + async def _cmd_status( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + + # Connection status. + status_text = "Connected" + if self._status_provider is not None: + adapter_status = self._status_provider() + status_text = f"Status: {adapter_status.state.value}" + + # Current task. + task_text = "No current task" + if pairing.active_session_id is not None: + from app.models.chat import ChatSession + + session = await db.get(ChatSession, pairing.active_session_id) + if session is not None and session.title: + task_text = f"Current task: {session.title}" + elif session is not None: + task_text = f"Current task: session {session.id}" + + return RemoteActionResult( + status="ok", + text=f"{status_text}\n{task_text}\nPaired: {pairing.label or 'Yes'}", + ) + + async def _cmd_settings( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.models.chat import ChatSession, normalize_mode + from app.remote import control + from app.remote.formatting import render_settings_card + + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + + no_active_session_text = ( + "No active task yet — send a message to start one, then " + "/settings shows its mode/model/agent." + ) + if pairing.active_session_id is None: + await self._send(action.principal.destination_id, no_active_session_text) + return RemoteActionResult(status="ok", text=no_active_session_text) + + session = await db.get(ChatSession, pairing.active_session_id) + if session is None: + await self._send(action.principal.destination_id, no_active_session_text) + return RemoteActionResult(status="ok", text=no_active_session_text) + + app_mode = normalize_mode(session.mode) + session_id = str(session.id) + + mode_tokens = { + mode: self._issue_settings_token(action, session_id, "set_mode", mode) + for mode in control.ALLOWED_REMOTE_MODES + } + agent_names = await control.list_lead_names(app_mode) + agent_tokens = { + name: self._issue_settings_token(action, session_id, "set_agent", name) + for name in agent_names + } + model_ids = await control.list_model_ids(app_mode) + model_tokens = { + model_id: self._issue_settings_token( + action, session_id, "set_model", model_id + ) + for model_id in model_ids + } + response_mode_tokens = { + mode: self._issue_settings_token( + action, str(pairing.id), "set_response_mode", mode + ) + for mode in control.ALLOWED_RESPONSE_MODES + } + configured_provider_count = await control.count_configured_providers() + + text, buttons = render_settings_card( + connection_label=pairing.label or "This phone", + model=session.model or "(default)", + permission_mode=session.permission_mode, + agent_name=session.agent_name or "(default)", + response_mode=pairing.response_mode, + response_mode_tokens=response_mode_tokens, + mode_tokens=mode_tokens, + agent_tokens=agent_tokens, + model_tokens=model_tokens, + configured_provider_count=configured_provider_count, + ) + + if self._adapter is not None: + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok", text=text) + + def build_onboarding_card( + self, action: RemoteInboundAction, *, label: str + ) -> tuple[str, tuple[RemoteButton, ...]]: + """The first-run card sent right after a successful pairing + (AC-54). Unlike every other card this module builds, no session + or turn exists yet — these three tokens' session_id/action_target + are unused placeholders, the same repurposed-field pattern already + used for the response-mode tokens' pairing-id reuse.""" + from app.models.chat import ChatSession + from app.remote.formatting import render_onboarding_card + + setup_token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="onboarding_setup", + action_target="", + ) + health_token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="onboarding_health", + action_target="", + ) + start_token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="onboarding_start", + action_target="", + ) + default_mode = ChatSession.model_fields["permission_mode"].default + return render_onboarding_card( + label=label, + default_permission_mode=default_mode, + setup_token=setup_token, + health_token=health_token, + start_token=start_token, + ) + + def _issue_settings_token( + self, + action: RemoteInboundAction, + session_id: str, + action_kind: str, + action_target: str, + ) -> str: + return self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id=session_id, + action_kind=action_kind, + action_target=action_target, + ) + + async def _cmd_health( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + """System-wide diagnostics — no pairing/session lookup beyond + authorization, unlike /settings and /changes which are per-session.""" + from app.remote import control + from app.remote.formatting import render_health_card + + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + + diagnostics = await control.get_health_diagnostics() + text = render_health_card(diagnostics.get("checks", [])) + return RemoteActionResult(status="ok", text=text) + + async def _cmd_changes( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.models.chat import ChatSession + from app.remote.formatting import render_changes_card + from app.services.turn_changes import get_latest + + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + + no_changes_text = ( + "No active task yet — send a message to start one, then " + "/changes shows what it touched." + ) + if pairing.active_session_id is None: + await self._send(action.principal.destination_id, no_changes_text) + return RemoteActionResult(status="ok", text=no_changes_text) + + session = await db.get(ChatSession, pairing.active_session_id) + if session is None: + await self._send(action.principal.destination_id, no_changes_text) + return RemoteActionResult(status="ok", text=no_changes_text) + + session_id = str(session.id) + snapshot = get_latest(session_id) + if snapshot is None or not snapshot.files: + no_files_text = "No file changes recorded for this task yet." + await self._send(action.principal.destination_id, no_files_text) + return RemoteActionResult(status="ok", text=no_files_text) + + bounded_files = snapshot.files[:8] + file_tokens = { + f.path: self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id=session_id, + action_kind="changes_diff", + action_target=f.path, + ) + for f in bounded_files + } + + text, buttons = render_changes_card( + title=session.title or "Task", + files=[(f.path, f.status, f.additions, f.deletions) for f in bounded_files], + additions=snapshot.additions, + deletions=snapshot.deletions, + file_tokens=file_tokens, + ) + + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok", text=text) + + async def _cmd_clear( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + """Delete this bot's own recent messages in the chat. Telegram + only lets a bot delete messages it sent, and only within 48 hours + (see TelegramAdapter.clear_history) — the user's own messages, + including the /clear command itself, are never touched. Does not + self-send: like /health, its text is delivered by runtime.py's + command fallback.""" + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + + clear_history = getattr(self._adapter, "clear_history", None) + if clear_history is None: + return RemoteActionResult( + status="ok", text="This connection can't clear messages." + ) + + cleared = await clear_history(action.principal.destination_id) + text = ( + f"Cleared {cleared} message(s)." + if cleared + else "Nothing to clear (either none sent, or too old for Telegram " + "to delete)." + ) + return RemoteActionResult(status="ok", text=text) + + async def _cmd_new( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.remote.inbound import RemoteInboundService + + inbound = RemoteInboundService(pairing_service=self._pairing_service) + result = await inbound.new_task(db, action) + if result.status == "unauthorized": + return RemoteActionResult(status="unauthorized") + return RemoteActionResult( + status="ok", text="Current task cleared. Next message starts a new task." + ) + + async def _cmd_stop( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.remote.inbound import RemoteInboundService + + inbound = RemoteInboundService(pairing_service=self._pairing_service) + result = await inbound.stop_current(db, action) + if result.status == "unauthorized": + return RemoteActionResult(status="unauthorized") + if result.status == "no_active_turn": + return RemoteActionResult(status="ok", text="No active task to stop.") + return RemoteActionResult(status="ok", text="Task stopped.") + + async def _cmd_history( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + """List recent sessions with inline buttons to inspect each one.""" + from app.services.chat_service import list_sessions_page + + sessions, _cursor, _has_more = await list_sessions_page(db, limit=5) + if not sessions: + return RemoteActionResult(status="ok", text="No sessions yet.") + + items: list[dict[str, str]] = [] + for s in sessions: + token = self._issue_token( + action_kind="session_detail", + action_target=str(s.id), + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + ) + from datetime import UTC, datetime as _dt + + now = _dt.now(UTC) + age = now - (s.updated_at or s.created_at or now) + hours = int(age.total_seconds() // 3600) + if hours < 1: + time_label = "just now" + elif hours < 24: + time_label = f"{hours}h ago" + else: + days = hours // 24 + time_label = f"{days}d ago" + title = s.title or "Untitled" + items.append({"title": f"{title} · {time_label}", "token": token}) + + from app.remote.formatting import render_session_list_card + + text, buttons = render_session_list_card(items) + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok") + + async def _exec_session_detail( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Show details for one session.""" + from app.models.chat import ChatSession, SessionMessage + from app.remote.formatting import render_session_detail_card + from sqlmodel import col, func, select + + session_id_str = cap.action_target + try: + session_id = UUID(session_id_str) + except ValueError: + await self._reply_text( + action.principal.destination_id, "Invalid session id." + ) + return True + session = await db.get(ChatSession, session_id) + if session is None: + await self._reply_text( + action.principal.destination_id, "Session not found." + ) + return True + + # Count messages and tool calls. + msg_count = ( + await db.exec( + select(func.count()).where( + col(SessionMessage.session_id) == session_id, + col(SessionMessage.exclude_from_context).is_(False), + col(SessionMessage.is_summary).is_(False), + ) + ) + ).one() + tool_count = ( + await db.exec( + select(func.count()).where( + col(SessionMessage.session_id) == session_id, + col(SessionMessage.role) == "tool", + ) + ) + ).one() + + # Get last user and assistant messages. + last_user = ( + await db.exec( + select(SessionMessage) + .where( + col(SessionMessage.session_id) == session_id, + col(SessionMessage.role) == "user", + col(SessionMessage.exclude_from_context).is_(False), + ) + .order_by(col(SessionMessage.created_at).desc()) + .limit(1) + ) + ).first() + last_assistant = ( + await db.exec( + select(SessionMessage) + .where( + col(SessionMessage.session_id) == session_id, + col(SessionMessage.role) == "assistant", + col(SessionMessage.exclude_from_context).is_(False), + col(SessionMessage.is_summary).is_(False), + ) + .order_by(col(SessionMessage.created_at).desc()) + .limit(1) + ) + ).first() + + from datetime import UTC, datetime as _dt + + created = session.created_at or _dt.now(UTC) + age = _dt.now(UTC) - created + hours = int(age.total_seconds() // 3600) + if hours < 1: + time_label = "just now" + elif hours < 24: + time_label = f"{hours}h ago" + else: + days = hours // 24 + time_label = f"{days}d ago" + + switch_token = self._issue_token( + action_kind="session_switch", + action_target=str(session.id), + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + ) + summarize_token = self._issue_token( + action_kind="session_summarize", + action_target=str(session.id), + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + ) + + text, buttons = render_session_detail_card( + title=session.title or "Untitled", + mode=session.mode, + created_at=time_label, + message_count=msg_count or 0, + tool_call_count=tool_count or 0, + last_user_message=last_user.content[:500] + if last_user and last_user.content + else None, + last_assistant_message=last_assistant.content[:500] + if last_assistant and last_assistant.content + else None, + switch_token=switch_token, + summarize_token=summarize_token, + ) + await self._send(action.principal.destination_id, text, buttons=buttons) + return True + + async def _exec_session_switch( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Switch the active session to the selected one.""" + from app.models.chat import ChatSession + + session_id = cap.action_target + try: + session_uuid = UUID(session_id) + except ValueError: + await self._reply_text( + action.principal.destination_id, "Invalid session id." + ) + return True + session = await db.get(ChatSession, session_uuid) + if session is None: + await self._reply_text( + action.principal.destination_id, "Session not found." + ) + return True + + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + await self._reply_text(action.principal.destination_id, "Not paired.") + return True + + pairing.active_session_id = session.id + db.add(pairing) + await db.commit() + + title = session.title or "Untitled" + await self._reply_text( + action.principal.destination_id, + f"\u2705 Switched to: {title}\nNext message continues this session.", + ) + return True + + async def _exec_session_summarize( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Show a condensed transcript of the selected session.""" + from app.models.chat import SessionMessage + from sqlmodel import col, select + + session_id = UUID(cap.action_target) + messages = ( + await db.exec( + select(SessionMessage) + .where( + col(SessionMessage.session_id) == session_id, + col(SessionMessage.exclude_from_context).is_(False), + col(SessionMessage.is_summary).is_(False), + col(SessionMessage.role).in_(["user", "assistant"]), + ) + .order_by(col(SessionMessage.created_at)) + .limit(20) + ) + ).all() + + if not messages: + await self._reply_text( + action.principal.destination_id, "No messages in this session." + ) + return True + + lines: list[str] = ["\U0001f4dd Session transcript", ""] + for msg in messages: + role_icon = "\U0001f464" if msg.role == "user" else "\U0001f916" + content = (msg.content or "")[:200] + if len(msg.content or "") > 200: + content += "\u2026" + lines.append(f"{role_icon} {formatting.markdown_to_telegram_html(content)}") + text = "\n".join(lines) + await self._reply_text(action.principal.destination_id, text) + return True + return True + + async def _cmd_unpair( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + removed = await self._pairing_service.unpair(db, action.connection_id) + if removed: + if self._projection is not None: + self._projection.clear_active_pairing() + return RemoteActionResult( + status="ok", text="Phone unpaired. Send /start to pair again." + ) + return RemoteActionResult(status="ok", text="No active pairing to remove.") + + async def _cmd_pair( + self, db: AsyncSession, action: RemoteInboundAction, arg: str + ) -> RemoteActionResult: + """Verify a phone-submitted pairing code (``/pair ``). + + On success the connection is paired; on failure the user sees a + short diagnostic message. + """ + code = arg.strip() + if not code or not code.isdigit(): + return RemoteActionResult( + status="pair_bad_format", + text="Send /pair followed by the 8-digit code shown on your desktop. Example: /pair 12345678", + ) + + try: + pairing = await self._pairing_service.verify_pair_code( + db, + connection_id=action.connection_id, + principal=action.principal, + raw_code=code, + ) + except PairingCodeExpired: + return RemoteActionResult( + status="pair_expired", + text="Code expired. Open EvoFlux and generate a new one.", + ) + except PairingCodeMismatch: + return RemoteActionResult( + status="pair_mismatch", + text="That code didn't match. Check the code and try again.", + ) + except PairingCodeRateLimited: + return RemoteActionResult( + status="pair_rate_limited", + text="Too many attempts. Wait a moment and try again.", + ) + + if self._projection is not None: + self._projection.set_active_pairing( + connection_id=str(action.connection_id), + destination_id=pairing.destination_id, + notify_scope=pairing.notify_scope, + principal_id=pairing.principal_id, + ) + return RemoteActionResult(status="pair_ok", text="Phone connected.") + + async def _cmd_actions( + self, db: AsyncSession, action: RemoteInboundAction, arg: str + ) -> RemoteActionResult: + """Show the More-actions menu.""" + items = await self._load_action_menu(db, action) + if not items: + return RemoteActionResult( + status="ok", text="No additional actions available." + ) + + # Format as a numbered list. + lines = ["More actions:"] + for i, item in enumerate(items, 1): + lines.append(f"{i}. {item.label}") + if item.description: + lines.append(f" {item.description}") + + # Send the menu with buttons. + if self._adapter is not None: + buttons = tuple( + RemoteButton(text=item.label[:64], token=item.token) + for item in items[:8] # bound to 8 buttons + ) + await self._send( + action.principal.destination_id, + "\n".join(lines), + buttons=buttons, + ) + + return RemoteActionResult(status="ok", text="\n".join(lines)) + + # ── Menu loaders ─────────────────────────────────────────────────────── + + async def _load_action_menu( + self, db: AsyncSession, action: RemoteInboundAction + ) -> list[RemoteMenuItem]: + """Load available actions from all owners.""" + items: list[RemoteMenuItem] = [] + + # Workflows + items.extend(await self._load_workflows(db, action)) + + # Coding projects + items.extend(await self._load_coding_projects(db, action)) + + # Scheduled tasks + items.extend(await self._load_scheduled_tasks(db, action)) + + return items[:20] # bounded to 20 items + + async def _load_workflows( + self, db: AsyncSession, action: RemoteInboundAction + ) -> list[RemoteMenuItem]: + """Load available workflows.""" + try: + from app.services.workflows_fs import discover_workflows + + discovered = discover_workflows(None) + items: list[RemoteMenuItem] = [] + for found in discovered[:5]: # bound to 5 + defn = found.definition + if defn is None: + continue + token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="workflow_start", + action_target=defn.name, + ) + items.append( + RemoteMenuItem( + token=token, + label=f"Workflow: {defn.name[:50]}", + description=defn.description[:100] if defn.description else "", + ) + ) + return items + except Exception as exc: + logger.debug("remote_actions_load_workflows_failed error={}", exc) + return [] + + async def _load_coding_projects( + self, db: AsyncSession, action: RemoteInboundAction + ) -> list[RemoteMenuItem]: + """Load visible coding projects.""" + try: + from app.services.coding_project_service import list_visible_projects + + projects = await list_visible_projects(db) + items: list[RemoteMenuItem] = [] + for proj in projects[:5]: # bound to 5 + token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="coding_task", + action_target=str(proj.id), + ) + items.append( + RemoteMenuItem( + token=token, + label=f"Project: {proj.name[:50]}", + description=(proj.description or "")[:100], + ) + ) + return items + except Exception as exc: + logger.debug("remote_actions_load_projects_failed error={}", exc) + return [] + + async def _load_scheduled_tasks( + self, db: AsyncSession, action: RemoteInboundAction + ) -> list[RemoteMenuItem]: + """Load manually-triggerable scheduled tasks.""" + try: + from app.scheduler.scheduler import task_scheduler + + tasks = await task_scheduler.list_tasks() + items: list[RemoteMenuItem] = [] + for task in tasks[:5]: # bound to 5 + if not task.enabled: + continue + token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="schedule_trigger", + action_target=str(task.id), + ) + items.append( + RemoteMenuItem( + token=token, + label=f"Schedule: {task.name[:50]}", + ) + ) + return items + except Exception as exc: + logger.debug("remote_actions_load_schedules_failed error={}", exc) + return [] + + # ── Action execution ─────────────────────────────────────────────────── + + async def _execute_action( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Execute a menu action by kind.""" + if cap.action_kind == "workflow_start": + return await self._exec_workflow_start(cap, action, db) + elif cap.action_kind == "coding_task": + return await self._exec_coding_task(cap, action, db) + elif cap.action_kind == "schedule_trigger": + return await self._exec_schedule_trigger(cap, action, db) + elif cap.action_kind == "set_mode": + return await self._exec_set_mode(cap, action, db) + elif cap.action_kind == "set_agent": + return await self._exec_set_agent(cap, action, db) + elif cap.action_kind == "set_model": + return await self._exec_set_model(cap, action, db) + elif cap.action_kind == "set_response_mode": + return await self._exec_set_response_mode(cap, action, db) + elif cap.action_kind == "onboarding_setup": + return await self._exec_onboarding_setup(cap, action, db) + elif cap.action_kind == "onboarding_health": + return await self._exec_onboarding_health(cap, action, db) + elif cap.action_kind == "onboarding_start": + return await self._exec_onboarding_start(cap, action, db) + elif cap.action_kind == "changes_diff": + return await self._exec_changes_diff(cap, action, db) + elif cap.action_kind == "session_detail": + return await self._exec_session_detail(cap, action, db) + elif cap.action_kind == "session_switch": + return await self._exec_session_switch(cap, action, db) + elif cap.action_kind == "session_summarize": + return await self._exec_session_summarize(cap, action, db) + return False + + async def _exec_workflow_start( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Start a workflow by name.""" + try: + from app.services.workflows_fs import discover_workflows + + discovered = discover_workflows(None) + defn = None + for found in discovered: + if found.definition and found.definition.name == cap.action_target: + defn = found.definition + break + + if defn is None: + await self._reply_text( + action.principal.destination_id, + f"Workflow '{cap.action_target}' not found.", + ) + return True + + # Workflows need a session to run in. We need to create one or + # use the current task's session. + pairing = await self._pairing_service.authorize( + db, + connection_id=cap.connection_id, + principal_id=cap.principal_id, + ) + if pairing is None or pairing.active_session_id is None: + await self._reply_text( + action.principal.destination_id, + "No active task. Send a message first to create one, then try again.", + ) + return True + + session_id = str(pairing.active_session_id) + + from app.workflow.runner import WorkflowRunner + + runner = WorkflowRunner() + await runner.start( + defn, + definition_hash="", + session_id=session_id, + inputs={}, + scope_workspace=None, + ) + await self._reply_text( + action.principal.destination_id, + f"Workflow '{cap.action_target}' started.", + ) + return True + except RuntimeError as exc: + await self._reply_text( + action.principal.destination_id, + f"Cannot start workflow: {exc}", + ) + return True + except Exception as exc: + logger.warning("remote_workflow_start_failed error={}", exc) + await self._reply_text( + action.principal.destination_id, + "Failed to start workflow.", + ) + return True + + async def _exec_coding_task( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Start a coding task for a project.""" + try: + from app.services.coding_project_service import get_project + + project = await get_project(db, UUID(cap.action_target)) + if project is None: + await self._reply_text( + action.principal.destination_id, + "Project not found.", + ) + return True + + # Create a coding session for this project. + from app.services.chat_service import create_chat_session + from app.services.coding_project_service import ( + get_project_workspace_paths, + ) + + chat = await create_chat_session(db) + chat.mode = "coding" + chat.project_id = project.id + # Set the primary workspace so resolve_team_for_session takes + # the coding branch (it checks session.workspace is truthy). + paths = await get_project_workspace_paths(db, project.id) + if paths: + chat.workspace = paths[0] + chat.tags = [ + "remote_origin", + f"remote_connection:{cap.connection_id}", + ] + db.add(chat) + await db.commit() + + # Update pairing to point to this session. + pairing = await self._pairing_service.authorize( + db, + connection_id=cap.connection_id, + principal_id=cap.principal_id, + ) + if pairing is not None: + pairing.active_session_id = chat.id + db.add(pairing) + await db.commit() + + await self._reply_text( + action.principal.destination_id, + f"Coding task created for project '{project.name}'. Send your first message.", + ) + return True + except Exception as exc: + logger.warning("remote_coding_task_failed error={}", exc) + await self._reply_text( + action.principal.destination_id, + "Failed to create coding task.", + ) + return True + + async def _exec_schedule_trigger( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Trigger a scheduled task manually.""" + try: + from app.scheduler.scheduler import task_scheduler + + await task_scheduler.trigger(UUID(cap.action_target)) + await self._reply_text( + action.principal.destination_id, + "Scheduled task triggered.", + ) + return True + except Exception as exc: + logger.warning("remote_schedule_trigger_failed error={}", exc) + await self._reply_text( + action.principal.destination_id, + f"Failed to trigger task: {exc}", + ) + return True + + # ── Token management ─────────────────────────────────────────────────── + + def _issue_token( + self, + *, + connection_id: UUID, + principal_id: str, + destination_id: str, + session_id: str, + action_kind: str, + action_target: str, + ) -> str: + token = secrets.token_urlsafe(16) + assert len(token) <= _MAX_CALLBACK_TOKEN_BYTES + cap = _ActionCapability( + token=token, + connection_id=connection_id, + principal_id=principal_id, + destination_id=destination_id, + session_id=session_id, + action_kind=action_kind, + action_target=action_target, + ) + self._capabilities[token] = cap + self._pending_by_token[token] = action_kind + return token + + def _discard(self, cap: _ActionCapability) -> None: + self._capabilities.pop(cap.token, None) + self._pending_by_token.pop(cap.token, None) + + # ── Delivery helpers ─────────────────────────────────────────────────── + + async def _send( + self, + destination_id: str, + text: str, + buttons: tuple[RemoteButton, ...] = (), + *, + connection_id: UUID | None = None, + priority: RemoteOutboundPriority = RemoteOutboundPriority.INFORMATIONAL, + ) -> None: + if self._adapter is None: + return + try: + msg = RemoteOutboundMessage( + connection_id=connection_id or UUID(int=0), + destination_id=destination_id, + text=text, + buttons=buttons, + priority=priority, + ) + await self._adapter.send(msg) + except Exception as exc: + logger.warning("remote_action_send_failed error={}", exc) + + async def _exec_set_mode( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_permission_mode( + db, cap.session_id, cap.action_target + ) + await self._reply_control_result( + action, result, f"Mode set to {cap.action_target}." + ) + return True + + async def _exec_set_agent( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_lead_agent(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Lead agent set to {cap.action_target}." + ) + return True + + async def _exec_set_model( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_model(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Model set to {cap.action_target}." + ) + return True + + async def _exec_set_response_mode( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """cap.session_id here holds a PAIRING id, not a chat session id — + this preference lives on RemotePairing, the one action kind in + this module that isn't chat-session-scoped.""" + from app.remote import control + + result = await control.set_response_mode(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Responses set to {cap.action_target}." + ) + return True + + async def _exec_onboarding_setup( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """/settings already self-sends its reply — nothing further to do + here, matching how /settings itself works when typed directly.""" + await self._cmd_settings(db, action) + return True + + async def _exec_onboarding_health( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Unlike /settings, _cmd_health does not self-send (its text is + normally sent by runtime.py's /health fallback path) — send it + explicitly here.""" + result = await self._cmd_health(db, action) + if result.text: + await self._reply_text(action.principal.destination_id, result.text) + return True + + async def _exec_onboarding_start( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + await self._reply_text( + action.principal.destination_id, + "Great — type your first message whenever you're ready.", + ) + return True + + async def _exec_changes_diff( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Unlike every other capability action kind, this one fetches its + content fresh at tap time rather than reading a pre-stored + ``action_target`` string — a git diff must reflect the file's + state now, not its state when the turn finished. ``action_target`` + holds the file path; the session's workspace (needed for + ``control.get_file_diff``) is resolved from ``cap.session_id`` here.""" + from uuid import UUID as _UUID + + from app.models.chat import ChatSession + from app.remote import control + + try: + session_uuid = _UUID(cap.session_id) + except ValueError: + await self._reply_text( + action.principal.destination_id, "That task no longer exists." + ) + return True + + session = await db.get(ChatSession, session_uuid) + if session is None or not session.workspace: + await self._reply_text( + action.principal.destination_id, + "That task's workspace is no longer available.", + ) + return True + + diff = await control.get_file_diff(session.workspace, cap.action_target) + if not diff.strip(): + await self._reply_text( + action.principal.destination_id, + f"No diff available for {cap.action_target}.", + ) + return True + + redacted = _redact_text(diff) + for text in _render_detail_cards(f"Diff: {cap.action_target}", redacted): + await self._send( + action.principal.destination_id, + text, + connection_id=action.connection_id, + priority=RemoteOutboundPriority.HIGH, + ) + return True + + async def _reply_control_result( + self, + action: RemoteInboundAction, + result: control.ControlResult, + success_text: str, + ) -> None: + if result.status == "ok": + text = success_text + elif result.status == "conflict": + text = result.detail or "That can't be changed right now." + elif result.status == "not_found": + text = "That task no longer exists." + elif result.detail: + text = f"That value isn't valid: {result.detail}" + else: + text = "That value isn't valid." + await self._reply_text(action.principal.destination_id, text) + + async def _reply_text(self, destination_id: str, text: str) -> None: + await self._send(destination_id, text) + + async def _send_detail(self, cap: _ActionCapability) -> None: + """Send redacted, escaped drill-down content in bounded HTML cards.""" + label = "Full diff" if cap.action_kind == "diff" else "Tool log" + redacted = _redact_text(cap.action_target) + for text in _render_detail_cards(label, redacted): + await self._send( + cap.destination_id, + text, + connection_id=cap.connection_id, + priority=RemoteOutboundPriority.HIGH, + ) + + +# ── Redaction helper ────────────────────────────────────────────────────────── + + +def _render_detail_cards(label: str, content: str) -> list[str]: + """Wrap escaped detail text in independently valid Telegram HTML cards. + + Escaping can expand a source character (``<`` becomes ``<``), so chunk + the escaped result rather than source text. Every card keeps its own + heading and ``
`` wrapper and is bounded by Telegram's 4096-character
+    provider limit.
+    """
+    prefix = f"{label}\n\n
"
+    suffix = "
" + content_budget = _TELEGRAM_MAX_MESSAGE_LENGTH - len(prefix) - len(suffix) + assert content_budget > 0 + + cards: list[str] = [] + current: list[str] = [] + current_length = 0 + for character in content: + escaped = formatting.escape(character) + if current and current_length + len(escaped) > content_budget: + cards.append(prefix + "".join(current) + suffix) + current = [] + current_length = 0 + current.append(escaped) + current_length += len(escaped) + + cards.append(prefix + "".join(current) + suffix) + return cards + + +def _redact_text(text: str) -> str: + """Apply remote-channel outbound redaction.""" + try: + from app.agent.outbound_redaction import OutboundContext, protect_outbound_text + + protected, _report = protect_outbound_text( + text, context=OutboundContext(channel="remote") + ) + return protected + except Exception: + return text + + +# ── Command validation ──────────────────────────────────────────────────────── + + +def is_slash_command(text: str) -> bool: + """Check if text is a recognized slash command.""" + if not text.startswith("/"): + return False + command = text.split(maxsplit=1)[0][1:].lower() + return command in _SLASH_COMMANDS + + +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/app/remote/connection_service.py b/app/remote/connection_service.py new file mode 100644 index 00000000..f155e602 --- /dev/null +++ b/app/remote/connection_service.py @@ -0,0 +1,288 @@ +"""Durable connection-record coordination: validate, vault, persist, limit. + +Keeps the v1 one-connection product limit, credential custody, and bot +identity binding in one narrow service so HTTP routes (a later task) stay +thin and the Telegram adapter (a later task) never touches ``settings.yaml``, +the database, or the credential vault directly. + +Per the spec's concurrency contract, a database transaction here never +performs network or vault I/O: a token is validated against the adapter and +saved to the OS credential vault *before* the connection row is inserted or +updated, with a compensating vault write if the database commit then fails. +""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable +from uuid import UUID + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.credential_store import ( + CredentialStore, + CredentialStoreError, + CredentialStoreProtocol, +) +from app.models.remote import RemoteConnection, RemotePairing +from app.remote.contracts import ( + RemoteAdapterFactory, + RemoteAdapterKind, + RemoteAdapterValidationError, +) + +#: Vault service name every remote connection's credential is stored under. +#: The account key is derived per connection as ``connection:`` and +#: never appears in an API response. +CREDENTIAL_VAULT_SERVICE = "EvoFlux Remote" + +CredentialStoreFactory = Callable[[UUID], CredentialStoreProtocol] + +__all__ = [ + "CREDENTIAL_VAULT_SERVICE", + "CredentialStoreFactory", + "RemoteAdapterValidationError", + "RemoteConnectionConflictError", + "RemoteConnectionError", + "RemoteConnectionNotFoundError", + "RemoteConnectionService", + "RemoteCredentialError", + "default_credential_store_factory", +] + + +def default_credential_store_factory(connection_id: UUID) -> CredentialStoreProtocol: + """The production vault-account keying: ``connection:``.""" + return CredentialStore( + service=CREDENTIAL_VAULT_SERVICE, account=f"connection:{connection_id}" + ) + + +class RemoteConnectionError(Exception): + """Base class for :class:`RemoteConnectionService` domain errors.""" + + +class RemoteConnectionConflictError(RemoteConnectionError): + """A second connection was requested. + + AC-3: v1 permits exactly one configured connection. This is a + service-level product limit, not a database uniqueness constraint, so a + later release can lift it without a migration. + """ + + +class RemoteConnectionNotFoundError(RemoteConnectionError): + """An operation named a connection ID that does not exist.""" + + +class RemoteCredentialError(RemoteConnectionError): + """The OS credential vault could not complete an operation. + + Never carries the token or any vault-internal detail beyond the + underlying :class:`~app.core.credential_store.CredentialStoreError` + message, which is itself already safe (AC-5). + """ + + +class RemoteConnectionService: + """Coordinates connection lifecycle across the database, the OS + credential vault, and adapter-identity validation.""" + + def __init__( + self, + *, + adapter_factory: RemoteAdapterFactory, + credential_store_factory: CredentialStoreFactory | None = None, + ) -> None: + self._adapter_factory = adapter_factory + self._credential_store_factory = ( + credential_store_factory or default_credential_store_factory + ) + + def _store(self, connection_id: UUID) -> CredentialStoreProtocol: + return self._credential_store_factory(connection_id) + + async def list(self, session: AsyncSession) -> builtins.list[RemoteConnection]: + result = await session.exec(select(RemoteConnection)) + return builtins.list(result.all()) + + async def get( + self, session: AsyncSession, connection_id: UUID + ) -> RemoteConnection | None: + return await session.get(RemoteConnection, connection_id) + + async def create_connection( + self, session: AsyncSession, *, token: str, label: str + ) -> RemoteConnection: + """Validate *token*, vault it, then persist a new connection. + + Raises :class:`RemoteConnectionConflictError` if a connection + already exists, + :class:`~app.remote.contracts.RemoteAdapterValidationError` if the + token does not resolve to a bot identity, or + :class:`RemoteCredentialError` if the vault save fails. In every + failure case, no connection record is created. + """ + if await self.list(session): + raise RemoteConnectionConflictError( + "This installation already has a configured remote connection." + ) + + identity = await self._adapter_factory.validate_token( + RemoteAdapterKind.TELEGRAM, token + ) + + connection = RemoteConnection( + adapter=identity.adapter.value, + label=label, + enabled=False, + adapter_principal_id=identity.principal_id, + adapter_username=identity.username, + ) + + store = self._store(connection.id) + try: + store.save(token) + except CredentialStoreError as exc: + raise RemoteCredentialError(str(exc)) from exc + + try: + session.add(connection) + await session.commit() + except BaseException: + await session.rollback() + _delete_best_effort(store) + raise + + await session.refresh(connection) + return connection + + async def update_token( + self, session: AsyncSession, connection_id: UUID, *, token: str + ) -> RemoteConnection: + """Validate and atomically replace a connection's bot token. + + A new identity whose ``principal_id`` differs from the one already + stored (a different bot) invalidates the existing pairing row before + the new credential becomes active (AC-6, AC-10). A new identity for + the *same* bot leaves any existing pairing untouched — only the + decorative username may change. + """ + connection = await self.get(session, connection_id) + if connection is None: + raise RemoteConnectionNotFoundError( + f"Remote connection {connection_id} does not exist." + ) + + identity = await self._adapter_factory.validate_token( + RemoteAdapterKind.TELEGRAM, token + ) + + store = self._store(connection_id) + try: + previous_token = store.load() + except CredentialStoreError: + previous_token = None + + try: + store.save(token) + except CredentialStoreError as exc: + raise RemoteCredentialError(str(exc)) from exc + + different_bot = identity.principal_id != connection.adapter_principal_id + connection.adapter_principal_id = identity.principal_id + connection.adapter_username = identity.username + + try: + if different_bot: + existing_pairings = await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection_id + ) + ) + for pairing in existing_pairings.all(): + await session.delete(pairing) + session.add(connection) + await session.commit() + except BaseException: + await session.rollback() + if previous_token is not None: + _save_best_effort(store, previous_token) + raise + + await session.refresh(connection) + return connection + + async def set_enabled( + self, session: AsyncSession, connection_id: UUID, *, enabled: bool + ) -> RemoteConnection: + connection = await self.get(session, connection_id) + if connection is None: + raise RemoteConnectionNotFoundError( + f"Remote connection {connection_id} does not exist." + ) + connection.enabled = enabled + session.add(connection) + await session.commit() + await session.refresh(connection) + return connection + + async def set_label( + self, session: AsyncSession, connection_id: UUID, *, label: str + ) -> RemoteConnection: + """Rename a connection. Purely local metadata — never touches the + vault, adapter identity, or pairing (mirrors :meth:`set_enabled`).""" + connection = await self.get(session, connection_id) + if connection is None: + raise RemoteConnectionNotFoundError( + f"Remote connection {connection_id} does not exist." + ) + connection.label = label + session.add(connection) + await session.commit() + await session.refresh(connection) + return connection + + async def remove(self, session: AsyncSession, connection_id: UUID) -> None: + """Delete the connection's vault entry, then the connection + (cascading its pairing). + + The vault entry is deleted *first*, deliberately mirroring + :meth:`create_connection` and :meth:`update_token`'s vault-before- + commit ordering. A DB row that outlives its vault entry is a safe, + recoverable state — the row still names the connection, so the user + can retry ``remove`` (or ``set_enabled(False)``) and nothing is ever + orphaned. The reverse order would delete the only reference to the + vault account (``connection:``) before confirming the secret was + actually cleared, permanently stranding a live bot token in the OS + vault if the vault delete then failed. + """ + connection = await self.get(session, connection_id) + if connection is None: + raise RemoteConnectionNotFoundError( + f"Remote connection {connection_id} does not exist." + ) + + store = self._store(connection_id) + try: + store.delete() + except CredentialStoreError as exc: + raise RemoteCredentialError(str(exc)) from exc + + await session.delete(connection) + await session.commit() + + +def _delete_best_effort(store: CredentialStoreProtocol) -> None: + try: + store.delete() + except CredentialStoreError: + pass + + +def _save_best_effort(store: CredentialStoreProtocol, token: str) -> None: + try: + store.save(token) + except CredentialStoreError: + pass diff --git a/app/remote/contracts.py b/app/remote/contracts.py new file mode 100644 index 00000000..f86cac81 --- /dev/null +++ b/app/remote/contracts.py @@ -0,0 +1,229 @@ +"""Provider-neutral value types and protocols for the remote-access seam. + +Every value here is connection-aware (AC-4): pairings, credential keys, +inbound source keys, callback tokens, queue items, and runtime status all +carry a ``connection_id`` so a later multi-connection release does not need +to rekey anything. Nothing in this module imports an adapter-specific +(Telegram) payload type — that translation happens only inside +``app/remote/telegram/`` and never escapes it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import StrEnum +from typing import Protocol +from uuid import UUID + + +class RemoteAdapterKind(StrEnum): + """Bounded set of supported remote transports. Telegram is the only one + shipped in v1; the enum exists so a later adapter needs no schema + migration, only a new member.""" + + TELEGRAM = "telegram" + + +class RemoteConnectionState(StrEnum): + """Runtime lifecycle of one connection's adapter, per the Observability + contract. Distinct from :class:`RemoteErrorClass`, which names the last + *safe* error independently of the current state.""" + + DISABLED = "disabled" + STARTING = "starting" + PAIRING = "pairing" + POLLING = "polling" + BACKOFF = "backoff" + RATE_LIMITED = "rate_limited" + USED_ELSEWHERE = "used_elsewhere" + INVALID_TOKEN = "invalid_token" + PHONE_UNREACHABLE = "phone_unreachable" + CREDENTIAL_MISSING = "credential_missing" + ERROR = "error" + + +class RemoteErrorClass(StrEnum): + """Safe, bounded classification of the last adapter error. Never a raw + provider error string — those can carry tokens or update payloads.""" + + NONE = "none" + INVALID_TOKEN = "invalid_token" + USED_ELSEWHERE = "used_elsewhere" + RATE_LIMITED = "rate_limited" + TRANSPORT = "transport" + PHONE_UNREACHABLE = "phone_unreachable" + CREDENTIAL_MISSING = "credential_missing" + UNKNOWN = "unknown" + + +class RemoteInboundActionKind(StrEnum): + """What kind of update produced a :class:`RemoteInboundAction`.""" + + TEXT = "text" + CALLBACK = "callback" + PAIRING_START = "pairing_start" + + +class RemoteOutboundPriority(StrEnum): + """Which bounded, non-blocking queue an outbound message belongs to. + + ``HIGH`` carries gates and terminal completion notices; losing one is a + delivery failure the status surface must report. ``INFORMATIONAL`` + carries lifecycle/admission chatter where the oldest item is dropped + under pressure instead of blocking the producing turn. + """ + + HIGH = "high" + INFORMATIONAL = "informational" + + +@dataclass(frozen=True) +class ValidatedRemoteIdentity: + """The immutable bot identity returned by a successful ``getMe``-style + validation, before any persistence happens.""" + + adapter: RemoteAdapterKind + principal_id: str + username: str + + +class RemoteAdapterValidationError(Exception): + """Raised by :meth:`RemoteAdapterFactory.validate_token` when the token + does not resolve to a usable bot identity. Never carries the token.""" + + +class RemoteAdapterFactory(Protocol): + """Validates a credential against the adapter's identity endpoint. + + Kept as a narrow protocol so ``app/remote`` service-level code (and its + tests) never import or call a real Telegram client — a later task + supplies the concrete implementation. + """ + + async def validate_token( + self, adapter: RemoteAdapterKind, token: str + ) -> ValidatedRemoteIdentity: ... + + +@dataclass(frozen=True) +class RemotePrincipal: + """One paired account. ``principal_id`` authorizes actions; + ``destination_id`` addresses replies. They are stored and compared + separately even though a given adapter may report matching values for a + private chat (spec: "Connect the phone").""" + + connection_id: UUID + principal_id: str + destination_id: str + #: Untrusted, adapter-reported decoration (display name/username). Never + #: used for authorization. + display: str = "" + + +@dataclass(frozen=True) +class RemoteInboundAction: + """One normalized inbound update, already classified by the adapter. + + ``source_key`` is the inbound idempotency key (adapter + connection + + provider update ID, AC-16) so redelivery within a process or a replay + after a crash between admission and offset acknowledgement produces at + most one persisted message. + """ + + connection_id: UUID + kind: RemoteInboundActionKind + principal: RemotePrincipal + source_key: str + text: str | None = None + callback_token: str | None = None + pairing_token: str | None = None + + +@dataclass(frozen=True) +class RemoteButton: + """One inline button. ``token`` is an opaque, connection-owned, + principal-bound capability reference — never a raw session ID, database + ID, path, command, plan, or credential (AC-25).""" + + text: str + token: str + + +@dataclass(frozen=True) +class RemoteOutboundMessage: + """One outbound message bound for a paired destination. + + ``text`` is sent with Telegram HTML parse mode (AC-24, revised): callers + are expected to have rendered it through ``app/remote/formatting.py``, + which HTML-escapes every non-static field before interpolation, so + model/agent content can never manufacture links, mentions, or + formatting. ``correlation_id`` lets the owning turn's lifecycle message + be found again for editing instead of appending progress (AC-22). + """ + + connection_id: UUID + destination_id: str + text: str + buttons: tuple[RemoteButton, ...] = field(default_factory=tuple) + correlation_id: str | None = None + priority: RemoteOutboundPriority = RemoteOutboundPriority.INFORMATIONAL + + +@dataclass(frozen=True) +class RemoteAdapterStatus: + """Safe, diagnosable runtime status for one connection (AC-34). + + Never includes raw provider responses, credentials, or tokens. + """ + + connection_id: UUID + state: RemoteConnectionState + last_error_class: RemoteErrorClass = RemoteErrorClass.NONE + last_successful_poll_at: datetime | None = None + paired: bool = False + phone_reachable: bool | None = None + informational_drop_count: int = 0 + high_priority_drop_count: int = 0 + + +class RemoteAdapter(Protocol): + """Lifecycle and delivery surface an adapter (e.g. Telegram) implements. + + An adapter never decides EvoFlux authorization — it only starts/stops + polling, sends/edits messages, and acknowledges callbacks. The owning + ``RemoteService`` (a later task) is the sole authority for pairing, + principal checks, and gate resolution. + """ + + async def start(self) -> None: ... + + async def stop(self) -> None: ... + + async def send(self, message: RemoteOutboundMessage) -> None: ... + + async def edit(self, message: RemoteOutboundMessage) -> None: ... + + async def answer_callback(self, callback_token: str) -> None: ... + + async def indicate_typing(self, destination_id: str) -> None: ... + + def status(self) -> RemoteAdapterStatus: ... + + +__all__ = [ + "RemoteAdapter", + "RemoteAdapterFactory", + "RemoteAdapterKind", + "RemoteAdapterStatus", + "RemoteAdapterValidationError", + "RemoteButton", + "RemoteConnectionState", + "RemoteErrorClass", + "RemoteInboundAction", + "RemoteInboundActionKind", + "RemoteOutboundMessage", + "RemoteOutboundPriority", + "RemotePrincipal", + "ValidatedRemoteIdentity", +] diff --git a/app/remote/control.py b/app/remote/control.py new file mode 100644 index 00000000..2428a462 --- /dev/null +++ b/app/remote/control.py @@ -0,0 +1,294 @@ +"""Mode/model/lead-agent/health/diff control for the phone's /settings, +/health, and /changes commands. + +Each write function replicates the exact persistence sequence its HTTP +route sibling already uses (app/api/routes/team/chat.py's +set_session_permission_mode/update_team_session_lead, +app/api/routes/team/webbridge.py's update_browser_session_model), as a +plain async function app/remote/actions.py can call directly — remote +and desktop must stay behaviorally identical, but app/remote/ never +depends on app/api/routes/* except two narrow, deliberate departures: + +- list_model_ids/set_model call app.api.routes.agents.get_registry — the + model catalog has no service-layer equivalent. +- get_health_diagnostics/get_file_diff call + app.api.routes.health.health_diagnostics and + app.api.routes.team.git.get_diff_view directly — each already carries + nontrivial, security-sensitive logic (health's ~250 lines of db/ + provider/team/MCP/disk checks; diff-view's path-traversal guard and + staged/unstaged/untracked detection) with no service-layer equivalent + either. Reimplementing either in app/remote/ would risk silently + diverging from the desktop's own behavior, or worse, subtly + reintroducing a path-traversal bug. Both are called with explicit + arguments, never relying on their Depends(...) defaults, which are + FastAPI dependency-injection sentinels outside a real request. +- count_configured_providers calls app.api.routes.settings.list_providers + — the same static-credential/daemon-reachability aggregation the + desktop Settings screen reads, with no service-layer equivalent. + Providers stay read-only from the phone (AC-53): this module never + imports any of that file's write-capable functions (save_provider, + save_provider_visible_models, test_provider, list_provider_models, + delete_provider), enforced by a standing inspection test rather than + by convention (tests/remote/test_ac53_providers_read_only.py). + +bypass is deliberately excluded from ALLOWED_REMOTE_MODES, checked by +name (not by list length or position) before anything else runs — a +phone that could enable bypass could silently disable every approval +prompt an operator relies on (AC-48). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, cast +from uuid import UUID + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.chat import ChatSession + +__all__ = [ + "ALLOWED_REMOTE_MODES", + "ControlResult", + "set_permission_mode", + "set_lead_agent", + "list_lead_names", + "set_model", + "list_model_ids", + "get_health_diagnostics", + "get_file_diff", + "ALLOWED_RESPONSE_MODES", + "set_response_mode", + "count_configured_providers", +] + +#: Every permission mode this phone may set — bypass excluded on purpose. +ALLOWED_REMOTE_MODES: tuple[str, ...] = ("ask", "accept-edits", "plan", "auto") + +ControlStatus = Literal["ok", "invalid", "not_found", "conflict"] + + +@dataclass(frozen=True) +class ControlResult: + """A bounded, adapter-neutral outcome for one control write.""" + + status: ControlStatus + detail: str = "" + + +async def set_permission_mode( + db: AsyncSession, session_id: str, mode: str +) -> ControlResult: + if mode not in ALLOWED_REMOTE_MODES: + return ControlResult(status="invalid", detail=mode) + + try: + session_uuid = UUID(session_id) + except ValueError: + return ControlResult(status="not_found") + session = await db.get(ChatSession, session_uuid) + if session is None: + return ControlResult(status="not_found") + + session.permission_mode = mode + session_mode = session.mode + session_workspace = session.workspace + db.add(session) + await db.commit() + + from app.services import team_manager + + team_obj = team_manager.current_team_for_session(session_id) + if team_obj is None and session_mode == "coding" and session_workspace: + team_obj = team_manager.current_coding_team_for_session( + session_workspace, session_id + ) + if team_obj is not None: + team_obj.permission_mode = mode + + from app.agent.permission import Mode, get_services_for_stream + + for service in get_services_for_stream(session_id): + service.set_mode(cast(Mode, mode)) + + return ControlResult(status="ok") + + +async def list_lead_names(app_mode: str, *, limit: int = 5) -> list[str]: + """A short, stable-order list of configured lead-agent names for + *app_mode* ("work"/"coding") — bounded for a phone's button row, same + pattern already used for workflow/project menu items in actions.py.""" + from app.services import team_manager + + try: + _default_lead, rosters = team_manager.configured_lead_rosters(app_mode) + except ValueError: + return [] + return [lead.name for lead, _path, _members in rosters][:limit] + + +async def set_lead_agent( + db: AsyncSession, session_id: str, lead_name: str +) -> ControlResult: + from app.models.chat import normalize_mode + from app.services import memory_stream_store as stream_store + from app.services import team_manager + + try: + session_uuid = UUID(session_id) + except ValueError: + return ControlResult(status="not_found") + session = await db.get(ChatSession, session_uuid) + if session is None: + return ControlResult(status="not_found") + if session.parent_session_id is not None: + return ControlResult(status="not_found") + + live_team = team_manager.find_team_for_session(session_id) + if session_id in stream_store.running_session_ids() or ( + live_team is not None + and any(member.state == "working" for member in live_team.all_members) + ): + return ControlResult( + status="conflict", + detail="Finish or stop the active task before changing lead.", + ) + + app_mode = normalize_mode(session.mode) + try: + selected = team_manager.resolve_configured_lead(app_mode, lead_name) + except ValueError as exc: + return ControlResult(status="invalid", detail=str(exc)) + + if session.agent_name != selected: + session.agent_name = selected + db.add(session) + await db.commit() + await db.refresh(session) + await team_manager.stop_sessions({session_id}) + + return ControlResult(status="ok") + + +async def list_model_ids(app_mode: str | None = None, *, limit: int = 5) -> list[str]: + """A short, catalog-order list of registered model ids — bounded for a + phone's button row. There is no curated "recommended models" concept + in the registry today (every provider-visible model is returned + unbounded), so this is a simple positional cap, not a ranking. + + One deliberate departure from this module's own "service layer only" + rule: get_registry lives in app.api.routes.agents (a route module), + not a service — there is no equivalent service-layer function to call + instead. It is a plain async function with no Request/Depends-injected + state (its Query(...) annotations are OpenAPI metadata on otherwise + plain defaults), so calling it directly here is safe. + """ + from app.api.routes.agents import get_registry + + mode_arg = cast("Literal['work', 'coding'] | None", app_mode) + registry = await get_registry(mode=mode_arg) + return [entry.id for entry in registry.models][:limit] + + +async def set_model( + db: AsyncSession, + session_id: str, + model_id: str, + *, + thinking_level: str | None = None, +) -> ControlResult: + from app.agent.providers.thinking import accepts_thinking_level + from app.api.routes.agents import get_registry + + try: + session_uuid = UUID(session_id) + except ValueError: + return ControlResult(status="not_found") + session = await db.get(ChatSession, session_uuid) + if session is None: + return ControlResult(status="not_found") + + registry = await get_registry() + selected = next((entry for entry in registry.models if entry.id == model_id), None) + if selected is None: + return ControlResult(status="invalid", detail=model_id) + if thinking_level is not None and not accepts_thinking_level( + model_id, thinking_level + ): + return ControlResult(status="invalid", detail=thinking_level) + + session.model = model_id + session.thinking_level = thinking_level + db.add(session) + await db.commit() + + return ControlResult(status="ok") + + +async def get_health_diagnostics() -> dict: + """The same active health check the desktop UI's Diagnostics screen + uses, called directly rather than duplicated — see this module's + docstring for why app/remote/ makes an exception to "service layer + only" for this one function. Uses read_session_factory (not + async_session_factory) since this mirrors a GET route — app.core.db's + own get_session dependency picks the same read lane for GET requests.""" + from app.api.routes.health import health_diagnostics + from app.core.db import read_session_factory + + async with read_session_factory() as db: + return await health_diagnostics(session=db) + + +async def get_file_diff(workspace: str, path: str) -> str: + """One file's unified diff (staged, unstaged, or untracked-as-additions) + — delegates to the same route function the desktop UI's diff viewer + uses, which already carries the path-traversal and staged/unstaged/ + untracked detection logic; reimplementing that here would risk + subtly reintroducing a path-traversal bug.""" + from app.api.routes.team.git import get_diff_view + + result = await get_diff_view(workspace=workspace, path=path) + return result.get("diff", "") + + +#: The two response modes a phone may choose between (AC-55). Unlike +#: ALLOWED_REMOTE_MODES (permission modes, chat-session-scoped), this +#: preference lives on RemotePairing — one phone, one pairing, one +#: notion of how chatty its own turns should be. +ALLOWED_RESPONSE_MODES: tuple[str, ...] = ("summary", "live") + + +async def set_response_mode( + db: AsyncSession, pairing_id: str, mode: str +) -> ControlResult: + if mode not in ALLOWED_RESPONSE_MODES: + return ControlResult(status="invalid", detail=mode) + + from app.models.remote import RemotePairing + + try: + pairing_uuid = UUID(pairing_id) + except ValueError: + return ControlResult(status="not_found") + pairing = await db.get(RemotePairing, pairing_uuid) + if pairing is None: + return ControlResult(status="not_found") + + pairing.response_mode = mode + db.add(pairing) + await db.commit() + + return ControlResult(status="ok") + + +async def count_configured_providers() -> int: + """How many catalog providers currently have usable credentials — + the one number `/settings` shows for AC-53. Calls the same route + function the desktop Settings screen uses (see this module's + docstring for why app/remote/ makes this exception) rather than + reimplementing the static-credential/daemon-reachability checks + GET /api/settings/providers already performs.""" + from app.api.routes.settings import list_providers + + result = await list_providers() + return sum(1 for provider in result.providers if provider.is_configured) diff --git a/app/remote/edit_budget.py b/app/remote/edit_budget.py new file mode 100644 index 00000000..ec4d0589 --- /dev/null +++ b/app/remote/edit_budget.py @@ -0,0 +1,68 @@ +"""Shared per-connection edit budget for live-mode status-card updates +(AC-57). + +Live-mode edits are coalesced to at most one per ``LIVE_EDIT_INTERVAL`` +per turn, skipped entirely when the rendered text is unchanged, and drawn +from one shared allowance per connection across every concurrently live +turn — so contention under ``notify_scope=all`` degrades cadence rather +than producing a burst of 429s. Pure bookkeeping: callers pass their own +``now`` in tests (``time.monotonic()`` by default) rather than sleeping. + +A turn's final done/error card is never checked against this budget — see +outbound.py's ``_finalize_turn``, which always enqueues its edit directly. +Liveliness is best-effort; completion is not. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field + +__all__ = ["EditBudget", "LIVE_EDIT_INTERVAL"] + +LIVE_EDIT_INTERVAL = 2.0 + + +@dataclass +class EditBudget: + #: connection_id -> monotonic time that connection may next spend an edit. + _next_allowed_at: dict[str, float] = field(default_factory=dict) + #: key (one per live turn) -> last text actually delivered for it. + _last_text: dict[str, str] = field(default_factory=dict) + + def should_edit( + self, *, connection_id: str, key: str, text: str, now: float | None = None + ) -> bool: + """Whether a live-mode edit for *key* should be sent now: the text + actually changed since the last delivered edit for this key, and + this connection's shared budget currently allows an edit.""" + if self._last_text.get(key) == text: + return False + moment = time.monotonic() if now is None else now + return moment >= self._next_allowed_at.get(connection_id, 0.0) + + def record_edit( + self, *, connection_id: str, key: str, text: str, now: float | None = None + ) -> None: + """Call once an edit for *key* has actually been enqueued.""" + moment = time.monotonic() if now is None else now + self._next_allowed_at[connection_id] = moment + LIVE_EDIT_INTERVAL + self._last_text[key] = text + + def note_rate_limited( + self, *, connection_id: str, retry_after: float, now: float | None = None + ) -> None: + """Degrade this connection's cadence after a Telegram 429 — + pushes the next allowed edit out by *retry_after* seconds, never + shorter than the normal cadence would already have produced.""" + moment = time.monotonic() if now is None else now + candidate = moment + max(retry_after, LIVE_EDIT_INTERVAL) + self._next_allowed_at[connection_id] = max( + self._next_allowed_at.get(connection_id, 0.0), candidate + ) + + def discard(self, key: str) -> None: + """Forget a finished turn's last-delivered text. The connection's + shared cooldown is untouched — it belongs to the connection, not + to any one turn.""" + self._last_text.pop(key, None) diff --git a/app/remote/formatting.py b/app/remote/formatting.py new file mode 100644 index 00000000..caac0e11 --- /dev/null +++ b/app/remote/formatting.py @@ -0,0 +1,614 @@ +from __future__ import annotations + +import html +import re +from collections.abc import Mapping, Sequence + +from app.remote.contracts import RemoteButton + +__all__ = [ + "derive_card_heading", + "escape", + "format_elapsed", + "markdown_to_telegram_html", + "render_status_card", + "render_done_card", + "render_error_card", + "render_gate_card", + "render_settings_card", + "render_project_picker", + "render_prompt_suggestions", + "render_permission_card", + "render_permission_resolved_card", + "render_health_card", + "render_changes_card", + "render_live_status_card", + "render_onboarding_card", +] + +_CARD_HEADING_MAX_LENGTH = 60 + +_STATUS_ICON = {"accepted": "\U0001f527", "queued": "⏳", "pending": "⏳"} + +_SEVERITY_ICON = { + "high": "\U0001f534", + "elevated": "\U0001f7e0", + "normal": "\U0001f527", +} +_SEVERITY_LABEL = { + "high": "Dangerous command", + "elevated": "Command", + "normal": "Permission requested", +} +_RESOLUTION_ICON = {"once": "✅", "always": "\U0001f512", "reject": "❌"} +_RESOLUTION_LABEL = { + "once": "Allowed once", + "always": "Allowed for session", + "reject": "Rejected", +} +_HEALTH_ICON = {"ok": "✅", "warn": "⚠️", "fail": "❌"} + + +def escape(value: str) -> str: + """The only place raw text becomes safe to place inside an HTML tag.""" + return html.escape(value, quote=False) + + +def _format_token_count(n: int) -> str: + """Format a token count as a human-readable short string. + + Examples: 128000 → "128K", 1000000 → "1M", 200000 → "200K". + """ + if n >= 1_000_000: + return f"{n / 1_000_000:.0f}M" + if n >= 1_000: + return f"{n // 1_000:,}K" + return str(n) + + +# ── Markdown → Telegram HTML ───────────────────────────────────────────────── +# +# Telegram supports a strict HTML subset: , , , , ,
,
+# , , 
. No Markdown parse mode is used; +# the adapter always sends ``parse_mode="HTML"``. +# +# The converter runs AFTER HTML-escaping, so literal ``<`` / ``>`` / ``&`` in +# the source Markdown are already ``<`` / ``>`` / ``&`` and will +# not collide with the HTML tags we inject. + +# Placeholder used to protect pre-escaped code spans from later regex passes. +_CODE_SLOT = "\x00CODE{}\x00" +_CODE_SLOT_RE = re.compile(r"\x00CODE(\d+)\x00") + + +def markdown_to_telegram_html(text: str) -> str: + """Convert a Markdown string to Telegram-safe HTML. + + The input is first HTML-escaped so literal ``<`` / ``>`` are safe. + Markdown constructs are then converted to the Telegram HTML subset. + Code blocks and inline code are protected from inner conversions. + """ + if not text: + return "" + + # 1. HTML-escape everything first. + out = html.escape(text, quote=False) + + # 2. Protect fenced code blocks: ```lang\n...\n``` →
...
+ code_slots: list[str] = [] + + def _code_repl(m: re.Match) -> str: + lang = m.group(1) or "" + body = m.group(2) + if lang: + tag = f'
{body}
' + else: + tag = f"
{body}
" + idx = len(code_slots) + code_slots.append(tag) + return _CODE_SLOT.format(idx) + + out = re.sub( + r"```(\w*)\n(.*?)```", + _code_repl, + out, + flags=re.DOTALL, + ) + + # 3. Protect inline code: `...` → ... + def _inline_repl(m: re.Match) -> str: + idx = len(code_slots) + code_slots.append(f"{m.group(1)}") + return _CODE_SLOT.format(idx) + + out = re.sub(r"`([^`\n]+)`", _inline_repl, out) + + # 4. Headings: # / ## / ### → bold line + out = re.sub(r"^#{1,6}\s+(.+)$", r"\1", out, flags=re.MULTILINE) + + # 5. Bold + italic: ***text*** → text + out = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", out) + + # 6. Bold: **text** or __text__ → text + out = re.sub(r"\*\*(.+?)\*\*", r"\1", out) + out = re.sub(r"__(.+?)__", r"\1", out) + + # 7. Italic: *text* or _text_ → text + # Avoid matching * inside words (e.g. file_path*) by requiring a + # word boundary or whitespace before the opening *. + out = re.sub(r"(?\1
", out) + out = re.sub(r"(?\1", out) + + # 8. Strikethrough: ~~text~~ → text + out = re.sub(r"~~(.+?)~~", r"\1", out) + + # 9. Links: [text](url) →
text + out = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + r'\1', + out, + ) + + # 10. Unordered list: - item or * item → • item + out = re.sub(r"^[\-\*]\s+", "• ", out, flags=re.MULTILINE) + + # 11. Restore protected code slots. + out = _CODE_SLOT_RE.sub(lambda m: code_slots[int(m.group(1))], out) + + return out + + +def format_elapsed(seconds: float) -> str: + total = max(0, int(seconds)) + minutes, secs = divmod(total, 60) + return f"{minutes}m {secs}s" if minutes else f"{secs}s" + + +def derive_card_heading( + user_message: str | None, + fallback_title: str = "Task", +) -> str: + """Derive a concise, meaningful card heading. + + Prefers a truncated version of the user's original message (which is + always concrete and contextual) over a session title that is often + generic ("Task", "New task") or an LLM-generated title that may not + have been generated yet. + """ + if user_message and user_message.strip(): + text = user_message.strip().replace("\n", " ") + if len(text) > _CARD_HEADING_MAX_LENGTH: + return text[: _CARD_HEADING_MAX_LENGTH - 1].rstrip() + "\u2026" + return text + return fallback_title + + +def render_status_card( + *, title: str, status: str +) -> tuple[str, tuple[RemoteButton, ...]]: + icon = _STATUS_ICON.get(status, "\U0001f527") + text = f"{icon} {escape(title)}\n{escape(status)}" + return text, () + + +def render_live_status_card( + *, + title: str, + elapsed_seconds: float, + activity_lines: Sequence[str], + model: str | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, + cost_usd: float | None = None, +) -> tuple[str, tuple[RemoteButton, ...]]: + """The single status card a live-mode turn edits in place (AC-56). No + buttons — like ``render_status_card``, this card is never actionable; + when the turn ends this same message becomes the done/error card.""" + header = f"\U0001f527 {escape(title)} · {format_elapsed(elapsed_seconds)}" + parts = [header] + # Token / model footer — only shown once the first usage data arrives. + meta_parts: list[str] = [] + if model: + meta_parts.append(escape(model)) + if input_tokens is not None or output_tokens is not None: + meta_parts.append(f"{input_tokens or 0:,} \u2192 {output_tokens or 0:,} tok") + if cost_usd is not None and cost_usd > 0: + meta_parts.append(f"${cost_usd:.4f}") + if meta_parts: + parts.append("\U0001f4ca " + " \u00b7 ".join(meta_parts)) + if activity_lines: + parts.append("\n".join(activity_lines)) + return "\n\n".join(parts), () + + +def render_done_card( + *, + title: str, + elapsed_seconds: float, + response_text: str | None = None, + summary_lines: Sequence[str], + tool_call_count: int, + diff_token: str | None, + toollog_token: str | None, + model: str | None = None, + context_window: int | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, + cached_tokens: int | None = None, + reasoning_tokens: int | None = None, + cost_usd: float | None = None, +) -> tuple[str, tuple[RemoteButton, ...]]: + header = f"✅ {escape(title)} · {format_elapsed(elapsed_seconds)}" + lines = [escape(line) for line in summary_lines] + parts = [header] + if response_text and response_text.strip(): + parts.append(markdown_to_telegram_html(response_text.strip())) + if lines: + parts.append("\n".join(lines)) + parts.append(f"{tool_call_count} tool calls") + + # ── Usage footer ───────────────────────────────────────────────── + # Build a compact multi-line usage block instead of a single dense + # dot-separated line, giving each dimension room to breathe. + usage_lines: list[str] = [] + total_tokens = (input_tokens or 0) + (output_tokens or 0) + + # Line 1: model name + if model: + usage_lines.append(escape(model)) + + # Line 2: token summary — "📊 2.4k → 1.1k" with cache/reasoning callouts + if total_tokens > 0: + tok_parts: list[str] = [] + tok_parts.append(f"↓{_format_token_count(input_tokens or 0)}") + tok_parts.append(f"↑{_format_token_count(output_tokens or 0)}") + tok_line = "\U0001f4ca " + " ".join(tok_parts) + + sub_parts: list[str] = [] + if cached_tokens and cached_tokens > 0 and (input_tokens or 0) > 0: + hit_pct = cached_tokens / (input_tokens or 1) * 100 + sub_parts.append( + f"cache {_format_token_count(cached_tokens)} ({hit_pct:.0f}%)" + ) + if reasoning_tokens and reasoning_tokens > 0: + sub_parts.append(f"reasoning {_format_token_count(reasoning_tokens)}") + if sub_parts: + tok_line += " · " + " · ".join(sub_parts) + usage_lines.append(tok_line) + + # Line 3: context window usage bar + if context_window and context_window > 0 and (input_tokens or 0) > 0: + used_pct = min((input_tokens or 0) / context_window * 100, 100) + filled = round(used_pct / 5) # 20 bars = 100% + bar = "\u2588" * filled + "\u2591" * (20 - filled) + usage_lines.append( + f"{_format_token_count(context_window)} ctx [{bar}] {used_pct:.0f}%" + ) + + # Line 4: cost — show component breakdown when available + if cost_usd is not None and cost_usd > 0: + usage_lines.append(f"\U0001f4b0 ${cost_usd:.4f}") + + if usage_lines: + parts.append("\n".join(usage_lines)) + text = "\n\n".join(parts) + buttons: list[RemoteButton] = [] + if diff_token: + buttons.append(RemoteButton(text="\U0001f4c4 Full diff", token=diff_token)) + if toollog_token: + buttons.append(RemoteButton(text="\U0001f9fe Tool log", token=toollog_token)) + return text, tuple(buttons) + + +def _sanitize_error_message(message: str) -> str: + """Make common provider errors more user-friendly. + + Strips internal provider details and HTTP status codes that confuse + non-technical users while preserving enough to diagnose the issue. + """ + # Model unavailable errors — show a clean message. + if ( + "model is unavailable" in message.lower() + or "rejected the request" in message.lower() + ): + # Extract model name if present (e.g. "opencode:deepseek-v4-flash-free") + import re + + model_match = re.search(r"(\w+:[\w-]+)\s+rejected", message) + if model_match: + return f"Model {escape(model_match.group(1))} is currently unavailable. Check your provider dashboard or try a different model." + return "The selected model is currently unavailable. Check your provider dashboard or try a different model." + return escape(message) + + +def render_error_card( + *, title: str, message: str, toollog_token: str | None +) -> tuple[str, tuple[RemoteButton, ...]]: + friendly = _sanitize_error_message(message) + text = f"❌ {escape(title)}\n\nError: {friendly}" + buttons = ( + (RemoteButton(text="\U0001f9fe Tool log", token=toollog_token),) + if toollog_token + else () + ) + return text, buttons + + +def render_permission_card( + *, + tool: str, + command: str, + severity: str, + agent: str, + always_glob: str | None, + always_token: str | None, + once_token: str, + reject_token: str, +) -> tuple[str, tuple[RemoteButton, ...]]: + icon = _SEVERITY_ICON.get(severity, "\U0001f527") + label = _SEVERITY_LABEL.get(severity, "Permission requested") + text = ( + f"{icon} {escape(label)}\n" + f"
{escape(command)}
\n" + f"{escape(tool)} · {escape(agent)}" + ) + buttons = [RemoteButton(text="Allow once", token=once_token)] + if always_token and always_glob: + buttons.append( + RemoteButton( + text=f"\U0001f512 Allow for session — {escape(always_glob)}", + token=always_token, + ) + ) + buttons.append(RemoteButton(text="Reject", token=reject_token)) + return text, tuple(buttons) + + +def render_permission_resolved_card( + *, command: str, resolution: str +) -> tuple[str, tuple[RemoteButton, ...]]: + icon = _RESOLUTION_ICON.get(resolution, "✅") + label = _RESOLUTION_LABEL.get(resolution, "Resolved") + text = f"{icon} {escape(label)}\n
{escape(command)}
" + return text, () + + +def render_gate_card( + *, title: str, body: str, actions: Sequence[tuple[str, str]] +) -> tuple[str, tuple[RemoteButton, ...]]: + text = f"\U0001f510 {escape(title)}\n{escape(body)}" + buttons = tuple( + RemoteButton(text=escape(label), token=token) for token, label in actions + ) + return text, buttons + + +def render_settings_card( + *, + connection_label: str, + model: str, + permission_mode: str, + agent_name: str, + response_mode: str, + response_mode_tokens: Mapping[str, str], + mode_tokens: Mapping[str, str], + agent_tokens: Mapping[str, str], + model_tokens: Mapping[str, str], + configured_provider_count: int | None = None, + redaction_policy: str | None = None, + notify_scope: str | None = None, + redaction_tokens: Mapping[str, str] = {}, + notify_scope_tokens: Mapping[str, str] = {}, +) -> tuple[str, tuple[RemoteButton, ...]]: + lines = [ + "⚙️ Settings", + "", + f"Connection\n{escape(connection_label)}", + "", + f"Mode\n{escape(permission_mode)}", + "", + f"Model\n{escape(model)}", + "", + f"Lead agent\n{escape(agent_name)}", + "", + f"Responses\n{escape(response_mode)}", + ] + if configured_provider_count is not None: + lines += [ + "", + f"Providers\n{configured_provider_count} configured", + ] + if notify_scope is not None: + lines += ["", f"Notifications\n{escape(notify_scope)}"] + if redaction_policy is not None: + lines += [ + "", + f"Outbound redaction\n{escape(redaction_policy)}", + ] + text = "\n".join(lines) + + # bypass is excluded here too (not just at the control.py write path, + # see ALLOWED_REMOTE_MODES) — a bug in one boundary alone must never be + # the only thing standing between a phone and bypass mode (AC-48). + buttons: list[RemoteButton] = [ + RemoteButton(text=f"Mode: {escape(name)}", token=token) + for name, token in mode_tokens.items() + if name != "bypass" + ] + buttons += [ + RemoteButton(text=f"Agent: {escape(name)}", token=token) + for name, token in agent_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Responses: {escape(name)}", token=token) + for name, token in response_mode_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Model: {escape(name)}", token=token) + for name, token in model_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Redaction: {escape(name)}", token=token) + for name, token in redaction_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Notify: {escape(name)}", token=token) + for name, token in notify_scope_tokens.items() + ] + return text, tuple(buttons) + + +def render_onboarding_card( + *, + label: str, + default_permission_mode: str, + setup_token: str, + health_token: str, + start_token: str, +) -> tuple[str, tuple[RemoteButton, ...]]: + """The first-run card sent right after a successful pairing (AC-54) — + a starting point rather than a dead end. The mode line is stated + because it is the single most consequential default the operator + should know about at pairing time: "auto" means no approval prompts + will ever reach this phone.""" + mode_line = f"Mode is {escape(default_permission_mode)}" + if default_permission_mode == "auto": + mode_line += " — the agent won't ask before running commands." + else: + mode_line += "." + text = ( + f'✅ Paired! This phone ("{escape(label)}") is now connected ' + f"to EvoFlux.\n\n{mode_line}" + ) + buttons = ( + RemoteButton(text="⚙️ Set up", token=setup_token), + RemoteButton(text="\U0001fa7a Health check", token=health_token), + RemoteButton(text="\U0001f4ac Just start working", token=start_token), + ) + return text, buttons + + +def render_project_picker( + *, projects: Sequence[tuple[str, str]] +) -> tuple[str, tuple[RemoteButton, ...]]: + text = "Which project?" + buttons = tuple( + RemoteButton(text=f"\U0001f4c1 {escape(name)}", token=token) + for token, name in projects + ) + return text, buttons + + +def render_prompt_suggestions( + *, + project_name: str, + context_line: str, + suggestions: Sequence[tuple[str, str]], + continue_token: str | None, +) -> tuple[str, tuple[RemoteButton, ...]]: + text = ( + f"\U0001f4c1 {escape(project_name)}\n" + f"{escape(context_line)}\n\n" + "Try one, or just type your own:" + ) + buttons: list[RemoteButton] = [] + if continue_token: + buttons.append( + RemoteButton(text="▶ Continue last session", token=continue_token) + ) + buttons += [ + RemoteButton(text=escape(label), token=token) for token, label in suggestions + ] + return text, tuple(buttons) + + +def render_health_card(checks: Sequence[Mapping[str, object]]) -> str: + lines = ["\U0001fa7a Health", ""] + for check in checks: + icon = _HEALTH_ICON.get(str(check.get("status", "")), "❓") + label = escape(str(check.get("label", ""))) + detail = escape(str(check.get("detail", ""))) + lines.append(f"{icon} {label}\n{detail}") + return "\n\n".join(lines) + + +def render_changes_card( + *, + title: str, + files: Sequence[tuple[str, str, int | None, int | None]], + additions: int, + deletions: int, + file_tokens: Mapping[str, str], +) -> tuple[str, tuple[RemoteButton, ...]]: + lines = [ + f"\U0001f4dd Changes — {escape(title)}", + "", + f"+{additions} -{deletions} across {len(files)} file(s)", + "", + ] + for path, status, file_additions, file_deletions in files: + counts = "" + if file_additions is not None or file_deletions is not None: + counts = f" (+{file_additions or 0} -{file_deletions or 0})" + lines.append(f"\U0001f4c4 {escape(path)}{counts} — {escape(status)}") + text = "\n".join(lines) + + buttons = tuple( + RemoteButton(text=f"View: {escape(path)}", token=token) + for path, token in file_tokens.items() + ) + return text, buttons + + +def render_session_list_card( + sessions: Sequence[Mapping[str, str]], +) -> tuple[str, tuple[RemoteButton, ...]]: + """Render a session-history list card. + + Each item in *sessions* must have ``title``, ``subtitle``, and ``token``. + Returns text with a header and up to 5 inline buttons. + """ + parts = ["\U0001f4cb Recent sessions"] + buttons: list[RemoteButton] = [] + for item in sessions: + label = escape(item.get("title", "Untitled")) + buttons.append(RemoteButton(text=label, token=item["token"])) + if not buttons: + parts.append("No sessions yet.") + return "\n\n".join(parts), tuple(buttons) + + +def render_session_detail_card( + *, + title: str, + mode: str, + created_at: str, + message_count: int, + tool_call_count: int, + last_user_message: str | None, + last_assistant_message: str | None, + switch_token: str | None, + summarize_token: str | None, +) -> tuple[str, tuple[RemoteButton, ...]]: + """Render a single session's detail card with optional action buttons.""" + parts = [ + f"\U0001f4c4 {escape(title)}", + f"Mode: {escape(mode)} · Created: {escape(created_at)}", + f"{message_count} messages · {tool_call_count} tool calls", + ] + if last_user_message: + truncated = last_user_message[:200] + if len(last_user_message) > 200: + truncated += "\u2026" + parts.append(f"\U0001f464 {escape(truncated)}") + if last_assistant_message: + truncated = last_assistant_message[:200] + if len(last_assistant_message) > 200: + truncated += "\u2026" + parts.append(f"\U0001f916 {markdown_to_telegram_html(truncated)}") + buttons: list[RemoteButton] = [] + if switch_token: + buttons.append(RemoteButton(text="\u2705 Switch here", token=switch_token)) + if summarize_token: + buttons.append(RemoteButton(text="\U0001f4dd Summarize", token=summarize_token)) + return "\n\n".join(parts), tuple(buttons) diff --git a/app/remote/gates.py b/app/remote/gates.py new file mode 100644 index 00000000..1e517fc9 --- /dev/null +++ b/app/remote/gates.py @@ -0,0 +1,546 @@ +"""Remote gate bridge — opaque callback capabilities and gate resolution. + +Translates EvoFlux gate events (permission_asked, question_asked, +plan_approval_requested) into Telegram inline-button cards with opaque +callback tokens, and resolves inbound callbacks back through the active +PermissionService, AskUserService, or PlanModeService registry. + +Design constraints (from spec): +- Callback tokens are opaque, random, connection/principal-bound, and <=64 bytes. +- Internal IDs (session_id, request_id) are never serialized into callback data. +- ``answer_callback`` is called before any gate resolution (AC-26). +- A resolved gate's card is edited into a button-free, decision-stating + form (AC-47) — never left with live buttons after a reply. +- Remote permission replies accept ``once``, ``always``, and ``reject`` + (AC-28, revised) — ``always`` is session-scoped in ``PermissionService`` + (appends a rule to ``session_ruleset``), not a permanent grant. +""" + +from __future__ import annotations + +import asyncio +import secrets +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal +from uuid import UUID + +from loguru import logger + +from app.remote.contracts import ( + RemoteAdapter, + RemoteButton, + RemoteInboundAction, + RemoteOutboundMessage, + RemoteOutboundPriority, +) +from app.remote.formatting import ( + render_permission_card, + render_permission_resolved_card, +) +from app.remote.severity import derive_severity + +if TYPE_CHECKING: + pass + +__all__ = ["RemoteGateBridge"] + +#: Telegram callback data limit. +_MAX_CALLBACK_TOKEN_BYTES = 64 + +#: Capability expiry in seconds (10 minutes). +_CAPABILITY_TTL_SECONDS = 600 + +#: Gate kinds. +GateKind = Literal["permission", "question", "plan"] + + +@dataclass(frozen=True) +class GateCapability: + """One opaque, expiring capability record bound to a gate action.""" + + token: str + connection_id: UUID + principal_id: str + destination_id: str + session_id: str + request_id: str + gate_kind: GateKind + action: str + created_at: float = field(default_factory=time.monotonic) + + +@dataclass +class _PendingGate: + """Tracks one gate's outstanding capabilities and the command text (if + any) needed to render its resolved form later.""" + + request_id: str + session_id: str + gate_kind: GateKind + tokens: list[str] = field(default_factory=list) + command: str = "" + + +class RemoteGateBridge: + """Bridges EvoFlux gate events to Telegram inline buttons and resolves + inbound callbacks through the active service registry. + + Owned by the runtime alongside the projection. The projection calls + :meth:`on_gate` and :meth:`on_reply`; the runtime calls + :meth:`handle_callback` for inbound CALLBACK actions. + """ + + def __init__(self, adapter: RemoteAdapter) -> None: + self._adapter = adapter + self._capabilities: dict[str, GateCapability] = {} + self._pending_by_token: dict[str, str] = {} # token -> request_id + self._pending_gates: dict[str, _PendingGate] = {} # request_id -> gate + + def on_gate( + self, + session_id: str, + event_type: str, + data: dict, + connection_id: UUID, + destination_id: str, + ) -> None: + """Handle a gate event by creating opaque tokens and enqueuing a card. + + Called by the projection's ``_handle_gate``. + """ + request_id = data.get("request_id", "") + if not request_id: + return + + if event_type == "permission_asked": + self._on_permission_asked( + session_id, data, connection_id, destination_id, request_id + ) + return + + gate_kind: GateKind + actions: list[tuple[str, str]] # (action_label, button_text) + + if event_type == "question_asked": + gate_kind = "question" + questions = data.get("questions", []) + if questions: + first_q = questions[0] + text = f"Question: {first_q.get('question', '')}" + options = first_q.get("options", []) + actions = [(opt, opt) for opt in options[:8]] # bound to 8 + else: + text = "Question asked." + actions = [] + elif event_type == "plan_approval_requested": + gate_kind = "plan" + plan_text = data.get("plan", "") + steps = data.get("steps", []) + text = f"Plan ready for review ({len(steps)} steps)." + if plan_text: + text = f"Plan: {plan_text[:200]}" + actions = [("approve", "Approve"), ("reject", "Reject")] + else: + return + + # Create opaque tokens for each action. + buttons: list[RemoteButton] = [] + gate = _PendingGate( + request_id=request_id, + session_id=session_id, + gate_kind=gate_kind, + ) + + for action_value, button_text in actions: + token = self._issue_token( + connection_id=connection_id, + principal_id="", # filled on callback from action + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind=gate_kind, + action=action_value, + ) + buttons.append(RemoteButton(text=button_text, token=token)) + gate.tokens.append(token) + self._pending_by_token[token] = request_id + + self._pending_gates[request_id] = gate + + # Apply redaction and send. + text = _redact_text(text) + msg = RemoteOutboundMessage( + connection_id=connection_id, + destination_id=destination_id, + text=text, + buttons=tuple(buttons), + priority=RemoteOutboundPriority.HIGH, + correlation_id=f"gate:{request_id}", + ) + self._enqueue_send(msg) + + def _on_permission_asked( + self, + session_id: str, + data: dict, + connection_id: UUID, + destination_id: str, + request_id: str, + ) -> None: + """Render and send a decidable permission card (AC-45/AC-46): the + real command and a derived, advisory-only severity, never a tool + name alone.""" + tool = data.get("tool", "unknown") + patterns = data.get("patterns") or [] + command = patterns[0] if patterns else tool + always_patterns = data.get("always_patterns") or [] + always_glob = always_patterns[0] if always_patterns else None + agent_name = data.get("metadata", {}).get("agent", "agent") + severity = derive_severity(tool=tool, command=command) + + gate = _PendingGate( + request_id=request_id, + session_id=session_id, + gate_kind="permission", + command=_redact_text(command), + ) + + once_token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind="permission", + action="once", + ) + gate.tokens.append(once_token) + self._pending_by_token[once_token] = request_id + + always_token: str | None = None + if always_glob: + always_token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind="permission", + action="always", + ) + gate.tokens.append(always_token) + self._pending_by_token[always_token] = request_id + + reject_token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind="permission", + action="reject", + ) + gate.tokens.append(reject_token) + self._pending_by_token[reject_token] = request_id + + self._pending_gates[request_id] = gate + + text, buttons = render_permission_card( + tool=tool, + command=gate.command, + severity=severity, + agent=agent_name, + always_glob=_redact_text(always_glob) if always_glob else None, + always_token=always_token, + once_token=once_token, + reject_token=reject_token, + ) + msg = RemoteOutboundMessage( + connection_id=connection_id, + destination_id=destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + correlation_id=f"gate:{request_id}", + ) + self._enqueue_send(msg) + + def on_reply(self, session_id: str, event_type: str, data: dict) -> None: + """Handle a gate reply event by editing its card into a resolved, + button-free form (AC-47). + + Called by the projection when a ``permission_replied``, + ``question_replied``, or ``plan_approval_replied`` event fires. + """ + request_id = data.get("request_id", "") + if not request_id: + return + + gate = self._pending_gates.pop(request_id, None) + if gate is None: + return + + for token in gate.tokens: + self._capabilities.pop(token, None) + self._pending_by_token.pop(token, None) + + resolution_text = self._resolution_text(gate, data) + self._enqueue_resolved_edit(gate, resolution_text) + + def _resolution_text(self, gate: _PendingGate, data: dict) -> str: + """Build the resolved-form message text for one gate kind. Only + permission gates have a per-decision label defined by the spec + (AC-45's "Allowed once"/"Allowed for session"/"Rejected"); question + and plan gates get a generic resolved marker.""" + if gate.gate_kind == "permission": + reply = data.get("reply", "reject") + text, _buttons = render_permission_resolved_card( + command=gate.command or "(command unavailable)", resolution=reply + ) + return text + return "✅ Resolved" + + def _enqueue_resolved_edit(self, gate: _PendingGate, text: str) -> None: + """Edit this gate's card into its resolved form — fire-and-forget, + matching the delivery pattern already used by ``_enqueue_send``.""" + try: + loop = asyncio.get_running_loop() + loop.create_task(self._do_resolved_edit(gate, text)) + except RuntimeError: + pass + + async def _do_resolved_edit(self, gate: _PendingGate, text: str) -> None: + try: + msg = RemoteOutboundMessage( + connection_id=UUID(int=0), # not used for edit lookup + destination_id="", + text=text, + buttons=(), + correlation_id=f"gate:{gate.request_id}", + ) + await self._adapter.edit(msg) + except Exception as exc: + logger.debug( + "remote_gate_resolved_edit_failed request_id={} error={}", + gate.request_id, + exc, + ) + + async def handle_callback(self, action: RemoteInboundAction) -> None: + """Handle an inbound callback action. + + 1. Look up the capability by token. + 2. Validate principal/connection. + 3. ``answer_callback`` FIRST (acknowledge the tap). + 4. Resolve the gate via the appropriate service. + 5. Edit the message to remove buttons. + """ + token = action.callback_token + if not token: + return + + cap = self._capabilities.get(token) + if cap is None: + logger.debug( + "remote_gate_callback_unknown token_prefix={}", + token[:8], + ) + return + + # Validate connection. + if cap.connection_id != action.connection_id: + logger.debug( + "remote_gate_callback_wrong_connection expected={} got={}", + cap.connection_id, + action.connection_id, + ) + return + + # Check expiry. + if time.monotonic() - cap.created_at > _CAPABILITY_TTL_SECONDS: + self._discard_capability(cap) + logger.debug( + "remote_gate_callback_expired request_id={}", + cap.request_id, + ) + return + + # Acknowledge the callback FIRST (AC-26). + await self._adapter.answer_callback(token) + + # Resolve the gate. + resolved = await self._resolve_gate(cap, action) + if resolved: + self._discard_capability(cap) + + async def _resolve_gate( + self, cap: GateCapability, action: RemoteInboundAction + ) -> bool: + """Resolve a gate through the active service registry.""" + if cap.gate_kind == "permission": + return await self._resolve_permission(cap, action) + elif cap.gate_kind == "question": + return await self._resolve_question(cap, action) + elif cap.gate_kind == "plan": + return await self._resolve_plan(cap, action) + return False + + async def _resolve_permission( + self, cap: GateCapability, action: RemoteInboundAction + ) -> bool: + """Resolve a permission gate. Remote accepts once/always/reject + (AC-28, revised) — "always" is session-scoped in PermissionService + (it appends a rule to session_ruleset, not a permanent grant), which + is exactly what the remote card's "Allow for session" label says.""" + from app.agent.permission import Reply, get_service_for_session + + if cap.action not in ("once", "always", "reject"): + logger.warning( + "remote_gate_invalid_permission_action action={}", + cap.action, + ) + return False + reply_value: Reply = cap.action # ty: ignore[invalid-assignment] + + svc = get_service_for_session(cap.session_id) + if svc is None: + logger.debug( + "remote_gate_permission_service_missing session_id={}", + cap.session_id, + ) + return False + + resolved = svc.reply(cap.request_id, reply_value) + if resolved: + logger.info( + "remote_gate_permission_resolved request_id={} reply={}", + cap.request_id, + reply_value, + ) + return resolved + + async def _resolve_question( + self, cap: GateCapability, action: RemoteInboundAction + ) -> bool: + """Resolve a question gate with the selected option.""" + from app.agent.ask_user import get_service_for_session + + svc = get_service_for_session(cap.session_id) + if svc is None: + logger.debug( + "remote_gate_question_service_missing session_id={}", + cap.session_id, + ) + return False + + # The action is the answer (single-question batch for v1). + answers = [cap.action] + validation_error = svc.validate_answers(cap.request_id, answers) + if validation_error: + logger.warning( + "remote_gate_question_validation_failed request_id={} error={}", + cap.request_id, + validation_error, + ) + return False + + resolved = svc.reply(cap.request_id, answers) + if resolved: + logger.info( + "remote_gate_question_resolved request_id={}", + cap.request_id, + ) + return resolved + + async def _resolve_plan( + self, cap: GateCapability, action: RemoteInboundAction + ) -> bool: + """Resolve a plan approval gate.""" + from app.agent.plan import get_service_for_session + + decision = cap.action # "approve" -> "approved", "reject" -> "rejected" + plan_decision = "approved" if decision == "approve" else "rejected" + + svc = get_service_for_session(cap.session_id) + if svc is None: + logger.debug( + "remote_gate_plan_service_missing session_id={}", + cap.session_id, + ) + return False + + resolved = svc.reply(cap.request_id, plan_decision) + if resolved: + logger.info( + "remote_gate_plan_resolved request_id={} decision={}", + cap.request_id, + plan_decision, + ) + return resolved + + def _discard_capability(self, cap: GateCapability) -> None: + """Remove a capability and its token from all indices.""" + self._capabilities.pop(cap.token, None) + self._pending_by_token.pop(cap.token, None) + + def _issue_token( + self, + *, + connection_id: UUID, + principal_id: str, + destination_id: str, + session_id: str, + request_id: str, + gate_kind: GateKind, + action: str, + ) -> str: + """Issue an opaque, bounded callback token.""" + token = secrets.token_urlsafe(16) + assert len(token) <= _MAX_CALLBACK_TOKEN_BYTES, ( + f"Token too long: {len(token)} bytes" + ) + cap = GateCapability( + token=token, + connection_id=connection_id, + principal_id=principal_id, + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind=gate_kind, + action=action, + ) + self._capabilities[token] = cap + return token + + def _enqueue_send(self, msg: RemoteOutboundMessage) -> None: + """Enqueue a message for async delivery.""" + try: + loop = asyncio.get_running_loop() + loop.create_task(self._do_send(msg)) + except RuntimeError: + pass + + async def _do_send(self, msg: RemoteOutboundMessage) -> None: + """Send a message through the adapter.""" + try: + await self._adapter.send(msg) + except Exception as exc: + logger.warning( + "remote_gate_send_failed destination_id={} error={}", + msg.destination_id, + exc, + ) + + +def _redact_text(text: str) -> str: + """Apply remote-channel outbound redaction.""" + try: + from app.agent.outbound_redaction import OutboundContext, protect_outbound_text + + protected, _report = protect_outbound_text( + text, context=OutboundContext(channel="remote") + ) + return protected + except Exception: + return text diff --git a/app/remote/inbound.py b/app/remote/inbound.py new file mode 100644 index 00000000..4385e0c0 --- /dev/null +++ b/app/remote/inbound.py @@ -0,0 +1,236 @@ +"""Provider-neutral remote text ingress and current-task selection.""" + +from __future__ import annotations + +import asyncio +import hashlib +from dataclasses import dataclass +from uuid import UUID + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.chat import ChatSession +from app.models.remote import RemotePairing +from app.remote.contracts import RemoteInboundAction, RemoteInboundActionKind +from app.remote.pairing import PairingService +from app.services import agent_service +from app.services.chat_service import create_chat_session +from app.services.interactive_message_service import ( + NoTeamConfigured, + resolve_team_for_session, + submit_persisted_interactive_message, +) + + +@dataclass(frozen=True) +class RemoteInboundResult: + """A bounded, adapter-neutral outcome for one remote action.""" + + status: str + session_id: UUID | None = None + message_id: UUID | None = None + response_mode: str = "summary" + + +class RemoteInboundService: + """Admits paired remote actions through the existing service layer.""" + + def __init__(self, *, pairing_service: PairingService | None = None) -> None: + self._pairing_service = pairing_service or PairingService() + self._locks: dict[UUID, asyncio.Lock] = {} + self._locks_lock = asyncio.Lock() + + async def handle_text( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteInboundResult: + """Submit paired plain text to its current task or create one.""" + if action.kind is not RemoteInboundActionKind.TEXT: + return RemoteInboundResult(status="ignored") + content = (action.text or "").strip() + if not content: + return RemoteInboundResult(status="ignored") + + pairing = await self._authorize(db, action) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + + async with await self._lock_for(pairing.id): + pairing = await db.get(RemotePairing, pairing.id) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + # Captured now, before the rollback below expires every ORM + # instance in this session — accessing pairing.response_mode + # after that would need a lazy reload, which the async ORM + # cannot do implicitly. + response_mode = pairing.response_mode + session = await self._current_session(db, pairing) + if session is None: + session = await self._create_work_session(db, pairing, action) + + session_id = session.id + if db.in_transaction(): + await db.rollback() + session, team = await resolve_team_for_session( + db, str(session_id), require_existing=True + ) + if session is None: + return RemoteInboundResult(status="session_not_addressable") + + request_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() + result = await submit_persisted_interactive_message( + db, + session=session, + team=team, + content=content, + message_extra={ + "interactive_source": { + "channel": "remote", + "adapter": "telegram", + "connection_id": str(action.connection_id), + "key": action.source_key, + "request_hash": request_hash, + "state": "persisted", + } + }, + source_key=action.source_key, + source_request_hash=request_hash, + ) + return RemoteInboundResult( + status=result.status, + session_id=UUID(result.session_id), + message_id=result.message_id, + response_mode=response_mode, + ) + + async def new_task( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteInboundResult: + """Clear only this pairing's current-task pointer.""" + pairing = await self._authorize(db, action) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + + async with await self._lock_for(pairing.id): + pairing = await db.get(RemotePairing, pairing.id) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + pairing.active_session_id = None + db.add(pairing) + await db.commit() + return RemoteInboundResult(status="current_task_cleared") + + async def continue_task( + self, db: AsyncSession, action: RemoteInboundAction, session_id: UUID + ) -> RemoteInboundResult: + """Make an existing task current for the pairing.""" + pairing = await self._authorize(db, action) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + + async with await self._lock_for(pairing.id): + pairing = await db.get(RemotePairing, pairing.id) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + session = await db.get(ChatSession, session_id) + if not _is_addressable_session(session): + return RemoteInboundResult(status="session_not_addressable") + assert session is not None + pairing.active_session_id = session.id + db.add(pairing) + await db.commit() + return RemoteInboundResult( + status="current_task_selected", session_id=session_id + ) + + async def stop_current( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteInboundResult: + """Interrupt a live current turn without deleting or changing its task.""" + pairing = await self._authorize(db, action) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + + async with await self._lock_for(pairing.id): + pairing = await db.get(RemotePairing, pairing.id) + if pairing is None: + return RemoteInboundResult(status="unauthorized") + session = await self._current_session(db, pairing) + if session is None: + return RemoteInboundResult(status="no_active_turn") + session_id = session.id + if db.in_transaction(): + await db.rollback() + try: + _, team = await resolve_team_for_session( + db, str(session_id), require_existing=True + ) + except NoTeamConfigured: + return RemoteInboundResult( + status="no_active_turn", session_id=session_id + ) + if not team.has_active_user_turn(): + return RemoteInboundResult( + status="no_active_turn", session_id=session_id + ) + await agent_service.interrupt_team(team, str(session_id)) + return RemoteInboundResult(status="interrupted", session_id=session_id) + + async def _authorize( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemotePairing | None: + if action.principal.connection_id != action.connection_id: + return None + return await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + + async def _lock_for(self, pairing_id: UUID) -> asyncio.Lock: + async with self._locks_lock: + return self._locks.setdefault(pairing_id, asyncio.Lock()) + + async def _current_session( + self, db: AsyncSession, pairing: RemotePairing + ) -> ChatSession | None: + if pairing.active_session_id is None: + return None + session = await db.get(ChatSession, pairing.active_session_id) + if _is_addressable_session(session): + return session + pairing.active_session_id = None + db.add(pairing) + await db.commit() + return None + + async def _create_work_session( + self, + db: AsyncSession, + pairing: RemotePairing, + action: RemoteInboundAction, + ) -> ChatSession: + session = await create_chat_session(db) + session.mode = "work" + session.parent_session_id = None + session.session_type = "main" + session.tags = [ + "remote_origin", + f"remote_connection:{action.connection_id}", + ] + pairing.active_session_id = session.id + db.add(session) + db.add(pairing) + await db.commit() + return session + + +def _is_addressable_session(session: ChatSession | None) -> bool: + return ( + session is not None + and session.parent_session_id is None + and session.session_type == "main" + and session.mode in {"work", "coding"} + ) + + +__all__ = ["RemoteInboundResult", "RemoteInboundService"] diff --git a/app/remote/live_activity.py b/app/remote/live_activity.py new file mode 100644 index 00000000..53bd195f --- /dev/null +++ b/app/remote/live_activity.py @@ -0,0 +1,163 @@ +"""Rolling activity window for live-mode status cards (AC-56). + +Bounded, in-memory, per-turn bookkeeping — owned by +``outbound._TurnDeliveryState`` and discarded wherever that state already +is (completion, error, interrupt, adapter shutdown, unpairing; see that +module's docstring). Pure: no asyncio, no adapter, no database, so this +module is trivially unit-testable and safe to call from the synchronous +``RemoteProjection.observe`` path. + +Entries are keyed (by ``tool_call_id``, or ``"thinking:{agent}"`` for a +thinking entry) so a tool's start/end updates the same line in place +rather than growing the window, and the window keeps only the +``MAX_ENTRIES`` most-recently-touched entries — a "most recent" LRU, not a +plain append-only ring buffer, so a long-running tool call started before +five other tools finished still tracks its own completion correctly. + +Argument summaries are the highest-risk text this module renders (file +paths, command strings, occasionally secrets — see the control-surface +spec's Permissions section), so they are redacted with the same +``protect_outbound_text(channel="remote")`` every other rendered field in +this feature uses, then truncated to ``MAX_ARG_SUMMARY_LENGTH``, then +HTML-escaped — redaction first, truncation second, escaping last, so a +truncated multi-byte escape sequence can never appear. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from app.remote.formatting import escape + +__all__ = ["LiveActivityWindow", "MAX_ENTRIES", "MAX_ARG_SUMMARY_LENGTH"] + +#: The six most recent activity entries bound the block to roughly 600 +#: characters even at the 80-char argument cap, well clear of Telegram's +#: 4096-character limit (AC-56). +MAX_ENTRIES = 6 +MAX_ARG_SUMMARY_LENGTH = 80 + +_PENDING_ICON = "⏳" # ⏳ — tool_start observed, tool_end not yet +_DONE_ICON = "\U0001f527" # 🔧 — tool_end observed +_THINKING_ICON = "\U0001f4ad" # 💭 +_SKILL_ICON = "\U0001f4da" # 📚 + +#: Skills are not a distinct event; they are a tool call carrying +#: ``skill_name`` in its arguments (app/agent/tools/builtin/skill.py) and +#: are special-cased here rather than rendered as a generic tool call. +_SKILL_TOOL_NAME = "skill" + + +def _redact(text: str) -> str: + """Best-effort outbound redaction — mirrors outbound.py's own + ``_redact_text`` (kept independent per this module's "no dependency on + app.remote state" purity goal; both wrap the same protection call).""" + try: + from app.agent.outbound_redaction import OutboundContext, protect_outbound_text + + protected, _report = protect_outbound_text( + text, context=OutboundContext(channel="remote") + ) + return protected + except Exception: + return text + + +def _summarize_arguments(arguments: str | None) -> str: + """A short, redacted, truncated rendering of a tool's JSON arguments. + + Single-key argument dicts (the common case — ``{"path": ...}``, + ``{"pattern": ...}``, ``{"command": ...}``) render as just that value, + matching the spec's mockup (``grep "def test_auth"``, not + ``grep {"pattern": "def test_auth"}``). Anything else falls back to a + space-joined dump of the values. Malformed JSON renders as the raw + string rather than raising — a live-mode line is decoration, never + worth failing a turn over. + """ + if not arguments: + return "" + try: + parsed = json.loads(arguments) + except (json.JSONDecodeError, TypeError): + raw = arguments + else: + if isinstance(parsed, dict) and parsed: + raw = " ".join(str(v) for v in parsed.values()) + elif isinstance(parsed, dict): + raw = "" + else: + raw = str(parsed) + + raw = _redact(raw) + if len(raw) > MAX_ARG_SUMMARY_LENGTH: + raw = raw[: MAX_ARG_SUMMARY_LENGTH - 1] + "…" + return escape(raw) + + +def _extract_skill_name(arguments: str | None) -> str | None: + if not arguments: + return None + try: + parsed = json.loads(arguments) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(parsed, dict): + return None + name = parsed.get("skill_name") + return name if isinstance(name, str) and name else None + + +@dataclass +class LiveActivityWindow: + """A bounded rolling window of live-mode activity lines (AC-56).""" + + _order: list[str] = field(default_factory=list) # keys, oldest-touched first + _rendered: dict[str, str] = field(default_factory=dict) # key -> full line + + def _touch(self, key: str, line: str) -> None: + if key in self._rendered: + self._order.remove(key) + self._order.append(key) + self._rendered[key] = line + while len(self._order) > MAX_ENTRIES: + oldest = self._order.pop(0) + self._rendered.pop(oldest, None) + + def observe_tool_call(self, *, tool_call_id: str | None, name: str) -> None: + if name == _SKILL_TOOL_NAME: + return # rendered richly once tool_start's arguments arrive + key = tool_call_id or f"anon:{name}" + self._touch(key, f"{_PENDING_ICON} {escape(name)}") + + def observe_tool_start( + self, *, tool_call_id: str | None, name: str, arguments: str | None + ) -> None: + key = tool_call_id or f"anon:{name}" + if name == _SKILL_TOOL_NAME: + skill_name = _extract_skill_name(arguments) + if skill_name: + self._touch(key, f"{_SKILL_ICON} Skill: {escape(skill_name)}") + return + summary = _summarize_arguments(arguments) + line = f"{_PENDING_ICON} {escape(name)}" + if summary: + line += f" {summary}" + self._touch(key, line) + + def observe_tool_end(self, *, tool_call_id: str | None, name: str) -> None: + key = tool_call_id or f"anon:{name}" + existing = self._rendered.get(key) + if existing is not None and existing.startswith(_PENDING_ICON): + self._touch(key, _DONE_ICON + existing[len(_PENDING_ICON) :]) + elif existing is None: + self._touch(key, f"{_DONE_ICON} {escape(name)}") + else: + self._touch(key, existing) # already done or a skill card; just re-touch + + def observe_thinking(self, *, agent: str) -> None: + key = f"thinking:{agent}" + self._touch(key, f"{escape(agent)} · {_THINKING_ICON} thinking") + + def lines(self) -> list[str]: + return [self._rendered[key] for key in self._order] diff --git a/app/remote/outbound.py b/app/remote/outbound.py new file mode 100644 index 00000000..56710d76 --- /dev/null +++ b/app/remote/outbound.py @@ -0,0 +1,1115 @@ +"""Remote outbound projection, redaction, splitting, and delivery. + +Observes the global stream (via +:func:`app.services.memory_stream_store.register_observer`) and projects +relevant events for remote-originated sessions into safe, redacted Telegram +messages. + +Only sessions tagged ``remote_origin`` are observed. The projection +collapses duplicate completions, queries finalized assistant text from the +database after ``done``, and applies the ``remote`` outbound-redaction +channel before delivery. + +Design constraints (from spec): +- Observers are synchronous, bounded, and non-blocking. +- No network or database work runs under stream locks. +- All outbound text uses ``protect_outbound_text(..., context=OutboundContext(channel="remote"))``. +- No Telegram parse mode — model-authored text must never be interpreted as markup. +- Unicode-safe plain-text splitting at 4096 characters (Telegram provider bound). +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, Protocol +from uuid import UUID + +from loguru import logger + +from app.remote.contracts import ( + RemoteAdapter, + RemoteButton, + RemoteOutboundMessage, + RemoteOutboundPriority, +) +from app.remote.edit_budget import EditBudget +from app.remote.formatting import ( + derive_card_heading, + render_done_card, + render_error_card, + render_live_status_card, + render_status_card, +) +from app.remote.live_activity import LiveActivityWindow +from app.remote.turn_activity import load_turn_activity + +if TYPE_CHECKING: + from app.remote.gates import RemoteGateBridge + + +class _CapabilityRegistrar(Protocol): + """The Task 6 capability-minting surface used by completion cards.""" + + def register_capability( + self, + *, + connection_id: UUID | str, + principal_id: str, + destination_id: str, + session_id: str, + action_kind: str, + action_target: str, + ) -> str: ... + + +#: Telegram's maximum message length in characters. +_TELEGRAM_MAX_MESSAGE_LENGTH = 4096 + +#: Live-mode-only events (AC-56) — only handled for a turn whose cached +#: response_mode is "live"; the check happens in ``observe`` itself +#: (in-memory, synchronous — no I/O), never before it, since ``observe`` +#: has no other reason to look at a turn's mode. +_ACTIVITY_EVENT_TYPES = frozenset({"tool_call", "tool_start", "tool_end", "thinking"}) + +#: Events the remote projection forwards to the phone. +_OBSERVED_EVENT_TYPES = ( + frozenset( + { + "done", + "error", + "message", + "usage", + "permission_asked", + "question_asked", + "plan_approval_requested", + "permission_replied", + "question_replied", + "plan_approval_replied", + } + ) + | _ACTIVITY_EVENT_TYPES +) + + +@dataclass +class _TurnDeliveryState: + """Per-turn bookkeeping for one remote-originated session.""" + + session_id: str + connection_id: str + destination_id: str + #: The lifecycle (progress) message correlation, if one was sent. + lifecycle_correlation_id: str | None = None + #: Whether a completion message has already been sent for this turn. + completion_sent: bool = False + #: True for a turn that owns a single live status card — created by + #: ``begin_phone_turn`` — that the final done/error card must edit + #: in place rather than follow with a new message. + phone_admitted: bool = False + started_at: float = field(default_factory=time.monotonic) + turn_started_wall_clock: datetime = field(default_factory=lambda: datetime.now(UTC)) + #: The repeating native-typing-indicator task started alongside the + #: status card; cancelled and cleared once the turn finalizes. + typing_task: "asyncio.Task[None] | None" = field(default=None, repr=False) + title: str = "" + principal_id: str = "" + #: "summary" (default) or "live" — read fresh from the pairing at the + #: start of every phone-admitted turn (never cached across turns), so + #: AC-55's "no restart needed" holds. Desktop-started turns (no + #: begin_phone_turn call) stay at the default; only a phone-admitted + #: turn can be live, since only it owns an editable status card. + response_mode: str = "summary" + #: The rolling activity window this turn renders into while live — + #: unused and empty in summary mode. + activity: LiveActivityWindow = field(default_factory=LiveActivityWindow) + + # ── Usage tracking (populated from UsageEvent stream data) ── + usage_model: str | None = None + usage_context_window: int | None = None + usage_input_tokens: int | None = None + usage_output_tokens: int | None = None + usage_cached_tokens: int | None = None + usage_reasoning_tokens: int | None = None + usage_cost_usd: float | None = None + + #: The user's original message text. Used to derive a more meaningful + #: card heading than the session title (which is often "Task" or an + #: LLM-generated title that hasn't been generated yet). + user_message: str | None = None + + +@dataclass +class RemoteProjection: + """Projects stream events for remote-originated sessions into Telegram. + + Registered as a global stream observer. Each incoming event is checked + against the session's provenance tags; only ``remote_origin`` sessions + are tracked. Relevant events are normalized, redacted, split, and + enqueued for delivery through the live adapter. + """ + + _adapter: RemoteAdapter | None = field(default=None, repr=False) + _bridge: "RemoteGateBridge | None" = field(default=None, repr=False) + #: Set by a later task's ``set_actions`` (Task 6) so ``_finalize_turn`` + #: can mint "Full diff"/"Tool log" drill-down capability tokens. ``None`` + #: until that wiring lands — every use guards on it and treats a missing + #: service as "no button" (``formatting.py``'s card builders already + #: omit a button for a ``None`` token). + _actions: _CapabilityRegistrar | None = field(default=None, repr=False) + _turns: dict[str, _TurnDeliveryState] = field(default_factory=dict, repr=False) + _session_tags: dict[str, frozenset[str]] = field(default_factory=dict, repr=False) + _session_connection_ids: dict[str, str] = field(default_factory=dict, repr=False) + _session_destination_ids: dict[str, str] = field(default_factory=dict, repr=False) + _pending: list[RemoteOutboundMessage] = field(default_factory=list, repr=False) + _pending_edits: list[RemoteOutboundMessage] = field( + default_factory=list, repr=False + ) + #: Turns whose ``done``/``error`` event has been observed but whose + #: final card hasn't been built and delivered yet — building it needs a + #: database query (:func:`~app.remote.turn_activity.load_turn_activity`), + #: which ``observe()`` itself must never do (observers are synchronous + #: and non-blocking, per this module's design constraints). Queued here + #: instead and drained by :meth:`drain_pending`, matching how ``_pending`` + #: already defers ``adapter.send`` calls out of ``observe()``. + _pending_finalizations: list[tuple[_TurnDeliveryState, str | None]] = field( + default_factory=list, repr=False + ) + #: Completion/error events for sessions that have no remote-origin + #: registration. Addressability requires a database read, so these stay + #: queued until :meth:`_drain_unaddressed` can validate them off the + #: synchronous stream-observer path. + _unaddressed_pending: list[tuple[str, str, str, str, str, dict]] = field( + default_factory=list, repr=False + ) + #: Serializes the whole of :meth:`drain_pending` so two concurrently + #: scheduled drains (e.g. a status-card send and a same-turn done-card + #: edit, each fired by its own ``_schedule_drain``) can never interleave + #: — same precedent as ``PairingService._consume_lock`` in + #: ``app/remote/pairing.py`` for an identical check-then-act race. + _drain_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) + #: Correlation ids whose message has been *confirmed* sent (the + #: ``adapter.send`` call for it returned without raising) — as opposed + #: to merely enqueued. An adapter's ``edit`` silently no-ops when it has + #: no record of the original send (e.g. + #: :class:`~app.remote.telegram.adapter.TelegramAdapter` keys its + #: ``_sent_messages`` cache by correlation id and only populates it on + #: success), so ``_finalize_turn`` checks this before choosing to edit + #: rather than send, to avoid silently losing a turn's final card. + _sent_correlations: set[str] = field(default_factory=set, repr=False) + #: Caches the single v1 pairing's routing info so ``observe`` can reach + #: sessions it was never explicitly ``register_session``-ed for (e.g. + #: work started from the desktop, not the phone) — this app supports + #: exactly one Telegram pairing per installation, so there is never more + #: than one tuple to cache. ``principal_id`` travels alongside + #: ``connection_id``/``destination_id`` because a future task mints + #: capability tokens for these sessions and needs a real, non-empty + #: owner id: since v1 has exactly one pairing per connection, that + #: pairing's own principal is the only valid actor for the whole + #: connection regardless of which surface (phone or desktop) started the + #: work being notified about. + _active_pairing: tuple[str, str, str, str] | None = field(default=None, repr=False) + #: Shared across every live turn on this connection (AC-57) — see + #: app/remote/edit_budget.py's own docstring for why the throttle is + #: connection-scoped rather than per-turn. + _edit_budget: EditBudget = field(default_factory=EditBudget, repr=False) + + def set_adapter(self, adapter: RemoteAdapter | None) -> None: + """Bind or unbind the live adapter. Called by the runtime on start/stop. + + Unbinding (``adapter=None``) stops every turn's typing-indicator + task — otherwise a turn whose ``done``/``error`` event never arrives + (the runtime stopped mid-turn) would leave its loop calling + ``indicate_typing`` on a now-stale adapter reference every 4s + forever. + """ + self._adapter = adapter + if adapter is None: + for turn in self._turns.values(): + self._stop_typing(turn) + + def set_bridge(self, bridge: "RemoteGateBridge | None") -> None: + """Bind or unbind the gate bridge. Called by the runtime on start/stop.""" + self._bridge = bridge + + def set_actions(self, actions: _CapabilityRegistrar | None) -> None: + """Bind the short-lived completion-detail capability registrar.""" + self._actions = actions + + def set_active_pairing( + self, + *, + connection_id: str, + destination_id: str, + notify_scope: str, + principal_id: str, + ) -> None: + """Cache the single v1 pairing's routing info. + + Called by the runtime whenever the pairing changes: a pairing is + created, restored from the database on startup, or the runtime + stops (via :meth:`clear_active_pairing`). + """ + self._active_pairing = ( + connection_id, + destination_id, + notify_scope, + principal_id, + ) + + def clear_active_pairing(self) -> None: + """Drop the cached pairing. Called by the runtime on stop.""" + self._active_pairing = None + + def active_pairing(self) -> tuple[str, str, str, str] | None: + """Return the cached ``(connection_id, destination_id, notify_scope, + principal_id)`` tuple, or ``None`` if no pairing is active.""" + return self._active_pairing + + def register_session( + self, + session_id: str, + *, + connection_id: str, + destination_id: str, + tags: frozenset[str] = frozenset(), + ) -> None: + """Register a session as remote-originated so its events are projected.""" + self._session_tags[session_id] = tags + self._session_connection_ids[session_id] = connection_id + self._session_destination_ids[session_id] = destination_id + + def unregister_session(self, session_id: str) -> None: + """Stop tracking a session.""" + self._session_tags.pop(session_id, None) + self._session_connection_ids.pop(session_id, None) + self._session_destination_ids.pop(session_id, None) + turn = self._turns.pop(session_id, None) + if turn is not None: + self._stop_typing(turn) + + def begin_phone_turn( + self, + session_id: str, + *, + connection_id: str, + destination_id: str, + principal_id: str, + title: str, + status: str, + response_mode: str = "summary", + user_message: str | None = None, + ) -> None: + """Create the one status message a phone-admitted turn owns, and + start the native typing indicator alongside it. Called by + runtime.py right after ``register_session`` for a text-triggered + admission. + + Reachable more than once for the same session — a follow-up + message sent to the phone while the first turn is still running + (``status="queued"``) triggers another admission, and thus another + call here, before the first turn's ``done``/``error`` arrives. + Rather than start a second status message and orphan the first + turn's typing task, this reuses the still-unresolved turn's + message: stop its typing task and carry its + ``lifecycle_correlation_id`` forward so the *same* message is + edited with the new status text. If the original status card is + still queued locally, its pending payload is replaced before any + network delivery; a fresh message is only sent when there is no + reusable unresolved turn or its original send has already failed. + """ + existing = self._turns.get(session_id) + reuse_correlation_id: str | None = None + if existing is not None and not existing.completion_sent: + self._stop_typing(existing) + existing_correlation_id = existing.lifecycle_correlation_id + if existing_correlation_id is not None and ( + existing_correlation_id in self._sent_correlations + or any( + pending.correlation_id == existing_correlation_id + for pending in self._pending + ) + ): + reuse_correlation_id = existing_correlation_id + + correlation_id = ( + reuse_correlation_id or f"status:{session_id}:{uuid.uuid4().hex[:8]}" + ) + turn = _TurnDeliveryState( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + lifecycle_correlation_id=correlation_id, + phone_admitted=True, + title=title, + response_mode=response_mode, + user_message=user_message, + ) + self._turns[session_id] = turn + text, buttons = render_status_card(title=title, status=status) + if reuse_correlation_id in self._sent_correlations: + self._enqueue_edit( + destination_id=destination_id, + text=text, + buttons=buttons, + correlation_id=reuse_correlation_id, + ) + elif reuse_correlation_id is not None: + self._replace_pending_status( + correlation_id=reuse_correlation_id, + text=text, + buttons=buttons, + ) + else: + self._enqueue_send( + destination_id=destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + correlation_id=correlation_id, + ) + if self._adapter is not None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + pass + else: + turn.typing_task = loop.create_task(self._run_typing_loop(turn)) + + async def _run_typing_loop(self, turn: _TurnDeliveryState) -> None: + """Call ``indicate_typing`` every 4s until cancelled. + + Re-checks ``self._adapter`` on every iteration (rather than a + one-time snapshot) so unbinding the adapter mid-turn stops calls + going to a stale reference, and isolates each ``indicate_typing`` + call in its own ``try/except`` so one transport failure logs and + retries on the next tick instead of silently killing the loop (and + the typing indicator) for the rest of the turn. + """ + try: + while True: + adapter = self._adapter + if adapter is None: + return + try: + await adapter.indicate_typing(turn.destination_id) + except Exception as exc: + logger.warning( + "remote_typing_indicator_failed destination_id={} error={}", + turn.destination_id, + exc, + ) + await asyncio.sleep(4.0) + except asyncio.CancelledError: + pass + + def typing_task_for(self, session_id: str) -> "asyncio.Task[None] | None": + turn = self._turns.get(session_id) + return turn.typing_task if turn is not None else None + + def _replace_pending_status( + self, + *, + correlation_id: str, + text: str, + buttons: tuple[RemoteButton, ...], + ) -> None: + """Update an unsent lifecycle card without creating a second card.""" + for index, pending in enumerate(self._pending): + if pending.correlation_id == correlation_id: + self._pending[index] = replace(pending, text=text, buttons=buttons) + return + raise RuntimeError("reusable status correlation is not pending") + + def _stop_typing(self, turn: _TurnDeliveryState) -> None: + if turn.typing_task is not None and not turn.typing_task.done(): + turn.typing_task.cancel() + turn.typing_task = None + + def observe(self, session_id: str, envelope) -> None: + """Stream observer callback — invoked synchronously after ``push_event``. + + Must be non-blocking. Enqueues delivery work for the adapter. + """ + event_type = envelope.event + if event_type not in _OBSERVED_EVENT_TYPES: + return + + tags = self._session_tags.get(session_id) + if tags is None: + active = self._active_pairing + if active is None or active[2] != "all": + return + if event_type not in {"done", "error"}: + return + connection_id, destination_id, _, principal_id = active + self._enqueue_unregistered_completion( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + event_type=event_type, + envelope_data=dict(envelope.data), + ) + return + if "remote_origin" not in tags: + return + + connection_id = self._session_connection_ids.get(session_id, "") + destination_id = self._session_destination_ids.get(session_id, "") + if not connection_id or not destination_id: + return + + turn = self._turns.get(session_id) + if turn is None: + # No begin_phone_turn was ever called for this session (e.g. work + # started on the desktop that the phone only observes) — "Task" + # is a defensive fallback so the eventual done/error card never + # renders with a blank title; a real title isn't available here. + turn = _TurnDeliveryState( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + title="Task", + ) + self._turns[session_id] = turn + + if event_type in _ACTIVITY_EVENT_TYPES: + self._handle_activity(turn, event_type, envelope) + return + + if event_type == "usage": + self._handle_usage(turn, envelope) + return + + # Message (text content deltas) fire during LLM streaming. Observe + # them so the live status card refreshes at the edit-budget cadence + # even when no tool/thinking events are in flight. + if event_type == "message": + if ( + turn.phone_admitted + and turn.response_mode == "live" + and not turn.completion_sent + ): + self._maybe_schedule_live_edit(turn) + return + + if event_type == "done": + self._handle_done(turn, envelope) + elif event_type == "error": + self._handle_error(turn, envelope) + elif event_type in { + "permission_asked", + "question_asked", + "plan_approval_requested", + }: + self._handle_gate(turn, event_type, envelope) + elif event_type in { + "permission_replied", + "question_replied", + "plan_approval_replied", + }: + if self._bridge is not None: + self._bridge.on_reply(session_id, event_type, envelope.data) + + def _enqueue_unregistered_completion( + self, + *, + session_id: str, + connection_id: str, + destination_id: str, + principal_id: str, + event_type: str, + envelope_data: dict, + ) -> None: + """Queue a completion until the async path confirms addressability.""" + self._unaddressed_pending.append( + ( + session_id, + connection_id, + destination_id, + principal_id, + event_type, + envelope_data, + ) + ) + self._schedule_drain() + + def _handle_done(self, turn: _TurnDeliveryState, envelope) -> None: + if turn.completion_sent: + return + turn.completion_sent = True + self._pending_finalizations.append((turn, None)) + self._schedule_drain() + + def _handle_error(self, turn: _TurnDeliveryState, envelope) -> None: + if turn.completion_sent: + return + turn.completion_sent = True + message = envelope.data.get("message", "An error occurred.") + self._pending_finalizations.append((turn, message)) + self._schedule_drain() + + async def _finalize_turn( + self, turn: _TurnDeliveryState, *, error_message: str | None + ) -> None: + """Build the turn's final done/error card and deliver it. + + Stops the typing indicator first (the turn is over regardless of + how delivery goes), then queries this turn's tool-call activity to + build the card, and either edits the status card a phone-admitted + turn already owns, or sends a new message for every other turn + (e.g. one started from the desktop that the phone is only + observing). + """ + from app.core.db import async_session_factory + + self._stop_typing(turn) + if turn.lifecycle_correlation_id is not None: + self._edit_budget.discard(turn.lifecycle_correlation_id) + elapsed = time.monotonic() - turn.started_at + async with async_session_factory() as db: + activity = await load_turn_activity( + db, turn.session_id, since=turn.turn_started_wall_clock + ) + + diff_token: str | None = None + toollog_token: str | None = None + if self._actions is not None: + if activity.diff_text.strip() and activity.diff_text != "No file changes.": + diff_token = self._actions.register_capability( + connection_id=turn.connection_id, + principal_id=turn.principal_id, + destination_id=turn.destination_id, + session_id=turn.session_id, + action_kind="diff", + action_target=activity.diff_text, + ) + # Omit the button entirely rather than link to an always-empty + # "No tool calls." page — most conversational turns have none, + # and an always-present, always-empty button reads as broken. + if activity.tool_call_count > 0: + toollog_token = self._actions.register_capability( + connection_id=turn.connection_id, + principal_id=turn.principal_id, + destination_id=turn.destination_id, + session_id=turn.session_id, + action_kind="toollog", + action_target=activity.tool_log_text, + ) + + heading = derive_card_heading(turn.user_message, turn.title) + if error_message is not None: + text, buttons = render_error_card( + title=heading, + message=_redact_text(error_message), + toollog_token=toollog_token, + ) + else: + text, buttons = render_done_card( + title=heading, + elapsed_seconds=elapsed, + response_text=( + _redact_text(activity.response_text) + if activity.response_text + else None + ), + summary_lines=[_redact_text(line) for line in activity.summary_lines], + tool_call_count=activity.tool_call_count, + diff_token=diff_token, + toollog_token=toollog_token, + model=turn.usage_model, + context_window=turn.usage_context_window, + input_tokens=turn.usage_input_tokens, + output_tokens=turn.usage_output_tokens, + cached_tokens=turn.usage_cached_tokens, + reasoning_tokens=turn.usage_reasoning_tokens, + cost_usd=turn.usage_cost_usd, + ) + + # Only edit when this turn's status card was actually confirmed + # sent — an adapter's edit silently no-ops against a correlation id + # it never recorded a successful send for (see + # TelegramAdapter.edit's ``_sent_messages`` lookup), which would + # otherwise lose the final card entirely. + correlation_id = turn.lifecycle_correlation_id + was_sent = ( + correlation_id is not None and correlation_id in self._sent_correlations + ) + if correlation_id is not None: + self._sent_correlations.discard(correlation_id) + + if turn.phone_admitted and correlation_id is not None and was_sent: + self._enqueue_edit( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + correlation_id=correlation_id, + ) + else: + self._enqueue_send( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + ) + + def _handle_activity( + self, turn: _TurnDeliveryState, event_type: str, envelope + ) -> None: + """Feed one tool/thinking event into *turn*'s activity window and, + if this is a live phone-admitted turn, enqueue a throttled edit. + + A turn whose final card has already been queued + (``completion_sent``) ignores further activity — the card is + about to be overwritten by the done/error card regardless.""" + if ( + not turn.phone_admitted + or turn.response_mode != "live" + or turn.completion_sent + ): + return + + data = envelope.data + name = data.get("name", "") + tool_call_id = data.get("tool_call_id") + if event_type == "tool_call": + turn.activity.observe_tool_call(tool_call_id=tool_call_id, name=name) + elif event_type == "tool_start": + turn.activity.observe_tool_start( + tool_call_id=tool_call_id, name=name, arguments=data.get("arguments") + ) + elif event_type == "tool_end": + turn.activity.observe_tool_end(tool_call_id=tool_call_id, name=name) + elif event_type == "thinking": + turn.activity.observe_thinking(agent=data.get("agent", "")) + + self._maybe_schedule_live_edit(turn) + + def _maybe_schedule_live_edit(self, turn: _TurnDeliveryState) -> None: + correlation_id = turn.lifecycle_correlation_id + if correlation_id is None: + return + text, buttons = render_live_status_card( + title=derive_card_heading(turn.user_message, turn.title), + elapsed_seconds=time.monotonic() - turn.started_at, + activity_lines=turn.activity.lines(), + model=turn.usage_model, + input_tokens=turn.usage_input_tokens, + output_tokens=turn.usage_output_tokens, + cost_usd=turn.usage_cost_usd, + ) + if not self._edit_budget.should_edit( + connection_id=turn.connection_id, key=correlation_id, text=text + ): + return + self._edit_budget.record_edit( + connection_id=turn.connection_id, key=correlation_id, text=text + ) + self._enqueue_edit( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + correlation_id=correlation_id, + ) + + def _handle_gate(self, turn: _TurnDeliveryState, event_type: str, envelope) -> None: + data = envelope.data + if self._bridge is not None: + from uuid import UUID + + self._bridge.on_gate( + session_id=turn.session_id, + event_type=event_type, + data=data, + connection_id=UUID(turn.connection_id) + if turn.connection_id + else UUID(int=0), + destination_id=turn.destination_id, + ) + return + + # Fallback: send without opaque tokens (pre-bridge behavior). + if event_type == "permission_asked": + text = _redact_text( + f"Permission requested: {data.get('tool', 'unknown tool')}" + ) + buttons = ( + RemoteButton(text="Allow", token="perm:once"), + RemoteButton(text="Reject", token="perm:reject"), + ) + elif event_type == "question_asked": + question = data.get("question", "") + text = _redact_text(f"Question: {question}") + buttons = () + elif event_type == "plan_approval_requested": + text = _redact_text("A plan is ready for your review.") + buttons = ( + RemoteButton(text="Approve", token="plan:approve"), + RemoteButton(text="Reject", token="plan:reject"), + ) + else: + return + + self._enqueue_send( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + ) + + @staticmethod + def _pick_primary_model(models: list[str] | None) -> str | None: + """Return the most relevant model name from a turn's model list. + + The list is ordered by first-seen; the primary model is typically + the last entry (the one used for the final response). Returns + ``None`` when the list is empty or ``None``. + """ + if not models: + return None + return models[-1] + + def _handle_usage(self, turn: _TurnDeliveryState, envelope: Any) -> None: + """Accumulate token/model usage from a ``UsageEvent`` envelope. + + Usage events may fire multiple times per turn (primary model, auxiliary + calls, etc). We *sum* token counts and keep the last model name seen, + which matches the semantics the done card needs: a total across all + model calls in the turn. + """ + data = envelope.data + # Model name — UsageEvent has no top-level ``model`` field; the + # publisher stores it in ``metadata.models`` (a list of model IDs + # seen during the turn) or occasionally ``metadata.model``. + metadata = data.get("metadata") or {} + model = ( + data.get("model") + or metadata.get("model") + or self._pick_primary_model(metadata.get("models")) + ) + if model and isinstance(model, str): + turn.usage_model = model + # Look up context window from the model metadata registry. + try: + from app.agent.providers.model_metadata import get_model_limits + + limits = get_model_limits(model) + if limits.context_length: + turn.usage_context_window = limits.context_length + except Exception: # pragma: no cover + pass # best-effort; missing metadata is non-fatal + + prompt = data.get("prompt_tokens") or data.get("input_tokens") + if isinstance(prompt, int): + turn.usage_input_tokens = (turn.usage_input_tokens or 0) + prompt + + completion = data.get("completion_tokens") or data.get("output_tokens") + if isinstance(completion, int): + turn.usage_output_tokens = (turn.usage_output_tokens or 0) + completion + + cached = data.get("cached_tokens") + if isinstance(cached, int): + turn.usage_cached_tokens = (turn.usage_cached_tokens or 0) + cached + + thoughts = data.get("thoughts_tokens") + if isinstance(thoughts, int): + turn.usage_reasoning_tokens = (turn.usage_reasoning_tokens or 0) + thoughts + + cost = data.get("cost") + # UsageEvent.cost is ``dict[str, float]`` produced by + # ``estimate_cost()``. The dict contains a pre-calculated + # ``estimated_usd`` total *plus* per-component entries (``input_usd``, + # ``output_usd``, etc.). Use ``estimated_usd`` when present to avoid + # double-counting the total with the component values. + if isinstance(cost, dict): + estimated = cost.get("estimated_usd") + if isinstance(estimated, (int, float)): + turn.usage_cost_usd = (turn.usage_cost_usd or 0.0) + float(estimated) + else: + turn.usage_cost_usd = (turn.usage_cost_usd or 0.0) + sum( + v for v in cost.values() if isinstance(v, (int, float)) + ) + elif isinstance(cost, (int, float)): + turn.usage_cost_usd = (turn.usage_cost_usd or 0.0) + float(cost) + + # Trigger a live card edit so the phone sees updated token/model + # info in real time — not only at the next tool-call boundary. + self._maybe_schedule_live_edit(turn) + + def _enqueue_send( + self, + *, + destination_id: str, + text: str, + buttons: tuple[RemoteButton, ...] = (), + priority: RemoteOutboundPriority = RemoteOutboundPriority.INFORMATIONAL, + correlation_id: str | None = None, + ) -> None: + adapter = self._adapter + if adapter is None: + logger.debug("remote_outbound_no_adapter destination_id={}", destination_id) + return + + connection_id = self._any_connection_id() + + chunks = _split_text(text) + for i, chunk in enumerate(chunks): + msg = RemoteOutboundMessage( + connection_id=UUID(connection_id) if connection_id else UUID(int=0), + destination_id=destination_id, + text=chunk, + buttons=buttons if i == len(chunks) - 1 else (), + priority=priority, + correlation_id=correlation_id if i == len(chunks) - 1 else None, + ) + self._pending.append(msg) + + self._schedule_drain() + + def _enqueue_edit( + self, + *, + destination_id: str, + text: str, + buttons: tuple[RemoteButton, ...], + correlation_id: str, + ) -> None: + """Edit-flagged delivery — a status card's final transition. Unlike + ``_enqueue_send``, never splits (a status/done card is always short + and already bounded by ``formatting.py``'s builders), so it is + always exactly one queued item.""" + adapter = self._adapter + if adapter is None: + return + + connection_id = self._any_connection_id() + + msg = RemoteOutboundMessage( + connection_id=UUID(connection_id) if connection_id else UUID(int=0), + destination_id=destination_id, + text=text, + buttons=buttons, + correlation_id=correlation_id, + ) + self._pending_edits.append(msg) + self._schedule_drain() + + def _any_connection_id(self) -> str: + """One connection id to stamp on an outbound message. + + v1 supports exactly one Telegram pairing per installation (see the + ``_active_pairing`` field docstring), so any tracked session's + connection id is the right — and only — one to use. + """ + for cid in self._session_connection_ids.values(): + return cid + if self._active_pairing is not None: + return self._active_pairing[0] + return "" + + def _schedule_drain(self) -> None: + """Schedule an async drain of pending messages if a loop is running.""" + try: + loop = asyncio.get_running_loop() + loop.create_task(self.drain_pending()) + except RuntimeError: + pass + + async def drain_pending(self) -> None: + """Send queued status cards, finalize completed turns, then send or + edit their final cards through the adapter. + + Serialized by :attr:`_drain_lock`: ``_enqueue_send``/``_enqueue_edit``/ + ``_handle_done``/``_handle_error`` each fire-and-forget their own + ``_schedule_drain`` call, so without this lock two drains can run + concurrently — e.g. a status-card send still in flight when a + same-turn done-card edit's drain starts — and interleave in ways + that lose a message (see ``_sent_correlations`` above). + """ + async with self._drain_lock: + adapter = self._adapter + if adapter is None: + self._pending.clear() + self._pending_edits.clear() + self._pending_finalizations.clear() + self._unaddressed_pending.clear() + self._sent_correlations.clear() + return + await self._drain_unaddressed() + await self._drain_sends(adapter) + while self._pending_finalizations: + turn, error_message = self._pending_finalizations.pop(0) + try: + await self._finalize_turn(turn, error_message=error_message) + except Exception as exc: + logger.warning( + "remote_outbound_finalize_failed session_id={} error={}", + turn.session_id, + exc, + ) + # Finalization can enqueue a fallback completion send when the + # status send failed, so drain that work before the final-card + # edit queue. A successful status send is already recorded above, + # letting _finalize_turn choose its one in-place edit instead. + await self._drain_sends(adapter) + while self._pending_edits: + msg = self._pending_edits.pop(0) + try: + await adapter.edit(msg) + except Exception as exc: + # A 429 carries retry_after on Telegram's own exception + # type; duck-typed here rather than importing + # TelegramApiError, which would tie this + # adapter-neutral module to one adapter's transport. + error_code = getattr(exc, "error_code", None) + retry_after = getattr(exc, "retry_after", None) + if error_code == 429 and retry_after is not None: + self._edit_budget.note_rate_limited( + connection_id=str(msg.connection_id), + retry_after=float(retry_after), + ) + logger.warning( + "remote_outbound_edit_failed destination_id={} error={}", + msg.destination_id, + exc, + ) + + async def _drain_sends(self, adapter: RemoteAdapter) -> None: + """Deliver all currently queued sends and record confirmed cards.""" + while self._pending: + msg = self._pending.pop(0) + try: + await adapter.send(msg) + except Exception as exc: + logger.warning( + "remote_outbound_send_failed destination_id={} error={}", + msg.destination_id, + exc, + ) + else: + if msg.correlation_id is not None: + self._sent_correlations.add(msg.correlation_id) + + async def _drain_unaddressed(self) -> None: + """Deliver validated desktop/workflow completion cards asynchronously.""" + from app.core.db import async_session_factory + from app.models.chat import ChatSession + from app.remote.inbound import _is_addressable_session + + pending, self._unaddressed_pending = self._unaddressed_pending, [] + for ( + session_id, + connection_id, + destination_id, + principal_id, + event_type, + envelope_data, + ) in pending: + try: + chat_session_id = UUID(session_id) + except ValueError: + logger.warning( + "remote_unaddressed_invalid_session_id session_id={}", session_id + ) + continue + async with async_session_factory() as db: + session_row = await db.get(ChatSession, chat_session_id) + if session_row is None or not _is_addressable_session(session_row): + continue + + turn = self._turns.get(session_id) + if turn is None: + turn = _TurnDeliveryState( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + title=session_row.title or "Task", + ) + self._turns[session_id] = turn + if turn.completion_sent: + continue + turn.completion_sent = True + await self._finalize_turn( + turn, + error_message=( + None + if event_type == "done" + else envelope_data.get("message", "An error occurred.") + ), + ) + + def clear_turn(self, session_id: str) -> None: + """Clear delivery state for a completed turn.""" + turn = self._turns.pop(session_id, None) + if turn is not None: + self._stop_typing(turn) + if turn.lifecycle_correlation_id is not None: + self._edit_budget.discard(turn.lifecycle_correlation_id) + + +def _redact_text(text: str) -> str: + """Apply remote-channel outbound redaction.""" + try: + from app.agent.outbound_redaction import OutboundContext, protect_outbound_text + + protected, _report = protect_outbound_text( + text, context=OutboundContext(channel="remote") + ) + return protected + except Exception: + return text + + +def _split_text(text: str) -> list[str]: + """Split *text* into chunks of at most ``_TELEGRAM_MAX_MESSAGE_LENGTH`` chars. + + Splits on paragraph boundaries when possible, falling back to line + boundaries, then hard character splits. Preserves Unicode safety by + never splitting a surrogate pair or combining sequence. + """ + if len(text) <= _TELEGRAM_MAX_MESSAGE_LENGTH: + return [text] + + chunks: list[str] = [] + remaining = text + while remaining: + if len(remaining) <= _TELEGRAM_MAX_MESSAGE_LENGTH: + chunks.append(remaining) + break + + # Try to split at the last paragraph break within the limit. + split_at = _find_split_point(remaining, _TELEGRAM_MAX_MESSAGE_LENGTH) + chunks.append(remaining[:split_at]) + remaining = remaining[split_at:].lstrip("\n") + + return chunks + + +def _find_split_point(text: str, max_length: int) -> int: + """Find the best split point within *max_length* characters.""" + # Prefer paragraph break + idx = text.rfind("\n\n", 0, max_length) + if idx > 0: + return idx + 2 + + # Fall back to line break + idx = text.rfind("\n", 0, max_length) + if idx > 0: + return idx + 1 + + # Fall back to space + idx = text.rfind(" ", 0, max_length) + if idx > 0: + return idx + 1 + + # Hard split + return max_length + + +__all__ = ["RemoteProjection", "_split_text"] diff --git a/app/remote/pairing.py b/app/remote/pairing.py new file mode 100644 index 00000000..6633dcb5 --- /dev/null +++ b/app/remote/pairing.py @@ -0,0 +1,638 @@ +"""One-tap pairing and principal authorization (AC-7, AC-8, AC-9, AC-10). + +``PairingService`` is the transport-neutral home for: + +- minting the single-use pairing token behind **Connect phone**'s QR code + and ``https://t.me/?start=`` deep link (AC-7); +- binding a channel-attested principal/destination to a connection once + every check passes, and only then (AC-8); +- silencing every failure behind one indistinguishable refusal shape, with + per-principal and connection-wide rate limiting so a flood of bad + attempts cannot be used as a response/spam amplifier (AC-9); +- authorizing subsequent inbound actions against the live pairing row, with + no caching lag after ``unpair`` (AC-10). + +Nothing here imports a Telegram payload type — a later task's adapter +translates a raw update into a :class:`~app.remote.contracts.RemotePrincipal` +and booleans (``is_private_chat``, ``is_bot_sender``) before calling +``consume``. + +Design choice — one pairing per connection (v1 product limit) +---------------------------------------------------------------- +The spec's UI exposes **Connect phone** and a separate **Unpair phone** +action, and states "The UI can mint a replacement [token] without changing +the connection or bot token" for a lost/expired token. Read together, that +implies ``issue_link`` should stay a cheap, always-available, in-memory +operation — it never touches the database and never needs to know whether a +pairing already exists. The one-pairing-per-connection limit is instead +enforced at the single point a :class:`~app.models.remote.RemotePairing` row +would actually be written: :meth:`PairingService.consume`. If a connection +already has an active pairing, ``consume`` refuses — using the exact same +refusal shape as every other failure — rather than silently replacing it. +Re-pairing therefore requires an explicit ``unpair`` first, matching the +UI's two distinct actions. + +Token custody +------------- +A pairing token is generated with ``secrets.token_urlsafe(16)`` (128 bits of +entropy, a strict subset of Telegram's ``[A-Za-z0-9_-]`` ``start``-parameter +alphabet, 22 characters — comfortably under the 64-character limit). It +lives only in an in-process dict guarded by a lock, with a ten-minute +monotonic expiry, and is removed the instant it is either successfully +consumed or found expired/invalid. It is never written to the database and +never logged. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import hashlib +import hmac +import secrets +import threading +import time +from collections import deque +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum +from uuid import UUID + +from loguru import logger +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.remote import RemoteConnection, RemotePairing +from app.remote.contracts import RemotePrincipal + +__all__ = [ + "DEFAULT_CONNECTION_RATE_LIMIT", + "DEFAULT_PER_PRINCIPAL_RATE_LIMIT", + "DEFAULT_RATE_LIMIT_WINDOW_SECONDS", + "PAIRING_TOKEN_TTL_SECONDS", + "PairingCodeExpired", + "PairingCodeMismatch", + "PairingCodeRateLimited", + "PairingCodeResult", + "PairingLink", + "PairingService", +] + +#: AC-7: the pairing token expires after ten minutes. +PAIRING_TOKEN_TTL_SECONDS = 600.0 + +#: AC-9 defaults: generous enough for a legitimate retry (e.g. a corrected +#: private-chat attempt) but bounded so pairing attempts cannot be used as a +#: response/spam amplifier. Callers may override per-instance. +DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60.0 +DEFAULT_PER_PRINCIPAL_RATE_LIMIT = 5 +DEFAULT_CONNECTION_RATE_LIMIT = 20 + +_MAX_LABEL_LENGTH = 120 + +# ── pairing-code constants and helpers ──────────────────────────────────── + +_PAIRING_CODE_LENGTH: int = 8 +_PAIRING_CODE_TTL_SECONDS: int = 600 # 10 minutes +_PAIRING_CODE_MAX_ATTEMPTS: int = 5 +_PAIRING_CODE_RATE_LIMIT_WINDOW: float = 300.0 # 5 minutes + +# Server-side pepper. In tests this default is fine; production reads it +# from the same secret store as ``EVOFLUX_DESKTOP_TOKEN``. +_PAIRING_CODE_PEPPER = b"evoflux-pairing-code-v1" + +log = logger.bind(name="remote.pairing") + + +def _random_digits(length: int) -> str: + """Return *length* cryptographically-random decimal digits.""" + return "".join(str(secrets.randbelow(10)) for _ in range(length)) + + +def _hash_pairing_code(raw_code: str) -> str: + """Return a hex HMAC-SHA-256 digest of *raw_code*.""" + return hmac.new(_PAIRING_CODE_PEPPER, raw_code.encode(), hashlib.sha256).hexdigest() + + +def _verify_pairing_code(raw_code: str, code_hash: str | None) -> bool: + """Constant-time comparison of *raw_code* against stored *code_hash*.""" + if code_hash is None: + return False + expected = _hash_pairing_code(raw_code) + return hmac.compare_digest(expected, code_hash) + + +# ── pairing-code exceptions ────────────────────────────────────────────── + + +class PairingCodeExpired(Exception): + """The phone-side ``/pair`` code has passed its TTL.""" + + +class PairingCodeMismatch(Exception): + """The submitted code does not match the pending hash.""" + + +class PairingCodeRateLimited(Exception): + """Too many failed ``/pair`` attempts for the current pending code.""" + + +@dataclasses.dataclass(frozen=True) +class PairingCodeResult: + """Value returned by :meth:`PairingService.issue_pair_code`. + + ``display_code`` is the user-facing value with digit grouping + (e.g. ``"1234 5678"``). ``raw_code`` is the ungrouped digit string + that must be hashed before storage. ``expires_at`` is a wall-clock + ``datetime`` so the UI can render a countdown. + """ + + display_code: str + raw_code: str + expires_at: datetime + + +@dataclass(frozen=True) +class PairingLink: + """Returned by :meth:`PairingService.issue_link`. + + ``url`` is the fully-formed deep link + (``https://t.me/?start=``). ``qr_payload`` is what + the UI should encode into the QR code; for a Telegram deep link that is + the identical URL, since scanning it must resolve to the same Start + action as tapping **Open Telegram**. ``expires_at`` is a wall-clock UTC + timestamp for display only — the token's actual liveness check uses an + internal monotonic clock. + """ + + url: str + qr_payload: str + expires_at: datetime + + +class _SlidingWindowRateLimiter: + """Per-key sliding-window limiter. + + Same algorithm as + ``app.services.webbridge_pairing_service.WebBridgeRateLimiter``, kept as + a small private copy here rather than imported: it is a generic, + self-contained utility with no WebBridge-specific concept in it, and + keeping it inside ``app/remote`` avoids a cross-feature dependency on an + unrelated module for a dozen lines of logic. + """ + + def __init__(self, *, window_seconds: float) -> None: + if window_seconds <= 0: + raise ValueError("window_seconds must be positive") + self._window_seconds = window_seconds + self._events: dict[str, deque[float]] = {} + self._lock = threading.Lock() + + def allow(self, key: str, limit: int, *, now: float | None = None) -> bool: + timestamp = time.monotonic() if now is None else now + cutoff = timestamp - self._window_seconds + with self._lock: + events = self._events.setdefault(key, deque()) + while events and events[0] <= cutoff: + events.popleft() + if len(events) >= limit: + return False + events.append(timestamp) + return True + + +@dataclass +class _PendingToken: + connection_id: UUID + expires_at: float # monotonic seconds + + +class ConsumeResult(Enum): + """Discriminated outcome of :meth:`PairingService.consume`. + + The previous ``RemotePairing | None`` return made it impossible for the + caller to distinguish "someone else already paired from the same QR code" + from "token expired" or "invalid". The runtime now sends a targeted + feedback message to the second scanner when the result is ``ALREADY_PAIRED``. + """ + + #: Pairing succeeded — the returned ``RemotePairing`` is authoritative. + PAIRED = "paired" + #: The connection already has an active pairing (likely from a concurrent + #: scan of the same QR code). The caller should tell the second scanner. + ALREADY_PAIRED = "already_paired" + #: Token was missing, expired, rate-limited, or did not match. + INVALID = "invalid" + + +@dataclass +class ConsumeOutcome: + """Typed wrapper returned by :meth:`PairingService.consume`.""" + + result: ConsumeResult + pairing: RemotePairing | None = None + + +class PairingService: + """Issues one-tap pairing links and authorizes paired principals.""" + + def __init__( + self, + *, + token_ttl_seconds: float = PAIRING_TOKEN_TTL_SECONDS, + per_principal_rate_limit: int = DEFAULT_PER_PRINCIPAL_RATE_LIMIT, + connection_rate_limit: int = DEFAULT_CONNECTION_RATE_LIMIT, + rate_limit_window_seconds: float = DEFAULT_RATE_LIMIT_WINDOW_SECONDS, + ) -> None: + if token_ttl_seconds <= 0: + raise ValueError("token_ttl_seconds must be positive") + self._token_ttl_seconds = token_ttl_seconds + self._per_principal_rate_limit = per_principal_rate_limit + self._connection_rate_limit = connection_rate_limit + self._tokens: dict[str, _PendingToken] = {} + self._tokens_lock = threading.Lock() + #: Serializes consume()'s whole check-then-act sequence (existing- + #: pairing SELECT through token pop and RemotePairing insert) across + #: concurrent calls. This app is single-process (see e.g. + #: app/services/memory_stream_store.py's module docstring and + #: WebBridgeTicketStore's equivalent per-instance lock in + #: app/services/webbridge_pairing_service.py), so a plain + #: asyncio.Lock — safely held across await points — is the + #: right-sized fix: without it, two concurrent consume() calls can + #: both pass the "no existing pairing" check before either commits + #: (breaking the one-pairing-per-connection limit), or both race + #: past a since-consumed token toward a duplicate insert. + self._consume_lock = asyncio.Lock() + self._rate_limiter = _SlidingWindowRateLimiter( + window_seconds=rate_limit_window_seconds + ) + + # Phone-first pairing code rate-limiting (mirrors the link-based + # counters above but keyed separately so the two flows are + # independent). + self._pair_code_attempts: dict[UUID, int] = {} + self._pair_code_issued_at: dict[UUID, float] = {} + self._pairing_code_ttl: float = float(_PAIRING_CODE_TTL_SECONDS) + self._pairing_code_max_attempts: int = _PAIRING_CODE_MAX_ATTEMPTS + self._rate_limit_window: float = _PAIRING_CODE_RATE_LIMIT_WINDOW + + # ── issue_link ─────────────────────────────────────────────────────── + + def issue_link( + self, connection: RemoteConnection, *, now: float | None = None + ) -> PairingLink: + """Mint a single-use pairing token and return its deep link. + + Pure in-memory operation (no database access): it can always be + called to produce a fresh link, including as a replacement for a + lost or expired token, without any connection-state check. See the + module docstring for why the one-pairing-per-connection limit is + enforced in :meth:`consume` instead. + """ + issued_at = time.monotonic() if now is None else now + token = secrets.token_urlsafe(16) + with self._tokens_lock: + self._tokens[token] = _PendingToken( + connection_id=connection.id, + expires_at=issued_at + self._token_ttl_seconds, + ) + url = f"https://t.me/{connection.adapter_username}?start={token}" + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=self._token_ttl_seconds + ) + return PairingLink(url=url, qr_payload=url, expires_at=expires_at) + + # ── consume ────────────────────────────────────────────────────────── + + async def consume( + self, + session: AsyncSession, + token: str, + principal: RemotePrincipal, + *, + is_private_chat: bool, + is_bot_sender: bool, + now: float | None = None, + ) -> ConsumeOutcome: + """Attempt to bind *principal* using *token*. + + Returns a :class:`ConsumeOutcome` whose ``result`` discriminant tells + the caller exactly what happened: + + - ``PAIRED`` — success; ``outcome.pairing`` is the persisted row. + - ``ALREADY_PAIRED`` — the connection already has an active pairing + (most likely a concurrent scan of the same QR code). The caller + should tell the second scanner that the device was already claimed. + - ``INVALID`` — token missing, expired, rate-limited, wrong + connection, non-private chat, or bot sender. Silent rejection. + + A rejected attempt never consumes the token — only a *successful* + bind does — so a legitimate retry from a corrected context (for + example a private chat after a group-chat attempt) can still + succeed before the token's real expiry. + + The existing-pairing check, token pop, and insert run as one atomic + critical section under ``self._consume_lock`` so two concurrent + calls cannot both observe "no existing pairing" and both insert, and + cannot both pop the same token and both proceed toward insert. + """ + _invalid = ConsumeOutcome(result=ConsumeResult.INVALID) + timestamp = time.monotonic() if now is None else now + + if not self._rate_limiter.allow( + f"pairing:principal:{principal.principal_id}", + self._per_principal_rate_limit, + now=timestamp, + ): + return _invalid + if not self._rate_limiter.allow( + f"pairing:connection:{principal.connection_id}", + self._connection_rate_limit, + now=timestamp, + ): + return _invalid + + async with self._consume_lock: + with self._tokens_lock: + pending = self._tokens.get(token) + if pending is None or pending.expires_at <= timestamp: + return _invalid + if pending.connection_id != principal.connection_id: + return _invalid + if not is_private_chat or is_bot_sender: + return _invalid + + existing = ( + await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == principal.connection_id + ) + ) + ).first() + if existing is not None: + return ConsumeOutcome(result=ConsumeResult.ALREADY_PAIRED) + + # Every check passed: burn the token now, then persist the + # binding. Checking pop()'s return value is correct + # defense-in-depth even under the lock — it is the only thing + # standing between a racing/duplicate consume of the very same + # token and an unhandled IntegrityError from a duplicate insert. + with self._tokens_lock: + popped = self._tokens.pop(token, None) + if popped is None: + return _invalid + + display = principal.display[:_MAX_LABEL_LENGTH] + pairing = RemotePairing( + connection_id=principal.connection_id, + principal_id=principal.principal_id, + destination_id=principal.destination_id, + label=display or "Paired device", + display=display, + ) + session.add(pairing) + await session.commit() + await session.refresh(pairing) + return ConsumeOutcome(result=ConsumeResult.PAIRED, pairing=pairing) + + # ── authorize ──────────────────────────────────────────────────────── + + async def authorize( + self, + session: AsyncSession, + *, + connection_id: UUID, + principal_id: str, + now: float | None = None, + ) -> RemotePairing | None: + """Authorize an inbound action for *principal_id* on *connection_id*. + + Reads the pairing row fresh from the database on every call — there + is no cache to go stale, so a pairing deleted by :meth:`unpair` is + unauthorized on the very next call (AC-10). Also rate-limited, + per-principal and connection-wide (AC-9), returning ``None`` (the + same refusal as "no such pairing") when the limit is exceeded. + """ + timestamp = time.monotonic() if now is None else now + if not self._rate_limiter.allow( + f"authorize:principal:{principal_id}", + self._per_principal_rate_limit, + now=timestamp, + ): + return None + if not self._rate_limiter.allow( + f"authorize:connection:{connection_id}", + self._connection_rate_limit, + now=timestamp, + ): + return None + + pairing = ( + await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection_id, + RemotePairing.principal_id == principal_id, + ) + ) + ).first() + if pairing is None: + return None + pairing.last_seen_at = datetime.now(timezone.utc) + session.add(pairing) + await session.commit() + await session.refresh(pairing) + return pairing + + # ── unpair ─────────────────────────────────────────────────────────── + + async def unpair(self, session: AsyncSession, connection_id: UUID) -> bool: + """Delete *connection_id*'s pairing row(s), if any (AC-10). + + The delete is committed before returning, so a subsequent + :meth:`authorize` call for the former principal fails immediately — + there is no in-memory cache to invalidate. Returns ``True`` if a row + was deleted. + """ + rows = ( + await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection_id + ) + ) + ).all() + if not rows: + return False + for row in rows: + await session.delete(row) + await session.commit() + return True + + # ── phone-first pairing code (AC-7, AC-8, AC-9) ─────────────────────── + + async def issue_pair_code( + self, + session: AsyncSession, + connection_id: UUID, + *, + now: float | None = None, + ) -> PairingCodeResult: + """Generate a one-time8-digit pairing code for *connection_id*. + + The code is hashed before storage so a database leak does not expose + the plaintext value. Any previously-pending pairing-code row for the + same connection is replaced so at most one code is active at a time. + """ + stale = ( + await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection_id, + RemotePairing.pair_code_hash.is_not(None), # ty: ignore[unresolved-attribute] + ) + ) + ).all() + for row in stale: + await session.delete(row) + if stale: + await session.flush() + + raw_code = _random_digits(_PAIRING_CODE_LENGTH) + display_code = f"{raw_code[:4]} {raw_code[4:]}" + code_hash = _hash_pairing_code(raw_code) + + now_s = time.monotonic() if now is None else now + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=_PAIRING_CODE_TTL_SECONDS + ) + + pending = RemotePairing( + connection_id=connection_id, + principal_id="", + destination_id="", + label="", + pair_code_hash=code_hash, + pair_code_expires_at=expires_at, + ) + session.add(pending) + await session.commit() + + self._pair_code_attempts[connection_id] = 0 + self._pair_code_issued_at[connection_id] = now_s + + log.info( + "pair_code_issued connection_id={} expires_at={}", + connection_id, + expires_at.isoformat(), + ) + return PairingCodeResult( + display_code=display_code, + raw_code=raw_code, + expires_at=expires_at, + ) + + async def verify_pair_code( + self, + session: AsyncSession, + *, + connection_id: UUID, + principal: RemotePrincipal, + raw_code: str, + now: float | None = None, + is_private_chat: bool = True, + is_bot_sender: bool = False, + ) -> RemotePairing: + """Verify a phone-submitted pairing code and bind the principal. + + Raises :class:`PairingCodeExpired`, :class:`PairingCodeMismatch`, or + :class:`PairingCodeRateLimited` on failure. On success the pairing + row's ``principal_id`` / ``destination_id`` are set and the code hash + is cleared so the code cannot be replayed. + """ + if is_bot_sender: + raise PairingCodeMismatch() + if not is_private_chat: + raise PairingCodeMismatch() + + now_s = time.monotonic() if now is None else now + + attempts = self._pair_code_attempts.get(connection_id, 0) + if attempts >= self._pairing_code_max_attempts: + issued_at = self._pair_code_issued_at.get(connection_id, 0.0) + if (now_s - issued_at) < self._rate_limit_window: + raise PairingCodeRateLimited() + self._pair_code_attempts[connection_id] = 0 + + pending = ( + await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection_id, + RemotePairing.pair_code_hash.is_not(None), # ty: ignore[unresolved-attribute] + ) + ) + ).first() + + if pending is None: + self._pair_code_attempts[connection_id] = ( + self._pair_code_attempts.get(connection_id, 0) + 1 + ) + raise PairingCodeMismatch() + + if principal.connection_id != connection_id: + self._pair_code_attempts[connection_id] = ( + self._pair_code_attempts.get(connection_id, 0) + 1 + ) + raise PairingCodeMismatch() + + if not _verify_pairing_code(raw_code, pending.pair_code_hash): + self._pair_code_attempts[connection_id] = ( + self._pair_code_attempts.get(connection_id, 0) + 1 + ) + raise PairingCodeMismatch() + + # Check wall-clock expiry. + if pending.pair_code_expires_at is not None: + now_utc = datetime.now(timezone.utc) + if now_utc > pending.pair_code_expires_at: + await session.delete(pending) + await session.commit() + raise PairingCodeExpired() + + # Check monotonic TTL as well (for test-injected now). + # Skip if the issued-at entry is absent (e.g. process restarted and + # the in-memory dict was lost) — the wall-clock check above already + # covers real expiry; a missing monotonic timestamp is not evidence + # of timeout. + issued_at = self._pair_code_issued_at.get(connection_id) + if issued_at is not None and (now_s - issued_at) > self._pairing_code_ttl: + await session.delete(pending) + await session.commit() + raise PairingCodeExpired() + + # ── success: bind principal ──────────────────────────────────── + pending.principal_id = principal.principal_id + pending.destination_id = principal.destination_id + pending.pair_code_hash = None + pending.pair_code_expires_at = None + pending.last_seen_at = datetime.now(timezone.utc) + session.add(pending) + await session.commit() + await session.refresh(pending) + + self._pair_code_attempts.pop(connection_id, None) + self._pair_code_issued_at.pop(connection_id, None) + + log.info( + "pair_code_verified connection_id={} principal_id={}", + connection_id, + principal.principal_id, + ) + return pending + + +# Process-wide singleton. Pairing tokens and rate-limit windows live only in +# this instance's memory (AC-7: "exists only in process memory"), so every +# caller — the HTTP route that mints a link and the Telegram adapter's +# inbound dispatch that later consumes it — must share this exact object. +# A second `PairingService()` starts with an empty token store and can never +# see a token minted through this one; there is deliberately no other way to +# reach a `PairingService` in this codebase. +pairing_service = PairingService() diff --git a/app/remote/runtime.py b/app/remote/runtime.py new file mode 100644 index 00000000..2fd34962 --- /dev/null +++ b/app/remote/runtime.py @@ -0,0 +1,591 @@ +"""Process singleton owning the remote connection's lazy adapter lifecycle. + +AC-1 ("off and free by default") is the load-bearing contract of this +module: with no enabled, credentialed connection, nothing here ever +imports ``app.remote.telegram.adapter`` or ``app.remote.telegram.client``, +creates an ``asyncio.Task``, or makes a network call. Every reference to +either Telegram module is therefore a *function-local* import inside a +factory that only runs once a connection is actually enabled and +credentialed — never a module-level import here. + +:data:`remote_runtime` is constructed once per process and owns at most one +live :class:`~app.remote.contracts.RemoteAdapter` — the v1 product limit is +one connection (AC-3), so there is never more than one to own. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING +from uuid import UUID + +from loguru import logger + +from app.core.credential_store import CredentialStoreError +from app.remote.connection_service import ( + CredentialStoreFactory, + RemoteConnectionService, + default_credential_store_factory, +) +from app.remote.contracts import ( + RemoteAdapter, + RemoteAdapterKind, + RemoteAdapterStatus, + RemoteAdapterValidationError, + RemoteConnectionState, + RemoteInboundAction, + RemoteOutboundMessage, + RemoteOutboundPriority, + ValidatedRemoteIdentity, +) + +if TYPE_CHECKING: + from app.remote.actions import RemoteActionService + from app.remote.gates import RemoteGateBridge + from app.remote.outbound import RemoteProjection + +__all__ = [ + "AdapterConstructor", + "RemoteActionHandler", + "RemoteRuntime", + "TelegramAdapterFactory", + "remote_runtime", +] + +#: Invoked for every classified inbound action the adapter dispatches. See +#: :class:`app.remote.telegram.adapter.RemoteActionHandler` — redefined here +#: (rather than imported) so this module never imports anything under +#: ``app.remote.telegram`` at module scope. +RemoteActionHandler = Callable[[RemoteInboundAction], Awaitable[object]] + +#: Builds one connection's live adapter from its token. The production +#: implementation (:func:`_construct_telegram_adapter`) imports +#: ``app.remote.telegram.adapter`` lazily, inside the function body, so +#: merely *referencing* this type or constructing a :class:`RemoteRuntime` +#: never triggers that import. +AdapterConstructor = Callable[[UUID, str, RemoteActionHandler], RemoteAdapter] + + +class TelegramAdapterFactory: + """Production ``RemoteAdapterFactory`` (see ``app.remote.contracts``). + + Validates a candidate bot token by calling Telegram's ``getMe`` — the + only network call this factory ever makes, and only when a caller + (``RemoteConnectionService.create_connection``/``update_token``, reached + from ``POST``/``PUT /api/remote/connections...``) actually invokes + :meth:`validate_token`. ``app.remote.telegram.client`` is imported + lazily inside this method so constructing this factory, or importing + this module, never pulls it into ``sys.modules`` (AC-1). + """ + + async def validate_token( + self, adapter: RemoteAdapterKind, token: str + ) -> ValidatedRemoteIdentity: + from app.remote.telegram.client import ( + TelegramApiError, + TelegramClient, + TelegramMalformedResponseError, + TelegramTransportError, + ) + + client = TelegramClient(token) + try: + me = await client.get_me() + except ( + TelegramApiError, + TelegramTransportError, + TelegramMalformedResponseError, + ) as exc: + # Never include the token or the raw provider error (AC-5). + raise RemoteAdapterValidationError( + "The bot token could not be validated with Telegram." + ) from exc + finally: + await client.aclose() + + if not me.is_bot: + raise RemoteAdapterValidationError( + "The token must belong to a bot account, not a user account." + ) + + return ValidatedRemoteIdentity( + adapter=adapter, principal_id=str(me.id), username=me.username or "" + ) + + +def _default_connection_service() -> RemoteConnectionService: + """The production ``RemoteConnectionService``. + + ``TelegramAdapterFactory()`` construction does not itself import + Telegram — only a subsequent ``validate_token`` call does — so building + this service here is safe even when no connection is configured. + """ + return RemoteConnectionService(adapter_factory=TelegramAdapterFactory()) + + +def _construct_telegram_adapter( + connection_id: UUID, token: str, on_action: RemoteActionHandler +) -> RemoteAdapter: + """Build the real Telegram adapter for one enabled, credentialed + connection. + + This is the one place in this module that imports + ``app.remote.telegram.adapter`` — and it happens only when + :meth:`RemoteRuntime._start_locked` has already confirmed a connection + is enabled and its vault credential is present (AC-1). + """ + from app.remote.telegram.adapter import TelegramAdapter + + return TelegramAdapter( + connection_id=connection_id, token=token, on_action=on_action + ) + + +class RemoteRuntime: + """Owns the lifecycle of at most one live remote adapter. + + Every public method is safe to call at any time, including before any + connection exists: ``start``/``reconcile_connection`` are no-ops unless + the current connection is both ``enabled`` and has a vault credential, + and ``stop``/``status`` never raise even when nothing is running. + + Constructor parameters exist purely for test injection (mirroring + :class:`~app.remote.connection_service.RemoteConnectionService`'s own + ``credential_store_factory`` pattern) — production code always + constructs the module-level :data:`remote_runtime` with its defaults. + """ + + def __init__( + self, + *, + connection_service_factory: Callable[[], RemoteConnectionService] | None = None, + credential_store_factory: CredentialStoreFactory | None = None, + adapter_constructor: AdapterConstructor | None = None, + ) -> None: + self._connection_service_factory = ( + connection_service_factory or _default_connection_service + ) + self._credential_store_factory = ( + credential_store_factory or default_credential_store_factory + ) + self._adapter_constructor = adapter_constructor or _construct_telegram_adapter + + #: Serializes start/stop/reconcile so concurrent calls (e.g. two + #: rapid route mutations) cannot both observe "nothing running" and + #: both start an adapter, or interleave a stop with a start. + self._lock = asyncio.Lock() + self._adapter: RemoteAdapter | None = None + self._connection_id: UUID | None = None + + #: Stream projection for outbound delivery. Created lazily. + self._projection: RemoteProjection | None = None + self._observer_unregister: Callable[[], None] | None = None + self._bridge: RemoteGateBridge | None = None + self._actions: "RemoteActionService | None" = None + + async def start(self) -> None: + """Start the current connection's adapter, if any (AC-1). + + Called once from the app lifespan. A no-op when no connection + exists, the connection is disabled, its vault credential is + missing, or an adapter is already running. + """ + async with self._lock: + if self._adapter is not None: + return + await self._start_locked() + + async def stop(self) -> None: + """Stop the running adapter, if any. Idempotent (AC-13) — always + safe to call, including before ``start()`` was ever called.""" + async with self._lock: + await self._stop_locked() + + async def reconcile_connection(self, connection_id: UUID) -> None: + """Re-evaluate runtime state after a connection mutation. + + Called by the HTTP routes after any create, enable/disable, token + replacement, or removal so the effect is immediate rather than + requiring a process restart. v1 permits at most one connection + (AC-3), so the correct reaction to *any* mutation is always the + same: stop whatever is currently running, then re-evaluate the + current database state from scratch. If that state is still (or + newly) enabled and credentialed, a fresh adapter starts — bound to + a replaced token when one was just rotated; otherwise nothing + restarts. + """ + logger.debug("remote_runtime_reconcile connection_id={}", connection_id) + async with self._lock: + await self._stop_locked() + await self._start_locked() + + @property + def adapter(self) -> RemoteAdapter | None: + """The currently running adapter, if any. Read-only — callers must + not mutate the returned instance's lifecycle.""" + return self._adapter + + def status(self, connection_id: UUID) -> RemoteAdapterStatus: + """Safe, diagnosable status for *connection_id* (AC-34). + + Returns the live adapter's status when it is the one currently + running; otherwise a ``disabled`` shape naming *connection_id*. + Never raises and never imports or constructs anything + Telegram-specific. + """ + if self._adapter is not None and self._connection_id == connection_id: + return self._adapter.status() + return RemoteAdapterStatus( + connection_id=connection_id, state=RemoteConnectionState.DISABLED + ) + + # ------------------------------------------------------------------ + # Internal — callers must hold ``self._lock``. + # ------------------------------------------------------------------ + + async def _start_locked(self) -> None: + from app.core.db import read_session_factory + + async with read_session_factory() as session: + connections = await self._connection_service_factory().list(session) + connection = connections[0] if connections else None + if connection is None or not connection.enabled: + return + + store = self._credential_store_factory(connection.id) + try: + token = store.load() + except CredentialStoreError as exc: + logger.error( + "remote_runtime_credential_load_failed connection_id={} error={}", + connection.id, + exc, + ) + return + if not token: + logger.warning( + "remote_runtime_credential_missing connection_id={}", connection.id + ) + return + + adapter = self._adapter_constructor(connection.id, token, self._handle_action) + await adapter.start() + self._adapter = adapter + self._connection_id = connection.id + + # Register the outbound projection as a stream observer. + from app.remote.outbound import RemoteProjection + + projection = RemoteProjection() + projection.set_adapter(adapter) + self._projection = projection + + # Restore the active-pairing cache so notifications for sessions + # started before this process restarted (or started on the desktop, + # never explicitly register_session-ed) can still be routed. v1 + # permits at most one pairing per connection (AC-3), so `.first()` + # is always the right — and only — row to cache. + from sqlmodel import select + + from app.models.remote import RemotePairing + + async with read_session_factory() as pairing_session: + pairing = ( + await pairing_session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection.id + ) + ) + ).first() + if pairing is not None: + projection.set_active_pairing( + connection_id=str(pairing.connection_id), + destination_id=pairing.destination_id, + notify_scope=pairing.notify_scope, + principal_id=pairing.principal_id, + ) + + # Create the gate bridge for callback resolution. + from app.remote.gates import RemoteGateBridge + + self._bridge = RemoteGateBridge(adapter=adapter) + projection.set_bridge(self._bridge) + + # Slash commands (/help, /status, /new, /stop, /unpair, /actions) and + # the More-actions menu — shares the same PairingService singleton + # every other pairing-aware caller uses (see the module docstring in + # app/remote/pairing.py for why a locally-constructed one would be + # silently broken). + from app.remote.actions import RemoteActionService + from app.remote.pairing import pairing_service as _shared_pairing_service + + self._actions = RemoteActionService( + pairing_service=_shared_pairing_service, + adapter=adapter, + status_provider=lambda: self.status(connection.id), + ) + projection.set_actions(self._actions) + self._actions.set_projection(projection) + + from app.services.memory_stream_store import register_observer + + self._observer_unregister = register_observer(projection.observe) + + logger.info("remote_runtime_started connection_id={}", connection.id) + + async def _stop_locked(self) -> None: + adapter, self._adapter = self._adapter, None + self._connection_id = None + + # Unregister the stream observer before stopping the adapter. + if self._observer_unregister is not None: + self._observer_unregister() + self._observer_unregister = None + if self._projection is not None: + self._projection.set_adapter(None) + self._projection.set_bridge(None) + self._projection.set_actions(None) + self._projection.clear_active_pairing() + self._projection = None + self._bridge = None + if self._actions is not None: + self._actions.set_projection(None) + self._actions = None + + if adapter is not None: + await adapter.stop() + + async def _handle_action(self, action: RemoteInboundAction) -> None: + """Dispatch one classified inbound action to the appropriate service. + + TEXT actions go to :class:`~app.remote.inbound.RemoteInboundService`; + PAIRING_START actions are consumed by + :class:`~app.remote.pairing.PairingService`; CALLBACK actions are + handled by :class:`~app.remote.gates.RemoteGateBridge`. + """ + from app.remote.contracts import RemoteInboundActionKind + + if action.kind == RemoteInboundActionKind.PAIRING_START: + await self._handle_pairing(action) + elif action.kind == RemoteInboundActionKind.TEXT: + if (action.text or "").strip().startswith("/"): + await self._handle_command(action) + else: + await self._handle_text(action) + elif action.kind == RemoteInboundActionKind.CALLBACK: + # Two independent, opaque token namespaces share the callback + # channel: gate replies (permission/question/plan) owned by + # RemoteGateBridge, and More-actions menu picks owned by + # RemoteActionService. Try the menu first — it's a plain dict + # membership check — and fall back to the gate bridge, which + # already degrades safely (logs + no-ops) on an unknown token. + handled = False + if self._actions is not None and action.callback_token is not None: + from app.core.db import async_session_factory + + async with async_session_factory() as callback_session: + handled = await self._actions.handle_action_callback( + action, callback_session + ) + if not handled and self._bridge is not None: + await self._bridge.handle_callback(action) + elif not handled and self._bridge is None: + logger.debug( + "remote_callback_no_bridge connection_id={} source_key={}", + action.connection_id, + action.source_key, + ) + else: + logger.debug( + "remote_inbound_action_unknown connection_id={} kind={}", + action.connection_id, + action.kind, + ) + + async def _handle_pairing(self, action: RemoteInboundAction) -> None: + """Consume a ``/start`` deep-link pairing token.""" + from app.core.db import async_session_factory + from app.remote.pairing import pairing_service + + token = action.pairing_token + if not token: + logger.debug( + "remote_pairing_start_no_token connection_id={}", + action.connection_id, + ) + return + + from app.remote.pairing import ConsumeResult + + async with async_session_factory() as session: + outcome = await pairing_service.consume( + session, + token, + action.principal, + is_private_chat=True, + is_bot_sender=False, + ) + + if outcome.result is ConsumeResult.PAIRED and outcome.pairing is not None: + pairing = outcome.pairing + logger.info( + "remote_pairing_success connection_id={} principal_id={}", + action.connection_id, + action.principal.principal_id, + ) + if self._projection is not None: + self._projection.set_active_pairing( + connection_id=str(action.connection_id), + destination_id=pairing.destination_id, + notify_scope=pairing.notify_scope, + principal_id=pairing.principal_id, + ) + # A silently-persisted pairing is indistinguishable from a + # failed one from the phone's side — confirm it with a + # starting point rather than a dead end (AC-54). + if self._adapter is not None and self._actions is not None: + text, buttons = self._actions.build_onboarding_card( + action, label=pairing.label + ) + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + ) + ) + elif outcome.result is ConsumeResult.ALREADY_PAIRED: + # Another device already claimed this QR code. Tell the second + # scanner explicitly so they know the code is spent. + logger.debug( + "remote_pairing_already_claimed connection_id={} principal_id={}", + action.connection_id, + action.principal.principal_id, + ) + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text="\u26a0\ufe0f This device is already connected to another phone. Only one phone can be paired at a time.", + buttons=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + else: + logger.debug( + "remote_pairing_rejected connection_id={} principal_id={}", + action.connection_id, + action.principal.principal_id, + ) + + async def _handle_command(self, action: RemoteInboundAction) -> None: + """Dispatch a ``/command`` to :class:`~app.remote.actions.RemoteActionService` + and send its result back. + + Unauthorized (unpaired sender) dispatches are answered with nothing, + matching every other refusal in this feature — a stranger sending + ``/help`` to a bot they haven't paired with must not learn anything + the bot is willing to say to a paired user. + """ + from app.core.db import async_session_factory + + if self._actions is None: + return + + async with async_session_factory() as session: + result = await self._actions.dispatch_command(session, action) + + if result is None: + return + + if result.status == "unauthorized": + logger.debug( + "remote_command_unauthorized connection_id={} principal_id={}", + action.connection_id, + action.principal.principal_id, + ) + return + + # ``/actions``, ``/settings``, and ``/changes`` already send their + # own message (with buttons, or a friendly no-active-task/no-files + # notice) inside RemoteActionService — sending the returned text + # again here would duplicate it. ``/health`` does NOT self-send + # (plain text, no buttons), so it relies on this fallback like + # every other command. + command = (action.text or "").strip().split(maxsplit=1)[0][1:].lower() + if command in ("actions", "settings", "changes"): + return + + if result.text and self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text=result.text, + priority=RemoteOutboundPriority.HIGH, + ) + ) + + async def _handle_text(self, action: RemoteInboundAction) -> None: + """Submit paired plain text to the inbound service.""" + from app.core.db import async_session_factory + from app.remote.inbound import RemoteInboundService + from app.remote.pairing import pairing_service + + # Share the same PairingService instance the routes mint links + # through and _handle_pairing consumes tokens against — a + # locally-constructed one would carry its own empty rate-limiter + # state and diverge from the single source of truth for pairing. + inbound_service = RemoteInboundService(pairing_service=pairing_service) + + async with async_session_factory() as session: + result = await inbound_service.handle_text(session, action) + + logger.debug( + "remote_text_handled connection_id={} status={} session_id={}", + action.connection_id, + result.status, + result.session_id, + ) + + # Register the session with the projection so outbound events are + # delivered back through Telegram. + if result.session_id is not None and self._projection is not None: + self._projection.register_session( + str(result.session_id), + connection_id=str(action.connection_id), + destination_id=action.principal.destination_id, + tags=frozenset( + {"remote_origin", f"remote_connection:{action.connection_id}"} + ), + ) + + # Create the one status message this phone-admitted turn owns, with + # a native typing indicator running alongside it (AC-22/AC-38). + if result.session_id is not None and self._projection is not None: + from app.core.db import async_session_factory as _sf + from app.models.chat import ChatSession + + async with _sf() as title_session: + session_row = await title_session.get(ChatSession, result.session_id) + self._projection.begin_phone_turn( + str(result.session_id), + connection_id=str(action.connection_id), + destination_id=action.principal.destination_id, + principal_id=action.principal.principal_id, + title=( + session_row.title + if session_row and session_row.title + else "New task" + ), + status=result.status, + response_mode=result.response_mode, + user_message=action.text, + ) + + +remote_runtime = RemoteRuntime() diff --git a/app/remote/severity.py b/app/remote/severity.py new file mode 100644 index 00000000..082e9095 --- /dev/null +++ b/app/remote/severity.py @@ -0,0 +1,43 @@ +"""Advisory-only command severity for permission cards. + +Display hint alone — never consulted by any gating decision. +``PermissionService`` (``app/agent/permission.py``) is the sole authority on +whether a request blocks; this module exists only so a phone operator sees +"rm -rf" and "read a file" rendered differently, never to decide anything. +""" + +from __future__ import annotations + +from typing import Literal + +Severity = Literal["high", "elevated", "normal"] + +__all__ = ["Severity", "derive_severity"] + +#: Substrings that mark a command as destructive regardless of tool. +_DESTRUCTIVE_SUBSTRINGS = ( + "rm -rf", + "rm -r -f", + "git clean -fd", + "git reset --hard", + "git push --force", + "git push -f", + "drop table", + "drop database", + "truncate table", +) + +#: Tools that run arbitrary code/commands — elevated even without a +#: destructive-pattern match. +_ELEVATED_TOOLS = frozenset({"shell", "bash", "python", "process", "rm"}) + + +def derive_severity(*, tool: str, command: str) -> Severity: + """Derive a display-only severity from a permission request's tool and + command text. Never raises; unknown input degrades to "normal".""" + lowered = command.lower() + if any(pattern in lowered for pattern in _DESTRUCTIVE_SUBSTRINGS): + return "high" + if tool in _ELEVATED_TOOLS: + return "elevated" + return "normal" diff --git a/app/remote/telegram/__init__.py b/app/remote/telegram/__init__.py new file mode 100644 index 00000000..74e18d58 --- /dev/null +++ b/app/remote/telegram/__init__.py @@ -0,0 +1,10 @@ +"""Telegram Bot API transport and adapter lifecycle. + +Everything that knows the Telegram wire format — request/response models, +the HTTP client, and the polling adapter — lives in this package. Generic +remote-access code (``app/remote/contracts.py``, a later +``RemoteService``) never imports from here; only this package imports the +provider-neutral types in ``app/remote/contracts.py``. +""" + +from __future__ import annotations diff --git a/app/remote/telegram/adapter.py b/app/remote/telegram/adapter.py new file mode 100644 index 00000000..0c89ac66 --- /dev/null +++ b/app/remote/telegram/adapter.py @@ -0,0 +1,613 @@ +"""Telegram adapter lifecycle: long-poll ownership, offset bookkeeping, and +Telegram error-taxonomy -> connection-state translation. + +Modeled on ``app/conductor/service.py``'s ``start``/``stop`` shape (read for +pattern inspiration only — never imported from, never modified): one owner +``asyncio.Task`` runs the poll loop, and an ``asyncio.Event`` makes both the +long poll and any backoff/rate-limit sleep interruptible so ``stop()`` never +waits out the configured poll timeout or a retry delay (AC-13). Genuine +promptness during an in-flight long-poll HTTP call additionally requires +cancelling the task itself (setting an event cannot interrupt an httpx +request that isn't awaiting that event) — again mirroring conductor's +``stop()``. + +This module owns Bot API error classification (AC-12): webhook conflicts are +cleared once and retried immediately; a concurrent ``getUpdates`` consumer is +``used_elsewhere`` with bounded backoff; an invalid/revoked token is terminal +for the current ``start()`` call; ``429`` honors ``retry_after`` verbatim; +transport/5xx failures use exponential backoff with jitter; and an +unreachable paired chat only affects delivery status (``phone_reachable``), +never the inbound poll loop — inbound polling and outbound delivery are +independent failure domains. +""" + +from __future__ import annotations + +import asyncio +import random +from collections import OrderedDict, defaultdict, deque +from collections.abc import Awaitable, Callable, Sequence +from datetime import UTC, datetime +from uuid import UUID + +import httpx +from loguru import logger + +from app.remote.contracts import ( + RemoteAdapterStatus, + RemoteConnectionState, + RemoteErrorClass, + RemoteInboundAction, + RemoteInboundActionKind, + RemoteOutboundMessage, + RemotePrincipal, +) +from app.remote.telegram.client import ( + TelegramApiError, + TelegramClient, + TelegramMalformedResponseError, + TelegramTransportError, +) +from app.remote.telegram.models import TelegramUpdate + +#: Invoked for every classified inbound action (text, callback, or pairing +#: start). The adapter advances its offset past an update only after this +#: awaitable completes without raising — see ``_dispatch``. Its return value +#: is not interpreted by the adapter; a later task (interactive ingress +#: integration) may attach meaning to it. +RemoteActionHandler = Callable[[RemoteInboundAction], Awaitable[object]] + +#: ``jitter(low, high)`` -> a float in that range. Defaults to +#: ``random.uniform``; tests inject a deterministic function. +JitterFn = Callable[[float, float], float] + +#: Injectable clock for ``last_successful_poll_at``. +ClockFn = Callable[[], datetime] + +DEFAULT_POLL_TIMEOUT_SECONDS = 50 +DEFAULT_BACKOFF_BASE_SECONDS = 1.0 +DEFAULT_BACKOFF_MAX_SECONDS = 60.0 +DEFAULT_RATE_LIMIT_FALLBACK_SECONDS = 1 + +#: Bound on how many un-acknowledged callback tokens the adapter remembers +#: (opaque callback token -> raw Telegram callback_query id) so a burst of +#: unanswered callbacks cannot grow this mapping unboundedly. Ephemeral by +#: design, like every other in-memory interaction token (spec: "Remote +#: interaction contract"). +MAX_PENDING_CALLBACK_IDS = 512 + +#: Bound on how many sent message ids ``clear_history`` remembers per +#: destination, so a long-running connection's history for one chat +#: cannot grow unboundedly. Telegram itself only allows a bot to delete +#: its own messages within 48 hours anyway, so remembering far more than +#: this would rarely help. +MAX_TRACKED_MESSAGES_PER_DESTINATION = 200 + + +def _default_clock() -> datetime: + return datetime.now(UTC) + + +class TelegramAdapter: + """Owns Telegram long-polling lifecycle and Bot API delivery for one + remote connection. + + Constructed from a bot token (never persisted here — the OS vault and + connection record are a different layer's job) and an ``on_action`` + callback that receives every normalized inbound action. + """ + + def __init__( + self, + *, + connection_id: UUID, + token: str, + on_action: RemoteActionHandler, + http_client: httpx.AsyncClient | None = None, + poll_timeout_seconds: int = DEFAULT_POLL_TIMEOUT_SECONDS, + backoff_base_seconds: float = DEFAULT_BACKOFF_BASE_SECONDS, + backoff_max_seconds: float = DEFAULT_BACKOFF_MAX_SECONDS, + jitter: JitterFn = random.uniform, + clock: ClockFn = _default_clock, + ) -> None: + self._connection_id = connection_id + self._client = TelegramClient(token, http_client=http_client) + self._on_action = on_action + self._poll_timeout_seconds = poll_timeout_seconds + self._backoff_base_seconds = backoff_base_seconds + self._backoff_max_seconds = backoff_max_seconds + self._jitter = jitter + self._clock = clock + + self._stop_event = asyncio.Event() + self._task: asyncio.Task[None] | None = None + + self._state = RemoteConnectionState.DISABLED + self._last_error_class = RemoteErrorClass.NONE + self._last_successful_poll_at: datetime | None = None + self._phone_reachable: bool | None = None + + #: correlation_id -> (chat_id, message_id), so ``edit()`` can find + #: the message it was asked to update. Ephemeral by design (spec: + #: "progress-message IDs ... are deliberately ephemeral"). + self._sent_messages: dict[str, tuple[int, int]] = {} + #: opaque callback token -> raw Telegram callback_query id. + self._pending_callback_ids: OrderedDict[str, str] = OrderedDict() + #: destination_id -> the most recent message ids this bot sent + #: there, for ``clear_history``. Bounded per destination + #: (MAX_TRACKED_MESSAGES_PER_DESTINATION); ephemeral, like every + #: other in-memory tracking this adapter keeps. + self._message_history: dict[str, deque[int]] = defaultdict( + lambda: deque(maxlen=MAX_TRACKED_MESSAGES_PER_DESTINATION) + ) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start polling. Safe to call repeatedly while already running.""" + if self._task is not None and not self._task.done(): + return + self._stop_event.clear() + self._state = RemoteConnectionState.STARTING + self._task = asyncio.create_task( + self._run(), name=f"telegram-poll-{self._connection_id}" + ) + + async def stop(self) -> None: + """Stop polling promptly and close the HTTP client. Safe to call + repeatedly, including before ``start()`` was ever called. + + Setting the stop event resolves an in-progress backoff/rate-limit + sleep instantly; cancelling the task is additionally required to + interrupt a genuinely in-flight long-poll HTTP call, which is not + awaiting that event. + """ + self._stop_event.set() + task, self._task = self._task, None + if task is not None and task is not asyncio.current_task() and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await self._client.aclose() + self._state = RemoteConnectionState.DISABLED + + def status(self) -> RemoteAdapterStatus: + """Safe, diagnosable runtime status (AC-34). + + ``phone_unreachable`` is reported as an overlay on top of an + otherwise-healthy ``polling`` state — a delivery failure never + overrides a *worse* poll-loop state (backoff, used_elsewhere, + invalid_token, ...), matching "inbound polling and outbound + delivery are independent failure domains" while still using one + connection-state enum for display. + """ + state = self._state + if state == RemoteConnectionState.POLLING and self._phone_reachable is False: + state = RemoteConnectionState.PHONE_UNREACHABLE + return RemoteAdapterStatus( + connection_id=self._connection_id, + state=state, + last_error_class=self._last_error_class, + last_successful_poll_at=self._last_successful_poll_at, + phone_reachable=self._phone_reachable, + ) + + # ------------------------------------------------------------------ + # Outbound delivery + # ------------------------------------------------------------------ + + async def send(self, message: RemoteOutboundMessage) -> None: + try: + sent = await self._client.send_text( + chat_id=message.destination_id, + text=message.text, + buttons=message.buttons, + ) + except TelegramApiError as exc: + self._record_delivery_failure(exc) + raise + self._record_delivery_success() + if message.correlation_id is not None: + self._sent_messages[message.correlation_id] = ( + sent.chat.id, + sent.message_id, + ) + self._message_history[message.destination_id].append(sent.message_id) + + async def edit(self, message: RemoteOutboundMessage) -> None: + target = ( + self._sent_messages.get(message.correlation_id) + if message.correlation_id is not None + else None + ) + if target is None: + # Nothing recorded to edit (e.g. after a restart — progress + # message IDs are ephemeral by design). Not this adapter's call + # whether that matters; it simply has nothing to correct. + return + chat_id, message_id = target + try: + await self._client.edit_text( + chat_id=chat_id, + message_id=message_id, + text=message.text, + buttons=message.buttons, + ) + except TelegramApiError as exc: + self._record_delivery_failure(exc) + raise + self._record_delivery_success() + + async def clear_history(self, destination_id: str) -> int: + """Delete every message this bot remembers sending to + *destination_id* (bounded to the most recent + ``MAX_TRACKED_MESSAGES_PER_DESTINATION`` — see ``_message_history``). + + Telegram only lets a bot delete its own messages, and only within + 48 hours (docs: "Message can only be deleted if it was sent less + than 48 hours ago"); a message outside that window (or already + deleted) is skipped rather than aborting the whole clear — this is + best-effort tidying, not a guarantee. Not this adapter's job to + chase down messages it never sent: the user's own messages in a + private chat cannot be deleted by the bot at all. + """ + message_ids = list(self._message_history.pop(destination_id, ())) + cleared = 0 + for message_id in message_ids: + try: + await self._client.delete_message( + chat_id=destination_id, message_id=message_id + ) + except ( + TelegramApiError, + TelegramTransportError, + TelegramMalformedResponseError, + ): + logger.debug( + "telegram_delete_message_failed destination_id={} message_id={}", + destination_id, + message_id, + ) + continue + cleared += 1 + return cleared + + async def answer_callback(self, callback_token: str) -> None: + raw_id = self._pending_callback_ids.pop(callback_token, None) + if raw_id is None: + # Expired/unknown token (e.g. after a restart) — nothing to + # acknowledge at the transport level. Not an error: the caller + # (a later task) is responsible for telling the user their + # action expired. + return + try: + await self._client.answer_callback(raw_id) + except TelegramApiError as exc: + if exc.error_code == 400: + # Expired or invalid callback query — Telegram requires + # answering within 10 seconds; after a server restart or + # network delay the query is stale. Log and continue so + # the actual action (session switch, summarize, etc.) still + # executes instead of being killed by the transport error. + logger.debug( + "remote_answer_callback_expired error={}", + exc, + ) + self._record_delivery_failure(exc) + return + self._record_delivery_failure(exc) + raise + self._record_delivery_success() + + async def indicate_typing(self, destination_id: str) -> None: + """Best-effort liveliness signal (AC-38): a native "still typing" + indicator while a turn is unresolved. Never fails the caller — this + is decoration, not a delivery guarantee, so it must never surface an + error the way ``send``/``edit``/``answer_callback`` do.""" + try: + await self._client.send_chat_action(chat_id=destination_id) + except ( + TelegramApiError, + TelegramTransportError, + TelegramMalformedResponseError, + ): + # Debug, not warning: this fires repeatedly per turn, so + # warning-level logging here would be spam rather than a + # signal (contrast _register_commands, a one-shot startup + # call where a warning is appropriate). + logger.debug( + "telegram_indicate_typing_failed connection_id={}", + self._connection_id, + ) + + def _record_delivery_failure(self, exc: TelegramApiError) -> None: + if exc.error_code == 403: + self._phone_reachable = False + self._last_error_class = RemoteErrorClass.PHONE_UNREACHABLE + + def _record_delivery_success(self) -> None: + self._phone_reachable = True + if self._last_error_class == RemoteErrorClass.PHONE_UNREACHABLE: + self._last_error_class = RemoteErrorClass.NONE + + # ------------------------------------------------------------------ + # Poll loop + # ------------------------------------------------------------------ + + async def _run(self) -> None: + try: + if not await self._ensure_webhook_deleted(): + return + await self._register_commands() + self._state = RemoteConnectionState.POLLING + offset: int | None = None + attempt = 0 + while not self._stop_event.is_set(): + try: + updates = await self._client.get_updates( + offset=offset, timeout=self._poll_timeout_seconds + ) + except TelegramApiError as exc: + outcome = await self._handle_get_updates_error(exc, attempt) + if outcome is None: + return + attempt = outcome + continue + except (TelegramTransportError, TelegramMalformedResponseError): + attempt += 1 + self._state = RemoteConnectionState.BACKOFF + self._last_error_class = RemoteErrorClass.TRANSPORT + await self._interruptible_backoff(attempt) + continue + + attempt = 0 + self._state = RemoteConnectionState.POLLING + self._last_error_class = RemoteErrorClass.NONE + self._last_successful_poll_at = self._clock() + offset = await self._dispatch(updates, offset) + except asyncio.CancelledError: + raise + except Exception: + self._state = RemoteConnectionState.ERROR + self._last_error_class = RemoteErrorClass.UNKNOWN + logger.error( + "telegram_adapter_poll_loop_crashed connection_id={}", + self._connection_id, + ) + + async def _register_commands(self) -> None: + """Advertise the slash-command set as Telegram's native "/" menu. + + Best-effort: a paired user can still type any command by hand, so a + failure here must never block the poll loop from starting. + + ``clear``, ``history``, and ``pair`` are deliberate additions + beyond the control-surface spec's original AC-58 bounded set (9 + commands) — requested directly by a user testing this feature + live, after that set was first implemented. + """ + commands = [ + ("help", "What can I do here?"), + ("status", "What's my agent doing right now?"), + ("new", "Set aside this task, start a new one"), + ("stop", "Interrupt the agent mid-task"), + ("settings", "Change mode, model, agent, or response style"), + ("health", "Check system health"), + ("history", "Browse recent chat sessions"), + ("changes", "See this task's file changes"), + ("clear", "Delete my recent messages here"), + ("actions", "Run a workflow, project, or schedule"), + ("pair", "Connect this phone with a code"), + ("unpair", "Disconnect this phone"), + ] + try: + await self._client.set_commands(commands) + except ( + TelegramApiError, + TelegramTransportError, + TelegramMalformedResponseError, + ): + logger.warning( + "remote_set_commands_failed connection_id={}", self._connection_id + ) + + async def _ensure_webhook_deleted(self) -> bool: + """AC-11: remove any webhook and drop pending updates once, before + the first ``getUpdates`` call. Retries through transport/unknown-API + failures; an invalid token here is terminal, same as in the main + loop.""" + attempt = 0 + while not self._stop_event.is_set(): + try: + await self._client.delete_webhook(drop_pending_updates=True) + return True + except TelegramApiError as exc: + if exc.error_code == 401: + self._state = RemoteConnectionState.INVALID_TOKEN + self._last_error_class = RemoteErrorClass.INVALID_TOKEN + return False + attempt += 1 + self._state = RemoteConnectionState.BACKOFF + self._last_error_class = RemoteErrorClass.UNKNOWN + await self._interruptible_backoff(attempt) + except (TelegramTransportError, TelegramMalformedResponseError): + attempt += 1 + self._state = RemoteConnectionState.BACKOFF + self._last_error_class = RemoteErrorClass.TRANSPORT + await self._interruptible_backoff(attempt) + return False + + async def _handle_get_updates_error( + self, exc: TelegramApiError, attempt: int + ) -> int | None: + """Returns the next ``attempt`` count, or ``None`` if the caller + should stop polling entirely (invalid token).""" + if exc.error_code == 401: + self._state = RemoteConnectionState.INVALID_TOKEN + self._last_error_class = RemoteErrorClass.INVALID_TOKEN + return None + + if exc.error_code == 409: + description = (exc.description or "").lower() + if "webhook" in description: + # Cleared once, then retried immediately — not a backoff + # state (spec: "Webhook conflict is cleared once"). + try: + await self._client.delete_webhook(drop_pending_updates=True) + except ( + TelegramApiError, + TelegramTransportError, + TelegramMalformedResponseError, + ): + pass + return 0 + # A concurrent getUpdates consumer — bounded backoff, never a + # tight retry loop. + self._state = RemoteConnectionState.USED_ELSEWHERE + self._last_error_class = RemoteErrorClass.USED_ELSEWHERE + new_attempt = attempt + 1 + await self._interruptible_backoff(new_attempt) + return new_attempt + + if exc.error_code == 429: + self._state = RemoteConnectionState.RATE_LIMITED + self._last_error_class = RemoteErrorClass.RATE_LIMITED + retry_after = ( + exc.retry_after + if exc.retry_after is not None + else DEFAULT_RATE_LIMIT_FALLBACK_SECONDS + ) + # Honor Telegram's retry_after verbatim — not our own schedule. + await self._interruptible_sleep(float(retry_after)) + return attempt + + # Unknown API error: generic bounded exponential backoff. + self._state = RemoteConnectionState.BACKOFF + self._last_error_class = RemoteErrorClass.UNKNOWN + new_attempt = attempt + 1 + await self._interruptible_backoff(new_attempt) + return new_attempt + + async def _dispatch( + self, updates: Sequence[TelegramUpdate], offset: int | None + ) -> int | None: + """Process *updates* in update-ID order. Classification is + sequential to preserve offset ordering (spec: "Concurrency, failure, + recovery, and idempotency"). The offset advances past an update only + once it has been safely classified and, for a dispatched action, + once ``on_action`` has completed without raising — a handler failure + stops this batch so the failed update is retried on the next poll + rather than silently skipped.""" + for update in sorted(updates, key=lambda item: item.update_id): + action = self._classify(update) + if action is not None: + try: + await self._on_action(action) + except Exception: + logger.exception( + "telegram_inbound_action_handler_failed " + "connection_id={} update_id={}", + self._connection_id, + update.update_id, + ) + return offset + offset = update.update_id + 1 + return offset + + def _classify(self, update: TelegramUpdate) -> RemoteInboundAction | None: + source_key = f"telegram:{self._connection_id}:{update.update_id}" + + callback_query = update.callback_query + if callback_query is not None: + if not callback_query.data: + return None + self._remember_callback(callback_query.data, callback_query.id) + chat_id = ( + callback_query.message.chat.id + if callback_query.message is not None + else callback_query.from_user.id + ) + principal = RemotePrincipal( + connection_id=self._connection_id, + principal_id=str(callback_query.from_user.id), + destination_id=str(chat_id), + display=( + callback_query.from_user.username + or callback_query.from_user.first_name + or "" + ), + ) + return RemoteInboundAction( + connection_id=self._connection_id, + kind=RemoteInboundActionKind.CALLBACK, + principal=principal, + source_key=source_key, + callback_token=callback_query.data, + ) + + message = update.message + if message is None or message.text is None: + return None # media/service message — unsupported, ignored + if message.chat.type != "private": + return None # group/channel — never addressable + if message.from_user is None or message.from_user.is_bot: + return None # bot-authored or unattributable — ignored + + principal = RemotePrincipal( + connection_id=self._connection_id, + principal_id=str(message.from_user.id), + destination_id=str(message.chat.id), + display=message.from_user.username or message.from_user.first_name or "", + ) + text = message.text + if text == "/start" or text.startswith("/start "): + payload = text[len("/start ") :].strip() if " " in text else "" + return RemoteInboundAction( + connection_id=self._connection_id, + kind=RemoteInboundActionKind.PAIRING_START, + principal=principal, + source_key=source_key, + pairing_token=payload or None, + ) + return RemoteInboundAction( + connection_id=self._connection_id, + kind=RemoteInboundActionKind.TEXT, + principal=principal, + source_key=source_key, + text=text, + ) + + def _remember_callback(self, token: str, raw_callback_query_id: str) -> None: + self._pending_callback_ids[token] = raw_callback_query_id + self._pending_callback_ids.move_to_end(token) + while len(self._pending_callback_ids) > MAX_PENDING_CALLBACK_IDS: + self._pending_callback_ids.popitem(last=False) + + # ------------------------------------------------------------------ + # Interruptible waits + # ------------------------------------------------------------------ + + async def _interruptible_backoff(self, attempt: int) -> None: + cap = min( + self._backoff_max_seconds, + self._backoff_base_seconds * (2 ** (attempt - 1)), + ) + delay = self._jitter(0.0, cap) if cap > 0 else 0.0 + await self._interruptible_sleep(delay) + + async def _interruptible_sleep(self, seconds: float) -> None: + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=max(0.0, seconds)) + except TimeoutError: + pass + + +__all__ = [ + "DEFAULT_BACKOFF_BASE_SECONDS", + "DEFAULT_BACKOFF_MAX_SECONDS", + "DEFAULT_POLL_TIMEOUT_SECONDS", + "MAX_PENDING_CALLBACK_IDS", + "RemoteActionHandler", + "TelegramAdapter", +] diff --git a/app/remote/telegram/client.py b/app/remote/telegram/client.py new file mode 100644 index 00000000..8e32366a --- /dev/null +++ b/app/remote/telegram/client.py @@ -0,0 +1,335 @@ +"""Telegram Bot API HTTP client. + +Owns the ``httpx.AsyncClient``, the ``https://api.telegram.org/bot/`` +request shape, and translation of Bot API responses into safe internal +types. No exception this module raises — and no log line a caller should +ever write from one — includes the request URL, because the URL embeds the +bot token as a path segment. Every error carries only Telegram's +``error_code``/``description`` (already token-free — the token lives in the +URL, never the body) or the *type name* of a transport failure. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, TypeVar + +import httpx +from pydantic import TypeAdapter, ValidationError + +from app.remote.contracts import RemoteButton +from app.remote.telegram.models import ( + TelegramMessage, + TelegramResponse, + TelegramUpdate, + TelegramUser, +) + +TELEGRAM_API_BASE = "https://api.telegram.org" + +#: Telegram's inclusive byte-length contract for one button's callback_data. +CALLBACK_DATA_MIN_BYTES = 1 +CALLBACK_DATA_MAX_BYTES = 64 + +#: Only these update kinds are ever requested (spec: "Telegram update +#: contract") — no edited messages, channel posts, or other update kinds. +ALLOWED_UPDATE_KINDS: tuple[str, ...] = ("message", "callback_query") + +#: Extra seconds of HTTP read-timeout headroom over the server-side +#: long-poll `timeout` so the client never times out before Telegram does. +_LONG_POLL_TIMEOUT_MARGIN_SECONDS = 10.0 +_DEFAULT_TIMEOUT_SECONDS = 30.0 + +T = TypeVar("T") + + +class TelegramClientError(Exception): + """Base class for every error this client raises. + + Never carries the request URL (which embeds the bot token) or the bot + token itself. + """ + + +class TelegramApiError(TelegramClientError): + """The Bot API responded with ``ok: false``. + + Carries only Telegram's own ``error_code``/``description`` (safe — the + token lives in the URL, never the response body) and any ``retry_after`` + Telegram supplied in ``parameters``. Adapter-level code classifies this + into the spec's error taxonomy (webhook conflict, used-elsewhere, + invalid token, rate limit, ...). + """ + + def __init__( + self, + *, + error_code: int | None, + description: str | None, + retry_after: int | None = None, + ) -> None: + self.error_code = error_code + self.description = description or "" + self.retry_after = retry_after + message = "Telegram API error" + if error_code is not None: + message += f" {error_code}" + if self.description: + message += f": {self.description}" + super().__init__(message) + + +class TelegramTransportError(TelegramClientError): + """A network-level failure talking to the Bot API. + + Carries only the underlying exception's type name — never the + exception's own message, which for some httpx errors includes the + request URL. + """ + + def __init__(self, *, cause_type: str) -> None: + self.cause_type = cause_type + super().__init__(f"Telegram request failed ({cause_type}).") + + +class TelegramMalformedResponseError(TelegramClientError): + """The Bot API response body was not valid JSON, or did not match the + expected envelope shape. Never echoes the raw response body, which + could (in principle) reflect request content back.""" + + +class TelegramCallbackDataError(TelegramClientError, ValueError): + """A ``callback_data`` payload violates Telegram's 1-64 byte contract. + + Raised before any request is sent, so a caller never has to distinguish + "rejected locally" from "rejected by Telegram after the fact". + """ + + +def _validate_callback_data(token: str) -> None: + size = len(token.encode("utf-8")) + if not (CALLBACK_DATA_MIN_BYTES <= size <= CALLBACK_DATA_MAX_BYTES): + raise TelegramCallbackDataError( + "callback_data must be " + f"{CALLBACK_DATA_MIN_BYTES}-{CALLBACK_DATA_MAX_BYTES} bytes; got {size}." + ) + + +def _build_reply_markup(buttons: Sequence[RemoteButton]) -> dict[str, Any] | None: + if not buttons: + return None + for button in buttons: + _validate_callback_data(button.token) + return { + "inline_keyboard": [ + [{"text": button.text, "callback_data": button.token}] for button in buttons + ] + } + + +class TelegramClient: + """Thin, provider-specific translation over the Telegram Bot API. + + Constructed from a bot token; an ``httpx.AsyncClient`` can be injected + (tests use ``httpx.MockTransport``) or is otherwise created and owned — + and closed — by this client. + """ + + def __init__( + self, token: str, *, http_client: httpx.AsyncClient | None = None + ) -> None: + if not token: + raise ValueError("A Telegram bot token is required.") + self._token = token + self._owns_http = http_client is None + self._http = http_client or httpx.AsyncClient( + timeout=httpx.Timeout(_DEFAULT_TIMEOUT_SECONDS) + ) + + async def aclose(self) -> None: + if self._owns_http: + await self._http.aclose() + + def _url(self, method: str) -> str: + return f"{TELEGRAM_API_BASE}/bot{self._token}/{method}" + + async def _call( + self, + method: str, + payload: dict[str, Any], + *, + result_model: type[T], + timeout: float | None = None, + ) -> T: + try: + response = await self._http.post( + self._url(method), + json=payload, + timeout=( + httpx.Timeout(timeout) + if timeout is not None + else httpx.USE_CLIENT_DEFAULT + ), + ) + except httpx.HTTPError as exc: + raise TelegramTransportError(cause_type=type(exc).__name__) from None + + try: + body = response.json() + except ValueError: + raise TelegramMalformedResponseError( + f"Telegram {method} response was not valid JSON." + ) from None + + # Validated generically first (``Any`` is a real type expression, so + # this subscript is fine for a static checker); the payload-specific + # shape is validated separately below via ``TypeAdapter``, which + # takes a plain runtime value rather than a static subscript. + try: + envelope = TelegramResponse[Any].model_validate(body) + except ValidationError: + raise TelegramMalformedResponseError( + f"Telegram {method} response did not match the expected shape." + ) from None + + if not envelope.ok: + retry_after = ( + envelope.parameters.retry_after if envelope.parameters else None + ) + raise TelegramApiError( + error_code=envelope.error_code, + description=envelope.description, + retry_after=retry_after, + ) + + if envelope.result is None: + raise TelegramMalformedResponseError( + f"Telegram {method} response had no result." + ) + try: + return TypeAdapter(result_model).validate_python(envelope.result) + except ValidationError: + raise TelegramMalformedResponseError( + f"Telegram {method} response did not match the expected shape." + ) from None + + # -- Bot API methods -------------------------------------------------- + + async def get_me(self) -> TelegramUser: + return await self._call("getMe", {}, result_model=TelegramUser) + + async def delete_webhook(self, *, drop_pending_updates: bool = True) -> None: + await self._call( + "deleteWebhook", + {"drop_pending_updates": drop_pending_updates}, + result_model=bool, + ) + + async def get_updates( + self, + *, + offset: int | None, + timeout: int, + allowed_updates: Sequence[str] = ALLOWED_UPDATE_KINDS, + ) -> list[TelegramUpdate]: + payload: dict[str, Any] = { + "timeout": timeout, + "allowed_updates": list(allowed_updates), + } + if offset is not None: + payload["offset"] = offset + return await self._call( + "getUpdates", + payload, + result_model=list[TelegramUpdate], + timeout=float(timeout) + _LONG_POLL_TIMEOUT_MARGIN_SECONDS, + ) + + async def send_text( + self, + *, + chat_id: str | int, + text: str, + buttons: Sequence[RemoteButton] = (), + ) -> TelegramMessage: + # Always HTML parse mode (AC-24, revised): every caller is required + # to pass text already rendered by app/remote/formatting.py, which + # HTML-escapes every non-static field before interpolation — so + # Telegram markup can only ever come from that trusted renderer, + # never from raw model/agent text. + markup = _build_reply_markup(buttons) + payload: dict[str, Any] = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + } + if markup is not None: + payload["reply_markup"] = markup + return await self._call("sendMessage", payload, result_model=TelegramMessage) + + async def edit_text( + self, + *, + chat_id: str | int, + message_id: int, + text: str, + buttons: Sequence[RemoteButton] = (), + ) -> TelegramMessage: + markup = _build_reply_markup(buttons) + payload: dict[str, Any] = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + "parse_mode": "HTML", + } + if markup is not None: + payload["reply_markup"] = markup + return await self._call( + "editMessageText", payload, result_model=TelegramMessage + ) + + async def send_chat_action( + self, *, chat_id: str | int, action: str = "typing" + ) -> None: + await self._call( + "sendChatAction", + {"chat_id": chat_id, "action": action}, + result_model=bool, + ) + + async def answer_callback( + self, callback_query_id: str, *, text: str | None = None + ) -> None: + payload: dict[str, Any] = {"callback_query_id": callback_query_id} + if text is not None: + payload["text"] = text + await self._call("answerCallbackQuery", payload, result_model=bool) + + async def delete_message(self, *, chat_id: str | int, message_id: int) -> None: + await self._call( + "deleteMessage", + {"chat_id": chat_id, "message_id": message_id}, + result_model=bool, + ) + + async def set_commands(self, commands: Sequence[tuple[str, str]]) -> None: + payload = { + "commands": [ + {"command": command, "description": description} + for command, description in commands + ] + } + await self._call("setMyCommands", payload, result_model=bool) + + +__all__ = [ + "ALLOWED_UPDATE_KINDS", + "CALLBACK_DATA_MAX_BYTES", + "CALLBACK_DATA_MIN_BYTES", + "TelegramApiError", + "TelegramCallbackDataError", + "TelegramClient", + "TelegramClientError", + "TelegramMalformedResponseError", + "TelegramTransportError", +] diff --git a/app/remote/telegram/models.py b/app/remote/telegram/models.py new file mode 100644 index 00000000..7710bb08 --- /dev/null +++ b/app/remote/telegram/models.py @@ -0,0 +1,134 @@ +"""Bounded pydantic models for the subset of the Telegram Bot API wire +format this adapter uses. + +Every model ignores unknown fields (``extra="ignore"``) because Telegram's +API is additive and this adapter only ever reads a handful of fields from a +much larger payload. Nothing here is imported outside ``app/remote/telegram/``. +""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +T = TypeVar("T") + + +class TelegramUser(BaseModel): + """https://core.telegram.org/bots/api#user""" + + model_config = ConfigDict(extra="ignore") + + id: int + is_bot: bool = False + username: str | None = None + first_name: str | None = None + + +class TelegramChat(BaseModel): + """https://core.telegram.org/bots/api#chat""" + + model_config = ConfigDict(extra="ignore") + + id: int + type: str + + +class TelegramMessage(BaseModel): + """https://core.telegram.org/bots/api#message + + Only the fields this adapter reads or needs to echo back (to address an + edit) are modeled. Media, entities, and formatting fields are + deliberately absent: this adapter never sends or interprets them. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + message_id: int + date: int + chat: TelegramChat + from_user: TelegramUser | None = Field(default=None, alias="from") + text: str | None = None + + +class TelegramCallbackQuery(BaseModel): + """https://core.telegram.org/bots/api#callbackquery""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + id: str + from_user: TelegramUser = Field(alias="from") + message: TelegramMessage | None = None + data: str | None = None + + +class TelegramUpdate(BaseModel): + """https://core.telegram.org/bots/api#update + + Only ``message`` and ``callback_query`` are modeled because the adapter + requests ``allowed_updates=["message", "callback_query"]`` and Telegram + never delivers any other update kind once that filter is set. + """ + + model_config = ConfigDict(extra="ignore") + + update_id: int + message: TelegramMessage | None = None + callback_query: TelegramCallbackQuery | None = None + + +class TelegramInlineKeyboardButton(BaseModel): + """https://core.telegram.org/bots/api#inlinekeyboardbutton + + ``callback_data`` is always an opaque, connection-owned capability token + minted by the (later) remote service — never a raw session ID, path, or + command (spec: "Remote interaction contract"). + """ + + model_config = ConfigDict(extra="ignore") + + text: str + callback_data: str + + +class TelegramInlineKeyboardMarkup(BaseModel): + """https://core.telegram.org/bots/api#inlinekeyboardmarkup""" + + model_config = ConfigDict(extra="ignore") + + inline_keyboard: list[list[TelegramInlineKeyboardButton]] + + +class TelegramResponseParameters(BaseModel): + """https://core.telegram.org/bots/api#responseparameters""" + + model_config = ConfigDict(extra="ignore") + + retry_after: int | None = None + migrate_to_chat_id: int | None = None + + +class TelegramResponse(BaseModel, Generic[T]): + """The envelope every Bot API method response is wrapped in.""" + + model_config = ConfigDict(extra="ignore") + + ok: bool + result: T | None = None + error_code: int | None = None + description: str | None = None + parameters: TelegramResponseParameters | None = None + + +__all__ = [ + "TelegramCallbackQuery", + "TelegramChat", + "TelegramInlineKeyboardButton", + "TelegramInlineKeyboardMarkup", + "TelegramMessage", + "TelegramResponse", + "TelegramResponseParameters", + "TelegramUpdate", + "TelegramUser", +] diff --git a/app/remote/turn_activity.py b/app/remote/turn_activity.py new file mode 100644 index 00000000..2b0ea11c --- /dev/null +++ b/app/remote/turn_activity.py @@ -0,0 +1,101 @@ +"""Turn-activity summary built from a turn's persisted messages. + +Queries every :class:`~app.models.chat.SessionMessage` row created since a +turn started and reduces its tool calls into the bounded summary used by +the done-card's "N tool calls" line, and — via later drill-down capability +tokens minted in a subsequent task — the "Full diff"/"Tool log" buttons. + +Note: the plan's interface section names ``app.models.chat.ChatMessage``, +but no such model exists in this codebase; the real persisted-message table +is ``SessionMessage`` (``app/models/chat.py``), whose fields (``session_id``, +``role``, ``tool_calls``, ``tool_call_id``, ``content``, ``created_at``) +match exactly what this module needs, so it is used here instead. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from uuid import UUID + +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.chat import SessionMessage + +_DIFF_TOOLS = frozenset({"write", "edit", "patch"}) + +__all__ = ["TurnActivity", "load_turn_activity"] + + +@dataclass(frozen=True) +class TurnActivity: + tool_call_count: int + #: The agent's actual final reply text (the last persisted assistant + #: message with non-empty ``content`` in the turn's window) — never + #: available on a "done" stream envelope, whose ``DoneEvent`` carries + #: no text field at all (only ``type``/``metadata``), so this is the + #: only place that text can come from. + response_text: str = "" + summary_lines: list[str] = field(default_factory=list) + tool_log_text: str = "" + diff_text: str = "" + + +async def load_turn_activity( + db: AsyncSession, session_id: str, *, since: datetime +) -> TurnActivity: + # ``SessionMessage.session_id`` is a real ``sa.Uuid()`` column, so a + # non-UUID session_id (e.g. a test double's placeholder string, or an + # id from a caller that doesn't own a persisted chat session) can never + # match a row — return the empty-activity placeholders instead of + # raising, matching what an empty result set would produce anyway. + try: + session_uuid = UUID(session_id) + except ValueError: + return TurnActivity( + tool_call_count=0, + tool_log_text="No tool calls.", + diff_text="No file changes.", + ) + + rows = ( + await db.exec( + select(SessionMessage) + .where(SessionMessage.session_id == session_uuid) + .where(col(SessionMessage.created_at) >= since) + .order_by(col(SessionMessage.created_at)) + ) + ).all() + + tool_calls: list[tuple[str, str]] = [] + response_text = "" + for message in rows: + for call in message.tool_calls or []: + # Handle OpenAI nested format: {"function": {"name": ..., "arguments": ...}} + # and flat legacy/test format: {"name": ..., "arguments": ...} + fn = call.get("function") + if isinstance(fn, dict): + name = fn.get("name", "unknown") + args = fn.get("arguments", "{}") + else: + name = call.get("name", "unknown") + args = call.get("arguments", "{}") + tool_calls.append((name, str(args))) + # Rows are ordered oldest-first, so the last assistant message with + # actual content (not a tool-call-only or reasoning-only message) + # is the turn's final reply — overwriting as we go keeps that one. + if message.role == "assistant" and message.content: + response_text = message.content + + tool_log_lines = [f"{name}: {args}" for name, args in tool_calls] + diff_lines = [f"{name}: {args}" for name, args in tool_calls if name in _DIFF_TOOLS] + diff_paths = sorted({args for name, args in tool_calls if name in _DIFF_TOOLS}) + + return TurnActivity( + tool_call_count=len(tool_calls), + response_text=response_text, + summary_lines=diff_paths[:10], + tool_log_text="\n".join(tool_log_lines) or "No tool calls.", + diff_text="\n".join(diff_lines) or "No file changes.", + ) diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 27d52ce0..7e77d9d1 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -779,17 +779,27 @@ async def save_queued_user_message( ) +def get_channel_source(extra: dict | None) -> tuple[str, dict] | None: + """Return channel idempotency metadata, including legacy WebBridge rows.""" + for key in ("interactive_source", "webbridge_source"): + source = (extra or {}).get(key) + if isinstance(source, dict) and source.get("key"): + return key, source + return None + + async def mark_channel_source_delivered(db: AsyncSession, row: SessionMessage) -> bool: """Mark a source-keyed channel row after its delivery boundary succeeds.""" extra = dict(row.extra or {}) - source = extra.get("webbridge_source") - if not isinstance(source, dict) or not source.get("key"): + source_entry = get_channel_source(extra) + if source_entry is None: return False + source_key, source = source_entry if source.get("state") == "delivered": return False source = dict(source) source["state"] = "delivered" - extra["webbridge_source"] = source + extra[source_key] = source row.extra = extra db.add(row) await db.flush() @@ -1600,9 +1610,7 @@ async def get_team_history( # ── Internal helpers ────────────────────────────────────────────────────────── -def _restore_reasoning_items( - msg: "AssistantMessage", extra: dict | None -) -> None: +def _restore_reasoning_items(msg: "AssistantMessage", extra: dict | None) -> None: """Put the provider's reasoning items back on a rehydrated turn. They ride in ``extra`` because the field itself is excluded from the diff --git a/app/services/interactive_message_service.py b/app/services/interactive_message_service.py index e2fad939..417908de 100644 --- a/app/services/interactive_message_service.py +++ b/app/services/interactive_message_service.py @@ -12,7 +12,11 @@ from app.models.chat import ChatSession, SessionMessage from app.services import agent_service, team_manager from app.services.agent_service import NoTeamConfigured, RawAttachment -from app.services.chat_service import cleanup_reverted_tail, save_queued_user_message +from app.services.chat_service import ( + cleanup_reverted_tail, + get_channel_source, + save_queued_user_message, +) @dataclass(frozen=True) @@ -50,8 +54,8 @@ async def find_interactive_message_by_source( ) ).all() for row in rows: - source = (row.extra or {}).get("webbridge_source") - if isinstance(source, dict) and source.get("key") == source_key: + source_entry = get_channel_source(row.extra) + if source_entry is not None and source_entry[1].get("key") == source_key: return row return None @@ -138,10 +142,10 @@ async def submit_persisted_interactive_message( db, session_id=session.id, source_key=source_key ) if persisted_message is not None and source_request_hash: - source = (persisted_message.extra or {}).get("webbridge_source") + source_entry = get_channel_source(persisted_message.extra) if ( - isinstance(source, dict) - and source.get("request_hash") != source_request_hash + source_entry is not None + and source_entry[1].get("request_hash") != source_request_hash ): raise InteractiveMessageConflict( "Idempotency-Key was already used for another message." @@ -149,7 +153,8 @@ async def submit_persisted_interactive_message( await cleanup_reverted_tail(db, session.id) if persisted_message is not None: - source = (persisted_message.extra or {}).get("webbridge_source") or {} + source_entry = get_channel_source(persisted_message.extra) + source = source_entry[1] if source_entry is not None else {} if source.get("state") == "delivered": return InteractiveMessageResult( status="accepted", diff --git a/app/services/memory_stream_store.py b/app/services/memory_stream_store.py index 971bc2c1..e63e0af7 100644 --- a/app/services/memory_stream_store.py +++ b/app/services/memory_stream_store.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Literal, cast @@ -246,6 +247,50 @@ def _take_replay_snapshot(state: _TurnState) -> _ReplaySnapshot: ) +# ── Observer registry ──────────────────────────────────────────────────────── +# +# Synchronous callbacks invoked after every successful push_event. Observers +# are snapshot-copied under the per-turn lock and invoked immediately after +# releasing it, so they never block stream mutation or SSE fan-out. +# Each observer must be synchronous and non-blocking (no DB, no network). + +Observer = Callable[[str, StreamEnvelope], None] + +_observers: list[Observer] = [] + + +def register_observer(observer: Observer) -> Callable[[], None]: + """Register a synchronous stream observer. Returns an unregister callable. + + Observers receive ``(session_id, envelope)`` after every successful + ``push_event``. They must not perform blocking I/O or await anything. + Must be called from a synchronous context (e.g. server startup). + """ + _observers.append(observer) + + def _unregister() -> None: + try: + _observers.remove(observer) + except ValueError: + pass + + return _unregister + + +def _notify_observers(session_id: str, envelope: StreamEnvelope) -> None: + """Invoke every registered observer. Exception-isolated.""" + for observer in list(_observers): + try: + observer(session_id, envelope) + except Exception as exc: + logger.warning( + "stream_observer_failed session_id={} event_type={} error={}", + session_id, + envelope.event, + exc, + ) + + # Me store all active turns here _turns: dict[str, _TurnState] = {} @@ -311,8 +356,13 @@ async def push_event(session_id: str, envelope: StreamEnvelope) -> None: at the type boundary. Producers build envelopes via :meth:`StreamEnvelope.from_event` (for typed ``*Event`` payloads) or :meth:`StreamEnvelope.from_parts` (for ad-hoc lifecycle events). + + After the per-turn lock is released, registered observers are invoked + synchronously with ``(session_id, envelope)``. Observers are + exception-isolated — one failure never prevents the next from running. """ try: + pushed = False while True: state = _turns.get(session_id) if state is None: @@ -324,7 +374,10 @@ async def push_event(session_id: str, envelope: StreamEnvelope) -> None: if _turns.get(session_id) is not state: continue _push_event_locked(session_id, state, envelope) - return + pushed = True + break + if pushed: + _notify_observers(session_id, envelope) except Exception as exc: logger.warning( "memory_store_push_failed session_id={} error={}", @@ -855,3 +908,4 @@ async def attach(session_id: str) -> AsyncGenerator[dict[str, str], None]: async def close() -> None: """Clear all state (called on server shutdown).""" _turns.clear() + _observers.clear() diff --git a/docs/development/local-development-windows-macos.md b/docs/development/local-development-windows-macos.md new file mode 100644 index 00000000..ae638df1 --- /dev/null +++ b/docs/development/local-development-windows-macos.md @@ -0,0 +1,663 @@ +# Local development setup for Windows and macOS + +Status: current operational plan + +Audience: contributors installing EvoFlux from source for the first time and +maintaining a repeatable daily development environment. + +This guide covers native Windows and macOS development for the complete EvoFlux +desktop stack: + +```text +FastAPI sidecar (:8000) + Vite UI (:5173) + Tauri desktop shell +``` + +It was derived from the repository's `README.md`, `Makefile`, +`scripts/run_dev.py`, `desktop/Makefile`, package lockfiles, Tauri configuration, +platform tests, and desktop packaging workflow. Use +[Development and testing](setup-and-testing.md) for the concise quality-gate +reference and [Project deep dive](project-deep-dive.md) for architecture and +change ownership. + +## Findings from the repository + +The project already contains: + +- Python `>=3.12` metadata and a committed `uv.lock`; +- a committed Bun lockfile at `web/bun.lock`; +- a committed Rust lockfile at `desktop/src-tauri/Cargo.lock`; +- `make dev-web` for FastAPI plus Vite; +- `make dev-desktop` for FastAPI, Vite, and Tauri; +- a supervisor in `scripts/run_dev.py` that stops sibling processes when one + fails; +- distinct external-backend and bundled-sidecar Tauri development configs; +- first-start initialization that creates runtime roots and installs local seed + agents without overwriting existing user files; +- Windows and macOS package jobs in `.github/workflows/desktop-packages.yml`. + +There is one important platform gap: `scripts/run_dev.py` currently assumes +Unix process and port tools (`lsof`, `os.killpg`, POSIX signals, and +`start_new_session`). The root Make targets also use Unix shell syntax. This is +appropriate for macOS, but native Windows should use the three-terminal +PowerShell flow in this guide until a Windows supervisor is added. + +## Choose the run mode + +| Mode | Use it for | Command style | +|---|---|---| +| API only | Backend/API debugging | one terminal | +| Web development | Normal backend/frontend work without native capabilities | API + Vite | +| Desktop development | Default product-development loop | API + Vite + Tauri against source | +| Bundled-sidecar development | Sidecar imports, migrations, token handshake, resources, cleanup | rebuilt sidecar + Vite + Tauri | +| Native package | Installer/updater/release validation | host-platform release build | + +Start with desktop development. Use web-only mode when native commands do not +matter. Use bundled-sidecar mode only for changes that can behave differently +after Python packaging. + +## Shared requirements + +Both platforms need: + +- Git; +- Python 3.12 managed through `uv`; +- Bun; +- Rust stable and Cargo; +- Tauri CLI v2; +- platform-native Tauri build dependencies; +- an LLM provider credential, OAuth connection, or local model runtime to run + real agent turns. + +Official prerequisite references: + +- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/) +- [uv installation](https://docs.astral.sh/uv/getting-started/installation/) +- [Bun installation](https://bun.sh/docs/installation) +- [Rust installation](https://www.rust-lang.org/tools/install/) + +Do not install a separate global Python package copy of EvoFlux for source +development. `uv sync` creates and manages the repository environment. + +## Windows: first-time setup + +Use 64-bit Windows 10/11 and PowerShell. The production package is x64, while +the Rust crate uses the MSVC toolchain. + +### 1. Install native build prerequisites + +Install [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) +and select **Desktop development with C++**. Include the recommended MSVC and +Windows SDK components. + +Tauri uses Microsoft Edge WebView2. It is normally already present on current +Windows 10/11 systems. If it is missing, install the +[WebView2 Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/). + +EvoFlux builds NSIS packages on Windows, so the MSI-only VBSCRIPT prerequisite +is not needed for the normal project build. + +### 2. Install command-line tools + +Run PowerShell as the normal development user: + +```powershell +winget install --id Git.Git -e +winget install --id astral-sh.uv -e +powershell -c "irm bun.sh/install.ps1|iex" +winget install --id Rustlang.Rustup -e +``` + +Close and reopen PowerShell so user `PATH` changes are visible. Then select the +MSVC Rust toolchain and install Tauri CLI: + +```powershell +rustup default stable-msvc +cargo install tauri-cli --version "^2" --locked +``` + +If Bun installed successfully but is not on `PATH`, verify it directly: + +```powershell +& "$env:USERPROFILE\.bun\bin\bun" --version +``` + +Then restart the terminal. Follow Bun's official `PATH` instructions if the +direct command works but `bun --version` does not. + +### 3. Verify the toolchain + +```powershell +git --version +uv --version +bun --version +rustup --version +rustc --version +cargo --version +cargo tauri --version +``` + +Do not proceed until every command succeeds in a newly opened PowerShell +window. If Cargo fails to link a minimal build, reopen the Visual Studio Build +Tools installer and confirm the C++ desktop workload and Windows SDK. + +### 4. Clone or open the repository + +If the repository is not already present: + +```powershell +git clone https://github.com/evoelsewhere/evoflux.git +Set-Location evoflux +``` + +From an existing checkout, start at the repository root containing +`pyproject.toml`, `uv.lock`, `web/`, and `desktop/`. + +### 5. Install locked project dependencies + +```powershell +uv python install 3.12 +uv sync --frozen + +Push-Location web +bun install --frozen-lockfile +Pop-Location + +Push-Location desktop\src-tauri +cargo check +Pop-Location +``` + +`uv sync --frozen` creates `.venv` and installs backend plus development +dependencies from `uv.lock`. `bun install --frozen-lockfile` creates +`web/node_modules` without changing `web/bun.lock`. The first Cargo check +downloads and compiles Rust dependencies and can take several minutes. + +### 6. Initialize source-development configuration + +Keep source data separate from a packaged/production EvoFlux installation: + +```powershell +$env:APP_ENV = "development" +uv run evoflux init +``` + +The interactive initializer stores development configuration beneath +`.evoflux/dev/`. It asks for a provider/model and writes credentials to the +development config root. Alternatively, skip the interactive command, start +the UI, and configure a provider in Settings; startup will still seed agent +blueprints automatically. + +Source development does not auto-apply Alembic revisions. Create or upgrade the +isolated development database before the first run: + +```powershell +$env:APP_ENV = "development" +uv run alembic -c app\alembic.ini upgrade head +``` + +Never commit `.evoflux/`, `.env`, OAuth tokens, API keys, or provider +credentials. + +## Windows: run the project + +The current reliable native-Windows flow uses three PowerShell terminals. + +### Terminal 1: FastAPI + +```powershell +Set-Location D:\path\to\evoflux +$env:APP_ENV = "development" +uv run uvicorn app.server:app ` + --host 127.0.0.1 ` + --port 8000 ` + --reload ` + --reload-dir app ` + --no-access-log +``` + +### Terminal 2: Vite + +```powershell +Set-Location D:\path\to\evoflux\web +$env:VITE_API_PROXY_TARGET = "http://127.0.0.1:8000" +bun dev +``` + +### Terminal 3: Tauri + +```powershell +Set-Location D:\path\to\evoflux\desktop\src-tauri +$env:EVOFLUX_DESKTOP_DEV_BACKEND_URL = "http://127.0.0.1:8000" +cargo tauri dev -c tauri.dev.conf.json +``` + +Replace `D:\path\to\evoflux` with the checkout's real path. The desktop window +should be named **EvoFlux (dev)** and coexist with a packaged EvoFlux install. + +Stop development with `Ctrl+C` in Terminal 3, then Terminal 2, then Terminal 1. +Check ports before restarting if a process was force-closed: + +```powershell +Get-NetTCPConnection -LocalPort 8000,5173 -State Listen -ErrorAction SilentlyContinue +``` + +### Windows web-only shortcut + +Use only Terminals 1 and 2, then open `http://localhost:5173`. Native Tauri +commands, persistent browser behavior, tray integration, and native dialogs are +not fully represented in browser-only mode. + +## macOS: first-time setup + +The same steps work on Apple Silicon and Intel Macs. The repository and CI +build both architectures. + +### 1. Install Apple build tools + +For desktop-only development: + +```bash +xcode-select --install +``` + +If full Xcode is already installed, launch it once to finish component setup +and accept its license. Verify: + +```bash +xcode-select -p +clang --version +make --version +lsof -v +``` + +The unified launcher requires `make` and `lsof`; both are normally available +after the Apple command-line tools are installed. + +### 2. Install uv, Bun, and Rust + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +curl -fsSL https://bun.com/install | bash +curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh +``` + +Open a new Terminal, or load the Rust environment in the current shell: + +```bash +source "$HOME/.cargo/env" +``` + +Install Tauri CLI: + +```bash +rustup default stable +cargo install tauri-cli --version '^2' --locked +``` + +Homebrew is optional. If the standalone installers are not desired, official +package-manager alternatives include `brew install uv` and +`brew install oven-sh/bun/bun`; use one installation method per tool to avoid +conflicting upgrade paths. + +### 3. Verify the toolchain + +```bash +git --version +uv --version +bun --version +rustup --version +rustc --version +cargo --version +cargo tauri --version +``` + +### 4. Clone and install locked dependencies + +```bash +git clone https://github.com/evoelsewhere/evoflux.git +cd evoflux + +uv python install 3.12 +uv sync --frozen + +cd web +bun install --frozen-lockfile +cd .. + +cd desktop/src-tauri +cargo check +cd ../.. +``` + +Skip the clone commands when using an existing checkout. + +### 5. Initialize source-development configuration + +```bash +APP_ENV=development uv run evoflux init +APP_ENV=development uv run alembic -c app/alembic.ini upgrade head +``` + +As on Windows, the UI can perform provider setup after launch if the +interactive initializer is skipped. + +## macOS: run the project + +Run the complete source stack from the repository root: + +```bash +APP_ENV=development make dev-desktop +``` + +The supervisor starts FastAPI, Vite, and Tauri, prefixes their logs, and stops +the remaining services when one exits. Stop the group with `Ctrl+C`. + +For backend/frontend work without native capabilities: + +```bash +APP_ENV=development make dev-web +``` + +Open `http://localhost:5173` if a browser does not open automatically. + +## Verify a successful run + +Use these acceptance checks on either platform: + +1. FastAPI logs show startup reached `critical_startup_ready`. +2. `http://127.0.0.1:8000/api/health/live` returns a successful response. +3. Vite reports `http://localhost:5173` ready. +4. **EvoFlux (dev)** opens and passes its backend loading screen. +5. Work mode creates or resolves a session. +6. Coding mode can select an authorized repository. +7. A configured model can complete one simple response. +8. Closing the Tauri dev process does not leave an unexpected desktop child + process; in source mode the separately started API/Vite processes remain + until their terminals are stopped. + +PowerShell health check: + +```powershell +Invoke-RestMethod http://127.0.0.1:8000/api/health/live +``` + +macOS health check: + +```bash +curl -fsS http://127.0.0.1:8000/api/health/live +``` + +## Daily continuous-development loop + +### Start of day + +1. Open the repository and inspect existing work: + + ```bash + git status --short + git branch --show-current + ``` + +2. Update safely when appropriate: + + ```bash + git pull --ff-only + ``` + +3. Reconcile dependencies when lockfiles changed: + + ```bash + uv sync --frozen + cd web && bun install --frozen-lockfile && cd .. + cd desktop/src-tauri && cargo check && cd ../.. + ``` + +4. Apply new database revisions when `app/migrations/` changed: + + ```bash + APP_ENV=development uv run alembic -c app/alembic.ini upgrade head + ``` + +5. Start the macOS unified flow or Windows three-terminal flow. +6. Keep `APP_ENV=development` on the source backend so dev state stays under + `.evoflux/dev/`. + +PowerShell uses `Push-Location`/`Pop-Location` instead of the chained `cd` +examples above. + +### During development + +- Read root and nearest nested `AGENTS.md` files before editing. +- Follow the specification and acceptance workflow in the + [project deep dive](project-deep-dive.md#continued-development-workflow). +- Run the smallest focused test while iterating. +- Keep secrets and generated state out of Git. +- Do not delete `.evoflux/dev/config`, wiki, or workspaces merely to fix a + cache problem; cache and state have different retention rules. +- Restart only the affected service where possible. Python and Vite normally + reload automatically; Rust changes rebuild the Tauri process. + +### Before handoff + +Backend gate: + +```bash +uv run ruff check app/ tests/ +uv run ruff format --check app/ tests/ +uv run ty check app/ +uv run pytest --no-cov -q +``` + +Frontend gate: + +```bash +cd web +bun run lint +bun run typecheck +bun run build +``` + +Desktop gate: + +```bash +cd desktop/src-tauri +cargo check +``` + +Finish with `git diff --check` and report exact commands, failures, and checks +not run. + +## Dependency update policy + +The committed lockfiles are the reproducibility boundary. + +| Ecosystem | Normal install | Intentional dependency change | +|---|---|---| +| Python | `uv sync --frozen` | edit `pyproject.toml`, run the appropriate `uv add`/`uv lock`, review `uv.lock` | +| Web | `bun install --frozen-lockfile` | edit `web/package.json` or use `bun add`, review `package.json` and `bun.lock` | +| Rust | `cargo check --locked` where appropriate | edit `Cargo.toml` or use `cargo add`, review `Cargo.toml` and `Cargo.lock` | + +Do not casually upgrade all three ecosystems during an unrelated feature. +Toolchain upgrades should be a separate, verified change because they affect +desktop packaging and CI. + +## Bundled-sidecar validation + +Use this after changes to sidecar packaging, startup imports, migrations, +desktop token authentication, bundled resources, process cleanup, or Office +preview dependencies. + +### macOS + +Run Vite in one terminal: + +```bash +cd web +bun dev +``` + +Run the packaged-style sidecar/Tauri path in another: + +```bash +make -C desktop dev-bundled +``` + +### Windows + +Build the sidecar from the repository root: + +```powershell +uv run python scripts\build_sidecar.py ` + --root . ` + --out desktop\sidecar-bundle ` + --python-version 3.12 ` + --extras office-preview +``` + +Start Vite in one terminal, then run the bundled Tauri configuration from +`desktop\src-tauri` in another. Set `EVOFLUX_APP_ENV=development` and explicit +`EVOFLUX_*_DIR` values under the repository's `.evoflux\dev` directory before +launching so the Rust supervisor passes isolated paths to the child sidecar. + +This Windows flow should receive a project-owned PowerShell wrapper before it +becomes a routine developer command. + +## Native package smoke builds + +Package only on the target operating system. + +### Windows NSIS + +```powershell +uv run python scripts\build_sidecar.py ` + --root . ` + --out desktop\sidecar-bundle ` + --python-version 3.12 ` + --extras office-preview + +Push-Location desktop\src-tauri +cargo tauri build --bundles nsis +Pop-Location +``` + +### macOS app/DMG + +```bash +make -C desktop sidecar +make -C desktop build +``` + +Unsigned or ad-hoc local packages may trigger operating-system trust prompts. +Signing, notarization, updater metadata, and release artifact validation follow +the [release and packaging contract](release-and-packaging.md), not this daily +development guide. + +## Troubleshooting decision tree + +### A command is not found + +1. Open a new shell after installation. +2. Run the tool by its known install path to distinguish installation from + `PATH` configuration. +3. Confirm only one package manager owns the tool. +4. Re-run the toolchain verification block before retrying EvoFlux. + +### Port 8000 or 5173 is already in use + +- Stop the known prior EvoFlux dev terminal with `Ctrl+C`. +- Inspect the owning PID before terminating anything. +- Do not indiscriminately kill all Python, Bun, Node, or Cargo processes. + +### Backend starts but the UI cannot connect + +- Confirm `/api/health/live` works directly. +- Confirm Vite has `VITE_API_PROXY_TARGET=http://127.0.0.1:8000`. +- Confirm Tauri has + `EVOFLUX_DESKTOP_DEV_BACKEND_URL=http://127.0.0.1:8000`. +- Check that a stale packaged backend selection is not being used; the dev + environment variable should force the source backend. + +### Python imports or migrations fail + +- Run `uv sync --frozen` again. +- Confirm `uv run python --version` is Python 3.12 or newer. +- Confirm `APP_ENV=development` and inspect `.evoflux/dev/state` logs. +- Do not delete the database before reading the migration error. + +### Windows linker or WebView failure + +- Confirm the Visual Studio **Desktop development with C++** workload. +- Run `rustup default stable-msvc`. +- Confirm the Windows SDK and Edge WebView2 Runtime are installed. +- Restart the terminal or computer after native toolchain installation. + +### macOS compiler or permission failure + +- Run `xcode-select -p` and `xcodebuild -license` if prompted. +- Launch full Xcode once after an update. +- Treat microphone, screen capture, accessibility, and automation permissions + as OS-managed state; dev and packaged app identifiers may receive separate + permission records. + +## Planned Windows developer-experience improvement + +The immediate three-terminal flow is functional, but continued development +should add a native PowerShell supervisor. This is an internal developer-tool +change with unchanged product behavior. + +### Invariants + +- Start the same API, Vite, and optional Tauri commands as `run_dev.py`. +- Preserve API/Vite port configuration and environment propagation. +- Stop only processes started by the supervisor. +- Leave pre-existing unrelated listeners untouched unless the user explicitly + chooses to replace them. +- Preserve `Ctrl+C` cleanup and non-zero sibling-failure propagation. +- Default source runs to isolated development roots. + +### Proposed acceptance criteria + +- **DEV-AC-1:** Given a native Windows checkout with required tools, one + documented PowerShell command starts API, Vite, and Tauri with prefixed logs. +- **DEV-AC-2:** Given port 8000 or 5173 is occupied, preflight exits before + starting children and identifies the occupied port without killing it. +- **DEV-AC-3:** Given any child exits non-zero, the supervisor stops its own + remaining children and returns the failing status. +- **DEV-AC-4:** Given `Ctrl+C`, every owned child exits and no new listener + remains on the configured ports. +- **DEV-AC-5:** Given source mode, backend data/config/state/cache/workspace/wiki + roots resolve beneath `.evoflux/dev/` unless explicitly overridden. +- **DEV-AC-6:** Focused Windows-compatible tests cover command construction, + environment propagation, occupied ports, sibling failure, and interrupt + cleanup. + +### Suggested ownership + +| Work | Files | +|---|---| +| Windows supervisor | new `scripts/run_dev.ps1` or a cross-platform revision of `scripts/run_dev.py` | +| Root entry command | a PowerShell-documented command; avoid making GNU Make mandatory on Windows | +| Regression tests | `tests/scripts/test_run_dev.py` plus Windows-specific tests | +| Current docs | this guide, `setup-and-testing.md`, and the README source-run section | + +Do not implement this by relying on WSL for the native Tauri shell. WSL may be +useful for backend-only development, but it is not the primary Windows desktop +capability and process-lifecycle environment. + +## Setup completion checklist + +- [ ] Platform-native Tauri prerequisites installed +- [ ] Git, uv, Bun, Rust, Cargo, and Tauri CLI verified in a new shell +- [ ] Python 3.12 installed through uv +- [ ] `uv sync --frozen` completed +- [ ] `bun install --frozen-lockfile` completed +- [ ] `cargo check` completed +- [ ] Alembic upgraded the isolated development database to `head` +- [ ] Source backend runs with `APP_ENV=development` +- [ ] FastAPI health endpoint succeeds +- [ ] Vite serves port 5173 +- [ ] EvoFlux (dev) opens through Tauri +- [ ] Provider/model configured without committing credentials +- [ ] One Work session and one Coding workspace smoke-tested +- [ ] Stop/restart leaves no unexpected owned processes +- [ ] Focused baseline tests recorded before feature development begins diff --git a/docs/development/project-deep-dive.md b/docs/development/project-deep-dive.md new file mode 100644 index 00000000..0beb90fe --- /dev/null +++ b/docs/development/project-deep-dive.md @@ -0,0 +1,713 @@ +# EvoFlux project deep dive and continuation guide + +Status: current-state engineering guide + +Snapshot: `main` at `77cfd871` (`2026-08-25`) + +Audience: contributors preparing to change, test, package, or extend EvoFlux + +This guide is the shortest end-to-end mental model of the repository. It +summarizes what EvoFlux builds, how its processes and source tree fit together, +where runtime and development knowledge live, the conventions visible in code +and Git history, and the workflow expected for continued development. + +It does not replace the detailed contracts linked throughout the document. +When this guide, current-state documentation, tests, and code disagree, follow +the source-of-truth rules in [Development knowledge used by contributors](#development-knowledge-used-by-contributors) +and reconcile the discrepancy in the same change. + +## Executive summary + +EvoFlux is a local-first desktop workspace for teams of Work and Coding agents. +It is not primarily a hosted web application. The production product combines: + +1. a Rust/Tauri desktop shell that owns native lifecycle and capabilities; +2. a React/TypeScript UI rendered in the Tauri WebView; +3. a local Python/FastAPI sidecar that owns agent execution, policy, + persistence, workspace authorization, automation, and integrations. + +The key architectural idea is that models are replaceable reasoning engines. +EvoFlux owns the durable harness around them: context construction, agent/team +configuration, tools, permission checks, sandboxing, streaming, persistence, +memory, code intelligence, and verification. + +The product has two durable execution modes: + +- **Work mode** gives a session an isolated EvoFlux-managed workspace and a + general execution/exploration team. +- **Coding mode** authorizes one repository or a named multi-repository project + and adds editor, Git, code graph, LSP, Problems, ChangeSet, review, terminal, + and worktree behavior. + +The most important boundaries for future changes are: + +- keep FastAPI routes thin and reusable behavior in services/runtime modules; +- keep provider payloads behind provider adapters and generic schemas neutral; +- keep server state in TanStack Query and live/client state in Zustand; +- keep native lifecycle and capabilities in Tauri, but policy and persistence + in the sidecar; +- keep the application database separate from rebuildable repository indexes; +- treat external/tool/model/memory content as untrusted data; +- update specification, implementation, tests, current docs, and Help together + when a user-visible contract changes. + +## What the project builds + +The repository produces four related artifacts: + +| Artifact | Output and purpose | +|---|---| +| React application | `web/dist/`; the production UI embedded by Tauri | +| Python wheel | API-only `evoflux` package with the CLI, sidecar code, Alembic resources, and offline seed bundle; it does not embed the web UI | +| Python sidecar bundle | `desktop/sidecar-bundle/`; a standalone runtime assembled for native packaging and never committed | +| Native desktop packages | macOS DMG, Windows current-user NSIS installer, and Linux amd64 DEB | + +Version `0.0.8` is declared in the Python, web, and Tauri package metadata. The +Python project description is “Self-hosted AI agents,” but the current product +contract positions EvoFlux as a desktop application; the FastAPI-only wheel and +Vite server are development, integration, and headless API surfaces. + +Implemented product areas include streaming multi-agent chat, sessions and +folders, goals, workflows, scheduling, files and previews, terminal/process +control, Side Chat, Git/reviews, code graph/search, optional LSP integration, +scoped memory, a Markdown wiki with Dream consolidation, model providers, +Skills, MCP, plugins, browser/WebBridge integrations, sandbox/permissions, and +local telemetry. The authoritative implementation index is the +[feature catalogue](../features/README.md). + +## Architectural mental model + +```text +User + | + v +Tauri shell (Rust) + | native lifecycle, dialogs, tray, browser profile, capabilities, updates + | launches sidecar and injects per-launch origin/token + v +React WebView (TypeScript) + | HTTP for commands/data, SSE for agent events, WebSocket for terminals/browser + v +FastAPI sidecar (Python) + |-- API routes: validation and transport translation + |-- services: durable business and integration behavior + |-- agent runtime: providers, hooks, tools, policy, teams + |-- workflow/scheduler: automated execution + |-- SQLModel/Alembic: durable application state + `-- filesystem stores: config, wiki, workspaces, state, cache +``` + +### Process ownership + +| Concern | Owner | Important entry points | +|---|---|---| +| Native startup and shutdown | Tauri | `desktop/src-tauri/src/main.rs`, `sidecar.rs` | +| Web bootstrap and routing | React | `web/src/main.tsx`, `App.tsx`, `router.ts`, `routes/work.tsx` | +| HTTP application lifecycle | FastAPI | `app/server.py`, `app/api/app.py` | +| Session/team orchestration | Python services/runtime | `app/services/chat_service.py`, `team_manager.py`, `app/agent/mode/team/` | +| Model/tool loop | Agent runtime | `app/agent/agent_loop/core.py` and sibling modules | +| Persistent application state | SQLModel/Alembic | `app/models/`, `app/scheduler/models.py`, `app/migrations/` | +| Repository intelligence | Cache-local service | `app/services/code_index/` | + +The desktop shell starts the bundled sidecar on loopback with an ephemeral port +and random bearer token, waits for a handshake and liveness, then makes the +origin and token available to the WebView. The frontend installs same-origin +desktop authentication before other modules capture `fetch`. Cross-origin +requests do not receive the desktop token. + +## Repository structure and ownership + +```text +app/ Python sidecar, CLI, agent runtime, services, persistence +web/ React/Vite UI embedded in Tauri +desktop/ Rust/Tauri shell and native packaging +seed/ first-install agent and configuration templates +scripts/ development, validation, packaging, and release utilities +tests/ Python backend, integration, CLI, and packaging tests +docs/ only project documentation root +test-artifacts/ checked-in visual evidence for selected tests/reviews +``` + +Before editing anywhere, read the root `AGENTS.md` and the nearest nested one. +Nested instructions currently exist for `app/`, `app/agent/`, `app/api/`, +`app/services/`, `web/`, `web/src/`, `desktop/`, `desktop/src-tauri/`, `seed/`, +and `scripts/`. + +### Backend map + +| Path | Responsibility and extension rule | +|---|---| +| `app/api/routes/` | HTTP, SSE, upload, and WebSocket boundary. Validate and delegate; do not accumulate business logic here. | +| `app/api/schemas/` | Shared transport request/response shapes. Any shape change must be traced into frontend parsing and rendering. | +| `app/services/` | Reusable business logic for routes, CLI, scheduler, and agent runtime. | +| `app/agent/agent_loop/` | Per-turn streaming loop, retry/fallback, tool dispatch, checkpointing, and observation limits. | +| `app/agent/hooks/` | Context injection, memory, persistence, streaming, telemetry, diagnostics, and completion behavior. | +| `app/agent/mode/team/` | Lead/member lifecycle, mailboxes, delegation, handoff, todo, continuation, and team concurrency. | +| `app/agent/providers/` | Provider adapters and routing. Provider-specific wire shapes stop here. | +| `app/agent/tools/` | Tool registry, metadata, permission integration, and built-in implementations. | +| `app/agent/skills/` | Skill discovery, collision resolution, settings overlays, and activation. | +| `app/agent/mcp/` | User-global MCP config, process/client manager, and exposed MCP tools. | +| `app/plugin_platform/` | Portable Agent Plugin install/runtime boundary and isolated plugin MCP. | +| `app/workflow/` | Workflow schema, validation, graph, runner, and node handlers. | +| `app/scheduler/` | At/every/cron task persistence and team dispatch. | +| `app/conductor/` | Optional managed-resource and telemetry control-plane client. | +| `app/core/` | Settings, paths, database lanes, auth, logging, metrics, and telemetry. | +| `app/models/` and `app/migrations/` | Application SQLModel metadata and 54-version Alembic history at this snapshot. | +| `app/cli/` | `evoflux` CLI parser and command modules. | + +### Frontend map + +| Path | Responsibility and extension rule | +|---|---| +| `web/src/router.ts` | Work, Coding, Telemetry, and Scheduler route tree. Settings and Help are overlays. | +| `web/src/routes/work.tsx` | Resolves/restores Work or Coding session focus and synchronizes URL/store state. | +| `web/src/components/TeamChatView/` | Main composition root for chat, sidebars, workbench, SSE hooks, and responsive layouts. | +| `web/src/components/shell/` | Shared application, sidebar, row, menu, and side-panel chrome. Reuse these primitives. | +| `web/src/components/workbench/` | Lazy workbench dock, registry, surfaces, and open-with behavior. | +| `web/src/api/` | HTTP client domains, same-origin token handling, SSE parser, and Tauri boundary. | +| `web/src/queries/` | TanStack Query keys, queries, mutations, and durable server-state caching. | +| `web/src/stores/` | Zustand live-turn and client/UI state. SSE reduction lives with the team store. | +| `web/src/help/locales/` | Localized user-facing Help in English, Vietnamese, and Japanese. | +| `web/src/__tests__/` | Vitest tests for components, API helpers, stores, hooks, Help, and utilities. | + +`TeamChatView` and the team store are high-connectivity areas. They are already +split into hooks, reducers, domain clients, and lazy panels; extend those seams +instead of growing a second composition or state path. + +### Desktop and packaging map + +| Path | Responsibility | +|---|---| +| `desktop/src-tauri/src/main.rs` | Tauri plugins, windows, tray/events, and native command registration | +| `desktop/src-tauri/src/sidecar.rs` | Sidecar discovery, launch, handshake, health, and cleanup | +| `desktop/src-tauri/src/workspace.rs` | Native workspace/filesystem integration | +| `desktop/src-tauri/src/native_messaging.rs` | Browser native-messaging bridge | +| `desktop/src-tauri/capabilities/` | Explicit Tauri command grants | +| `desktop/src-tauri/tauri*.json` | Production, external-dev, and bundled-dev variants | +| `scripts/build_sidecar.py` | Standalone Python runtime assembly | +| `.github/workflows/desktop-packages.yml` | Four-platform packaging, signing, updater, and artifact workflow | + +## End-to-end runtime walkthrough + +### Startup + +1. Tauri creates the application shell and starts or connects to the Python + sidecar according to the selected development/production configuration. +2. The sidecar reports its chosen loopback port and token through the handshake. +3. FastAPI initializes the workspace roots, validates/migrates the database in + production, seeds the wiki, initializes telemetry, and exposes the critical + API before slower optional integrations finish. +4. Optional startup tasks reconcile MCP, plugin MCP, Conductor, agent files, + scheduler, scoped-memory backfill, Dream scheduling, and retention services. +5. React waits for backend readiness, installs authentication, restores theme, + appearance, locale, and the last mode route, then mounts the router. + +Optional service failure is observable but should not make the critical local +API disappear. Shutdown drains or stops teams, indexes, processes, previews, +language servers, memory extraction, telemetry, and database engines. + +### One chat turn + +1. `POST /api/team/chat` validates and queues a request, returning `202`. +2. `team_manager` resolves/builds the correct Work or Coding team. Coding + identity includes the authorized workspace/project, not just a UI mode bit. +3. The session transcript and current checkpoint are loaded from SQLite. +4. Hooks add bounded workspace instructions, project/folder context, selected + Skills, profile data, scoped memory recall, and other runtime context. +5. A provider adapter translates canonical messages/tools to its own protocol + and streams text, reasoning, usage, and tool calls back into canonical types. +6. The loop partitions tool calls into concurrency-safe waves and serial + barriers, with at most ten concurrently executing tools. +7. Permission, sandbox, workspace authorization, and outbound-data policy are + enforced before the tool/model boundary. Large observations can be offloaded + to artifacts rather than kept inline. +8. The loop continues through tool results until a final response, explicit + pause, interruption, recoverable continuation, or terminal error. +9. Checkpoints and hooks persist transcript/usage, update goals/workflows, + publish SSE events, collect diagnostics, and schedule memory extraction. +10. The frontend loads durable history first, layers the live SSE projection + over it, and updates Query caches through an explicit invalidation bridge. + +SSE is the live projection; SQLite session/message history is the durable truth +used for reconnect and pagination. Bidirectional terminal, direct-browser, and +WebBridge relay channels use WebSockets instead. + +### Teams and delegation + +Exactly one lead is required per team. Member definitions are lazy blueprints, +not permanent background processes. Delegation creates instances such as +`coder#1`; each gets its own child session, mailbox, history, and lifecycle. +Member text/tools/status are streamed through the parent session without +copying the member's complete model context into the lead. Durable delegation +rows record the work state, while the lead remains responsible for verifying a +handoff before presenting it as complete. + +## How knowledge is stored + +“Knowledge” has two meanings in this project and they should not be mixed. + +### Runtime knowledge used by agents + +| Layer | Storage | Role and trust rule | +|---|---|---| +| Working memory | `chat_sessions` and `session_messages` in the application DB | Full durable transcript; provider-visible history may be compacted, but audit history remains. | +| Episodic evidence | `memory_fact_evidence` | Links a fact to the source session/message that established it. | +| Semantic memory | `memory_facts` | Deduplicated facts with explicit user/project/workspace/folder/session scope, confidence, kind, status, origin, and occurrence count. | +| Extraction cursor | `memory_extraction_states` | Retryable lease/cursor so background extraction is idempotent across restarts. | +| Inspectable wiki | Markdown under `EVOFLUX_WIKI_DIR` | Human-readable profile, notes, topics, entities, sources, comparisons, indexes, and Dream logs. Treated as data, not executable policy. | +| Code knowledge | One cache-local SQLite DB per repository | Rebuildable files, chunks, FTS rows, symbols, relations, vectors, and parse errors. Never application DB state. | + +Semantic facts are the canonical automatic-recall store. The wiki is an +inspectable consolidation/projection and manual knowledge surface, not the sole +copy of extracted memory. Automatic extraction begins after the configured +completed-lead-response threshold (default behavior starts at three and then +periodically), rejects secret-like content, limits fact count/length, and +coerces invalid broad scopes to the narrowest available safe scope. + +Recall searches only compatible scopes and injects a small cited result inside +an explicit untrusted-data boundary. User-global scope is limited to explicit +durable preferences/profile; technical decisions stay project/workspace/folder +or session local. Deleting a session removes its evidence and deletes a fact +only when no other evidence remains. + +The wiki is seeded idempotently with: + +```text +USER.md durable profile YAML, injected as bounded data +INDEX.md Dream-maintained table of contents +LOG.md chronological Dream log +LINT.md latest wiki lint result +topics/ concepts +entities/ people, tools, organizations, products +sources/ source summaries +comparisons/ comparison pages +notes/ append-only daily agent/user notes +imports/ raw imported evidence +``` + +Dream incrementally consolidates top-level sessions and changed notes. Database +watermarks prevent unchanged sources from being processed repeatedly; +filesystem locks and atomic replacement protect cross-process wiki writes. + +Repository code indexes live at: + +```text +/code-index//code-context.sqlite3 +``` + +They are desired-state caches and may be rebuilt after schema/corruption +checks. Multi-repository links are resolved dynamically over only the +repositories authorized for the active project; no global cross-repository +knowledge graph is persisted. + +### Runtime roots and retention + +| Root | Typical contents | Recovery expectation | +|---|---|---| +| data | `evoflux.db`, installed plugin registry/private data, durable artifacts | Back up; internal and denied to agents by default | +| config | `.env`, settings, agent Markdown, Skills, MCP, sandbox, workflows | Back up; user-editable and policy-controlled | +| state | logs, per-session JSONL diagnostics, snapshots, OTEL, Conductor queues | Operational evidence; not primary product truth | +| cache | code indexes, model/OAuth cache, previews, LSP packages | Regeneratable | +| wiki | profile, notes, consolidated knowledge | Back up; bounded agent access | +| workspace | Work session files/uploads; Coding uses authorized repositories | User output; retention depends on mode/project | + +Development defaults live below `.evoflux/dev/`; production defaults use +XDG-style directories under the user's home. Tests override all roots to +`.tests/`. See the [configuration reference](../reference/configuration.md) for +exact environment variables and precedence. + +Session JSONL under `/logs/sessions//.jsonl` records +diagnostic events such as model calls, assistant messages, tools, results, and +usage. It is observability data; the SQL transcript remains canonical. + +### Development knowledge used by contributors + +Use this precedence when deciding what the software is supposed to do: + +1. an accepted specification for the planned change; +2. current-state contracts in `docs/features/`, `docs/architecture/`, and + `docs/reference/`; +3. tests as executable evidence; +4. existing code as implementation evidence; +5. historical material in `docs/plans/`, `docs/analysis/`, `docs/research/`, + and `docs/releases/` as rationale, not proof of current behavior. + +`AGENTS.md` files define contributor process and local invariants. The feature +catalogue maps behavior to owners. Architecture pages define storage, process, +concurrency, and trust boundaries. Reference pages define public API/config/CLI +contracts. Tests do not substitute for an absent product specification. + +If code, tests, and current docs disagree during discovery, investigate and +state the discrepancy; do not silently choose the easiest artifact. When a +change ships, current docs must describe the implementation and historical +plans must not be mistaken for the active contract. + +## Persistence and database rules + +The application database is SQLModel metadata managed by Alembic. At this +snapshot its durable domains include sessions/messages/folders, Coding +workspaces/projects, Git server connections, team delegation, goals, scoped +memory, Dream watermarks, scheduler tasks, workflows/gates, and WebBridge state. + +SQLite uses WAL and foreign keys with two deliberate lanes: + +- a bounded read engine/pool for read requests; +- one FIFO writer connection for mutations and durable streams. + +Never hold a database transaction while scanning the filesystem, invoking Git, +calling a model/network, starting a process, or waiting for SSE. Prepare +external work first, keep the write transaction short, and use explicit +idempotency/optimistic checks where retries are possible. + +A schema change is incomplete unless it: + +1. updates/imports SQLModel metadata; +2. adds an Alembic revision; +3. passes migration-head and supported upgrade-path tests; +4. updates affected feature/architecture/reference documentation; +5. defines compatibility and rollback behavior. + +## Coding conventions + +### Python + +- Python `>=3.12`, dependency management and commands through `uv`. +- Use `from __future__ import annotations`, `|` unions, strict signature types, + absolute `app...` imports, Pydantic v2, SQLModel, and async I/O boundaries. +- External/provider payload models normally use `ConfigDict(extra="ignore")` + for forward compatibility. +- Loguru uses structured templates such as + `logger.info("event_name key={}", value)`; avoid interpolation that defeats + structured fields. +- Routes validate/translate. Services/runtime own reusable behavior. +- Provider adapters translate to/from canonical chat, stream, tool, and usage + schemas; generic API/team code should not recognize provider wire formats. +- Tool changes must account for registry metadata, mode tier, deferred loading, + permission/sandbox behavior, result bounding/offload, UI rendering, and tests. +- Preserve async cancellation, cleanup, and retry semantics. Tests should use + injected timing/factories rather than real sleeps or external services. + +### TypeScript and React + +- ESM only, strict TypeScript, functional components, explicit props, and + application imports through `@/`. +- The existing files use single quotes and no semicolons; follow surrounding + formatting rather than introducing a second style. +- TanStack Query owns server state. Zustand/Immer owns live streaming and UI + state. Components should not create a parallel durable cache. +- API changes flow through domain clients, query hooks/store projection, and + focused rendering tests. +- Shared shell/workbench primitives own application chrome; new screens should + compose them rather than hand-roll sidebars or resizable panels. +- `localStorage` keys belong in `STORAGE_KEYS`; numeric z-index literals are + replaced by the `--z-*` token scale. +- Markdown parsing is centralized in `src/utils/markdown.tsx` and enforced by + ESLint restricted imports. +- Large workbench panels are lazy-loaded. Preserve the initial chat bundle and + use small store selectors to avoid unstable render subscriptions. + +### Rust and Tauri + +- Rust 2021 with minimum Rust 1.77 and Tauri v2. +- Keep lifecycle/auth changes small and platform-aware. Check Windows, macOS, + and Linux cleanup and process behavior. +- Native commands require matching Tauri capability grants; do not expose a + broad command because one frontend caller needs a narrow operation. +- Keep production, external-dev, and bundled-dev configuration variants in + sync when a capability, plugin, or sidecar contract changes. +- Do not commit `target/`, sidecar bundles, generated packages, signing keys, + or machine-local state. + +### Tests and documentation + +- Python tests mirror backend ownership under `tests/agent`, `api`, `services`, + `core`, `workflow`, `scheduler`, `plugin_platform`, `conductor`, and `cli`. +- Frontend unit/component tests live under `web/src/__tests__`; Rust unit tests + are colocated and desktop/package contracts also have Python tests. +- Test names should describe observable behavior. For non-trivial work, make + the acceptance-criterion mapping discoverable in the plan, test, or evidence. +- Current behavior goes in `docs/features`, `docs/architecture`, or + `docs/reference`; proposed/historical work goes elsewhere and is labeled. +- User-visible changes also update localized in-app Help when applicable. + +## Commit-message convention + +There is no separate commit-lint configuration in this snapshot, so Git +history is the practical convention rather than an independently enforced +contract. Use Conventional Commit-style subjects: + +```text +(): +``` + +Examples from current history: + +```text +refactor(memory): add scoped durable learning +fix(db): repair and enforce sqlite foreign keys +fix(code-graph): isolate and cache snapshot builds +perf(chat): stabilize incremental transcript rendering +docs(coding): define semantic intelligence contracts +test(code-graph): close combined mutation gate +chore: bump version to 0.0.7 +``` + +Recommended types are `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, +`chore`, and rarely `style`. Choose a stable product/component scope such as +`agent`, `api`, `browser`, `chat`, `code-graph`, `coding`, `db`, `desktop`, +`git`, `lsp`, `memory`, `providers`, `release`, `sandbox`, `skills`, `ui`, +`web`, or `webbridge`. + +Evidence from the latest 200 subjects at this snapshot: 94 `fix`, 56 `feat`, +9 `refactor`, 8 `perf`, 7 `docs`, 6 `test`, 5 `chore`, 1 `style`, and 14 merge +or non-conventional subjects. In other words, scoped conventional subjects are +the dominant style; merge commits and a few legacy/simple subjects are +exceptions. Recent commits generally have concise subjects and no body, so add +a body when compatibility, security, migration, or non-obvious rationale would +otherwise be lost. + +Commit discipline for continued work: + +- one coherent change/outcome per commit; +- imperative summary, lowercase type/scope, no trailing period; +- keep generated artifacts, secrets, and machine paths out of commits; +- do not mix unrelated cleanup with a feature/fix; +- make specification, implementation, tests, and current docs reviewable as + one traceable change when they define the same shipped contract. + +## Run, test, and build commands + +### Install + +```bash +uv sync +cd web && bun install --frozen-lockfile +``` + +### Development + +```bash +make run # FastAPI only; uvicorn default :8000 +make dev-web # FastAPI :8000 + Vite :5173 +make dev-desktop # FastAPI + Vite + Tauri source shell +``` + +Use `make -C desktop dev-bundled` when validating the packaged-sidecar import, +migration, authentication, resource, or cleanup path. It is slower and is not +the normal edit loop. + +### Quality gates + +```bash +uv run ruff check app/ tests/ +uv run ruff format --check app/ tests/ +uv run ty check app/ +uv run pytest --no-cov -q + +cd web +bun run lint +bun run typecheck +bun run build + +cd desktop/src-tauri +cargo check +``` + +Use the smallest focused suite while iterating, then expand according to the +affected boundary. The repository currently contains a desktop packaging +workflow, not a general all-layer CI workflow, so local quality gates are +especially important and must be reported exactly at handoff. + +### Build and package + +```bash +make build-web # web/dist +make build # Python wheel +make -C desktop sidecar # standard sidecar + Office preview engines +make -C desktop sidecar-full # adds Azure Document Intelligence +make -C desktop build # native package on the host platform +``` + +Tagged desktop builds require updater signing inputs. macOS signing/notarization +and Windows Authenticode use CI secrets; Linux packages are updated through the +package manager rather than in-place Tauri replacement. Follow the +[release and packaging contract](release-and-packaging.md) before changing +versioning, signing, sidecar resources, or installer behavior. + +## Continued-development workflow + +The repository requires Specification-Driven Development (SDD) and uses +Agent-Driven Development (ADD) only where bounded parallel ownership improves +the result. Use this sequence for every non-trivial change. + +### 1. Establish a clean evidence baseline + +- Read root and nearest nested `AGENTS.md` files. +- Record branch, `git status --short`, and relevant user-owned changes. +- Read the feature catalogue row, current feature contract, architecture and + reference pages, owning code, migrations, frontend consumers, and focused + tests. +- Distinguish implemented behavior from plans/research and note any mismatch. + +### 2. Classify the change + +| Change type | Required preparation | +|---|---| +| User-visible feature, public API/event, persistence, security, compatibility | Full specification with stable AC IDs and verification matrix before implementation | +| Bug against documented behavior | Cite the contract and add a failing regression test; update spec only if ambiguous/changing | +| Internal refactor/performance | Record invariants, measurable outcome, and verification plan | +| Trivial docs/typo/mechanical edit | Clear task scope is sufficient | + +When auth, permissions, migrations, concurrency, provider protocol, filesystem +scope, or release behavior is involved, use the stronger path even if the diff +looks small. + +### 3. Specify and plan + +- Put proposed design in `docs/plans/`; update current-state docs when behavior + actually ships. +- Define goals, non-goals, user states, API/event/UI contracts, data and trust + behavior, failure/recovery/idempotency, compatibility, rollback, diagnostics, + and acceptance criteria. +- Map each AC to implementation owner, test/evidence, and current docs. +- Plan vertical slices that leave the application usable and can be verified + independently. +- Delegate only disjoint, concrete work with explicit file ownership and ACs; + the lead still integrates and verifies cross-layer seams. + +### 4. Implement at the owning boundary + +- Route change: transport validation in `app/api`, durable logic in a service or + runtime module, response schema plus frontend client/query/store/rendering. +- SSE change: backend envelope, frontend parser, block/store reducer, UI + acknowledgement, and focused tests move together. +- Persistence change: model metadata, migration, upgrade-path tests, + compatibility/rollback, and docs move together. +- Tool change: registry/tier/deferred metadata, permission/sandbox, execution, + result rendering, and tests move together. +- UI change: query/store ownership, shared chrome, mobile behavior, Help, and + component tests move together. +- Native change: Rust command/lifecycle, capabilities, every relevant Tauri + config, frontend bridge, desktop tests, and platform smoke evidence. + +Do not silently revise accepted behavior to fit the easiest implementation. If +discovery invalidates the specification, stop that slice, revise the spec/plan, +and make the deviation visible. + +### 5. Verify in layers + +1. run the smallest regression test that proves the change; +2. run the owning directory's lint/type/test command from its `AGENTS.md`; +3. run seam tests for every changed API/SSE/persistence/native boundary; +4. run broader gates in proportion to risk; +5. inspect the final diff for unrelated or generated changes; +6. run `git diff --check`; +7. verify docs links and Help/catalog/reference reconciliation. + +Do not claim completion when a required migration, contract consumer, generated +resource, doc link, or affected-layer test remains unresolved. Separate +pre-existing failures from failures introduced by the change with evidence. + +### 6. Hand off for the next contributor + +Report: + +- outcome and ACs satisfied; +- files and public/internal contracts changed; +- exact commands and results; +- assumptions and material decisions; +- remaining risks, blockers, or checks not run; +- confirmation that unrelated work was preserved. + +## Common change traces + +| If changing... | Trace at minimum... | +|---|---| +| Chat/session behavior | session models and migrations → chat/team service → route/SSE → API client → team store/query caches → chat UI → backend/frontend tests → feature docs | +| Agent provider | provider factory/catalog/capabilities → adapter schemas/streaming → generic canonical schema compatibility → provider tests → Settings/model UI if exposed | +| Tool | registry metadata/tier → implementation → permission/sandbox → observation/offload → SSE/block rendering → tests and Help | +| Memory | extraction/context hooks → scoped-memory service/models → deletion/provenance behavior → wiki projection/Dream → memory tests → memory architecture/feature docs | +| Coding intelligence | workspace/project authorization → repository-local index/LSP service → API → Query/store → editor/graph/Problems UI → parser/service/frontend tests | +| Git/review | workspace authorization and credential boundary → Git/review service → thin route → domain client/query → Git/review panel → destructive-action guard tests | +| Scheduler/workflow | persisted model → runner/scheduler service → team turn boundary → route/tool → frontend projection → restart/idempotency tests → automation docs | +| Desktop startup | Tauri sidecar supervisor → CLI `serve` handshake → FastAPI auth/health → frontend bootstrap → Tauri capabilities/config variants → package smoke tests | + +## Known hotspots and practical cautions + +- **Session identity is cross-layer.** Work/Coding mode, workspace, project, + parent/side-chat ownership, model, and permission state must agree across URL, + store, service, and DB. Do not infer Coding authorization from the current UI. +- **Streaming has two truths with different lifetimes.** SSE is an in-memory + live projection; the DB is reconnect history. Changes must work during a live + turn, after refresh, and after process restart. +- **SQLite is deliberately serialized for writes.** Long transactions or hidden + I/O inside them can stall the whole desktop product even when unit tests pass. +- **Memory is scoped and untrusted.** Never convert a project decision into a + user-global preference or inject recalled/wiki text as policy. +- **Code indexes are disposable and authorization-scoped.** Do not move them + into app tables or persist cross-repository guesses. +- **Agent files are user-owned after initialization.** First-party base prompts + stay in code; seed frontmatter is additive and affects new installs only. +- **Frontend state ownership is intentional.** Query, Zustand, URL state, and + local storage each have a distinct role. Duplicating state produces difficult + reconnect and navigation bugs. +- **Optional integrations must degrade visibly.** MCP, plugins, Conductor, + Dream, browser, document engines, and LSP may be absent; keep the core API and + chat surface diagnosable. +- **Desktop changes are multi-platform.** A Windows-only fix can still alter + shared lifecycle or capability configuration used on macOS/Linux. +- **Plans are historical records.** A detailed plan is not evidence that a + feature shipped; confirm through current docs, code, and tests. + +## Suggested first-day walkthrough + +For a new contributor, this reading/debugging path gives the fastest useful +model of the project: + +1. [Documentation index](../README.md), [feature catalogue](../features/README.md), + and [system overview](../architecture/system-overview.md). +2. `app/api/app.py` for lifecycle and public router families. +3. `app/services/team_manager.py` and `app/agent/agent_loop/core.py` for team and + turn execution. +4. `app/models/chat.py`, `app/models/memory.py`, and + [data/storage architecture](../architecture/data-and-storage.md). +5. `web/src/router.ts`, `web/src/routes/work.tsx`, and + `web/src/components/TeamChatView/index.tsx` for UI composition. +6. `web/src/stores/useTeamStore/` and `web/src/api/` for history/live state. +7. `desktop/src-tauri/src/sidecar.rs` for the production process boundary. +8. The focused tests beside the area you intend to change. + +Then run one mode locally, follow a single chat turn from the network request to +SSE rendering and persisted history, and only after that choose an extension +point. This exposes the cross-layer contracts that are easy to miss in a +directory-only tour. + +## Primary references + +- [System overview](../architecture/system-overview.md) +- [Backend runtime](../architecture/backend-runtime.md) +- [Web frontend](../architecture/web-frontend.md) +- [Desktop shell](../architecture/desktop.md) +- [Data and storage](../architecture/data-and-storage.md) +- [Memory architecture](../architecture/memory-system.md) +- [SQLite concurrency](../architecture/sqlite-concurrency.md) +- [Application harness](../architecture/application-harness.md) +- [Repository map](../reference/repository-map.md) +- [Configuration](../reference/configuration.md) +- [HTTP API](../reference/http-api.md) +- [Development and testing](setup-and-testing.md) +- [Release and packaging](release-and-packaging.md) + +## Snapshot notes + +This review inspected the owning instructions, current architecture/feature/ +reference documentation, runtime entry points, application models and latest +migration, memory/wiki/index implementations, agent loop/provider/team +boundaries, API router families, frontend router/query/store/composition paths, +Tauri sidecar/package configuration, test layout, build scripts, and the latest +200 Git subjects. The worktree was clean before this documentation was added. + +Because this is a living system, refresh the snapshot line and any numeric +observations (version, migration count, provider count, commit distribution) +when they materially change; the linked current-state contracts remain the +preferred durable source. diff --git a/docs/development/run-locally-quickstart.md b/docs/development/run-locally-quickstart.md new file mode 100644 index 00000000..743d6f1f --- /dev/null +++ b/docs/development/run-locally-quickstart.md @@ -0,0 +1,137 @@ +# Run EvoFlux locally — quick start (Windows) + +Status: contributor quick-reference. For full detail, see +[local-development-windows-macos.md](local-development-windows-macos.md). + +This project runs as **three processes** together: + +```text +FastAPI backend (port 8000) + Vite UI (port 5173) + Tauri desktop window +``` + +You need **3 PowerShell windows** open at the same time, one per process. +Run everything from the repository root: `D:\evoflux\evoflux`. + +--- + +## 0. One-time setup (skip if already done) + +Only needed the very first time, or after pulling changes that touch +dependencies. + +```powershell +uv sync --frozen +cd web +bun install --frozen-lockfile +cd .. +cd desktop\src-tauri +cargo check +cd ..\.. +``` + +--- + +## 1. Every time you start working + +### Terminal 1 — Backend (FastAPI) + +```powershell +$env:APP_ENV = "development" +uv run alembic -c app\alembic.ini upgrade head +uv run uvicorn app.server:app --host 127.0.0.1 --port 8000 --reload --reload-dir app --no-access-log +``` + +Wait until you see `critical_startup_ready` in the log. Leave this terminal open. + +### Terminal 2 — Frontend (Vite) + +```powershell +cd web +$env:VITE_API_PROXY_TARGET = "http://127.0.0.1:8000" +bun dev +``` + +Wait until you see `VITE ... ready`. Leave this terminal open. + +### Terminal 3 — Desktop window (Tauri) + +```powershell +cd desktop\src-tauri +$env:EVOFLUX_DESKTOP_DEV_BACKEND_URL = "http://127.0.0.1:8000" +cargo tauri dev -c tauri.dev.conf.json +``` + +The first build compiles Rust code and can take 30–60+ seconds. When it +finishes you'll see `Running target\debug\evoflux-desktop.exe` and a window +titled **EvoFlux (dev)** will open. + +--- + +## 2. Check it worked + +```powershell +Invoke-RestMethod http://127.0.0.1:8000/api/health/live +``` + +Should return `status: ok`. The **EvoFlux (dev)** window should be visible and +past its loading screen. + +--- + +## 3. While you work + +- **Backend (Python) changes** → Terminal 1 auto-reloads (`--reload`). No restart needed. +- **Frontend (React/TypeScript) changes** → Terminal 2 hot-reloads in the open window. No restart needed. +- **Desktop shell (Rust) changes** → Terminal 3 detects the change and rebuilds/relaunches automatically. Just wait for it to finish. + +### If you changed dependencies (not just code) + +| You changed... | Run this before restarting | +|---|---| +| `pyproject.toml` / `uv.lock` | `uv sync --frozen` | +| `web/package.json` / `web/bun.lock` | `cd web && bun install --frozen-lockfile && cd ..` | +| `desktop/src-tauri/Cargo.toml` | `cd desktop\src-tauri && cargo check && cd ..\..` | + +### If you changed database models + +```powershell +$env:APP_ENV = "development" +uv run alembic -c app\alembic.ini upgrade head +``` + +--- + +## 4. Restarting after a fix (or after closing everything) + +Stop each terminal with `Ctrl+C`, in this order: Terminal 3, then Terminal 2, +then Terminal 1. Then just repeat **section 1** above (Terminal 1 → 2 → 3) — +Terminal 1's `alembic upgrade head` is safe to re-run every time, it does +nothing if the database is already current. + +If a terminal was closed by accident (not with `Ctrl+C`), check nothing is +still holding the ports before restarting: + +```powershell +Get-NetTCPConnection -LocalPort 8000,5173 -State Listen -ErrorAction SilentlyContinue +``` + +If something is listed, stop that leftover process (or just close its +terminal window) before starting a fresh one on the same port. + +--- + +## 5. Common problems + +| Symptom | Fix | +|---|---| +| Vite complains a package "could not be resolved" | `cd web && bun install --frozen-lockfile && cd ..`, then restart Terminal 2 | +| `alembic current` is behind `alembic heads` | `uv run alembic -c app\alembic.ini upgrade head` | +| Port 8000 or 5173 already in use | Find and close the old terminal/process, don't force-kill blindly | +| Tauri window never opens | Check Terminal 3 for a Rust compile error; fix the error, save, it rebuilds automatically | +| UI loads but can't reach backend | Confirm Terminal 1 shows `critical_startup_ready` and `http://127.0.0.1:8000/api/health/live` returns `ok` | + +--- + +## 6. Stopping everything + +`Ctrl+C` in Terminal 3, then Terminal 2, then Terminal 1, in that order. diff --git a/documents/architecture/system-overview.md b/documents/architecture/system-overview.md index 6bc81910..e611e98b 100644 --- a/documents/architecture/system-overview.md +++ b/documents/architecture/system-overview.md @@ -96,6 +96,14 @@ transcript and then resume live streaming. - **Integration:** global MCP, plugin MCP, provider adapters, WebBridge, and Conductor have separate configuration and lifecycle boundaries. +## Remote adapter trust boundary + +The remote adapter is an outbound-only polling bridge — it connects to the +Telegram API but never listens for inbound connections. Tokens live in the OS +credential vault (keyring), never in the database or environment variables. The +adapter uses a stream observer pattern to project outbound events (current task, +gates, completion) into Telegram chats while applying full redaction. + ## Source-of-truth map | Contract | Owner | diff --git a/documents/features/remote-access.md b/documents/features/remote-access.md new file mode 100644 index 00000000..021c8700 --- /dev/null +++ b/documents/features/remote-access.md @@ -0,0 +1,294 @@ +# Remote access + +Status: **Optional** — requires a user-owned Telegram bot and network access +from the EvoFlux host. + +## Problem and outcome + +When the user walks away from the desktop, EvoFlux becomes unreachable. Active +sessions may pause on a gate (permission, question, plan) and idle until the +user returns. Remote access lets the user receive, read, and resolve those gates +from a phone through a personal Telegram bot — one tap to approve, one message +to continue. + +## Goals + +1. **One-tap pairing** — scan a QR, open a deep link, or enter a short code; + no manual token entry on the phone. +2. **Current-task text** — the bot shows what the active session is doing so the + user can decide without opening the desktop. +3. **Automatic gates and completion** — permission requests, questions, and plan + review arrive as inline-button messages. Phone-started turns get one live + status card plus a native typing indicator; their final completion or error + card edits that same message in place. +4. **Safe redaction** — every outbound message passes through + `protect_outbound_text(channel="remote")` so secrets and PII never leave the + machine. + +## Non-goals + +- No shared or multi-tenant bot — each installation owns one bot. +- No inbound listener — the adapter polls outbound; Telegram cannot push into + EvoFlux. +- No public URL — the sidecar stays loopback; no webhook endpoint is exposed. + +## User flows and states + +### Setup + +1. In Settings → Remote access, the user clicks **Open BotFather & copy + /newbot**. The button opens @BotFather in Telegram and copies the + `/newbot` command to the clipboard so the user can paste it immediately. +2. The user creates a bot in BotFather, copies the token, and pastes it into + the EvoFlux token field. +3. EvoFlux stores the token in the OS credential vault, verifies it with the + Telegram API, creates a `remote_connections` row, and starts the adapter. +4. Once the adapter reaches `polling` state, EvoFlux automatically issues a + one-time pairing link and displays a **QR code** on the settings page. The + QR code encodes a `t.me/?start=` deep link. +5. The user scans the QR code with their phone camera (or taps the link). + Telegram opens and sends the `/start ` command automatically — no + typing needed. +6. EvoFlux records the `chat_id` in `remote_pairings` and the state becomes + `paired`. + +Users who prefer to type a code can expand the "Prefer to type a code +instead?" fallback section, which generates an 8-digit code and a `/pair` +command. + +The connection status uses adaptive polling: the frontend polls every 2 +seconds during transitional states (`starting`, `backoff`) and every 15 +seconds once the connection is stable (`polling`, `offline`). This ensures +the UI tracks adapter lifecycle changes in real time without navigating +away from the settings page. + +### Daily use + +1. User sends a message to the bot on Telegram. +2. The remote adapter polls `getUpdates`, matches the `chat_id` to a pairing, + and forwards the text into the active session as a user message. +3. The adapter immediately sends one HTML-formatted status card and repeats + Telegram's native typing indicator while the phone-started turn is unresolved. + The status card contains only the task title and admission status. +4. On completion or error, the adapter edits that card into a bounded final + summary. It never mirrors token, tool, or file-path deltas into the live + status text. +5. A completed card can offer **Full diff** and **Tool log**. Each button is a + short-lived, opaque capability for that exact turn only; tapping it sends + the already-persisted detail as redacted, escaped, bounded follow-up cards. + It cannot retrieve a different turn or session, and an expired button asks + the user to request fresh detail. + +### Desktop, Workflow, and Scheduler completion + +- The pairing's persisted notification scope defaults to `all`. Under that + scope, an addressable top-level Work or Coding session started from the + desktop, Workflow, or Scheduler sends the same final completion/error card + to the paired phone. +- These cross-origin sessions never create a live status card or typing + indicator. Side Chat, child, internal, and otherwise non-addressable + sessions never notify the phone. +- The `remote_only` scope keeps notifications limited to `remote_origin` + sessions. + +### Gate flow + +1. The session hits a permission, question, or plan gate. +2. The stream observer emits a gate event; the adapter sends a Telegram message + with inline buttons (e.g. Allow once / Reject, Accept / Revise / Reject). +3. The user taps a button. The adapter resolves the gate and continues. + +### Unpair + +- Desktop: Settings → Remote access → Remove. +- Phone: send `/pair ` to connect, or `/unpair` to disconnect. + +## Requirements and acceptance criteria + +Requirements and acceptance criteria are defined in the base implementation +plan as AC-1 through AC-36 and amended by the response-UI specification. The +implemented response-UI additions are AC-38 (bounded phone-turn lifecycle), +AC-39 (on-demand turn detail), AC-42 (notification scope), and AC-43 +(cross-origin completion delivery). +The status, done, and error-card paths also implement the response-card portion +of revised AC-24. They cover: + +- Connection lifecycle (create, verify, update label, replace token, delete) +- Pairing lifecycle (link, QR, resolve, revoke) +- Outbound projection (current task, gate events, completion summaries) +- Inbound forwarding (text messages to active session) +- Remote permission replies (limited to `once` and `reject`) +- Redaction of outbound text (secrets and PII) +- SQLite concurrency (per-pairing locks, source_key idempotency) +- Connection states (11 values covering setup through error) +- One connection per installation (v1) +- Migration `00000064` +- HTML-safe Telegram cards, native typing, and one status-card lifecycle for + phone-started turns +- Opaque, ten-minute Full diff and Tool log capabilities scoped to the + connection, principal, destination, and completed turn +- Addressability-gated final delivery for desktop, Workflow, and Scheduler + sessions when notification scope is `all` + +## API, event, tool, and UI contracts + +### Settings endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/settings/remote` | Read remote settings | +| `PUT` | `/api/settings/remote` | Update remote settings | + +### Connection endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/remote/connections` | List connections (0 or 1) | +| `POST` | `/api/remote/connections` | Create connection (write-only token) | +| `PATCH` | `/api/remote/connections/{id}` | Update label or enabled flag | +| `PUT` | `/api/remote/connections/{id}/token` | Replace bot token | +| `DELETE` | `/api/remote/connections/{id}` | Remove connection | + +### Pairing endpoints + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/remote/connections/{id}/pairing-links` | Issue pairing link | +| `POST` | `/api/remote/connections/{id}/pairing-codes` | Issue one-time8-digit pairing code | +| `GET` | `/api/remote/connections/{id}/pairing` | Read pairing state | +| `DELETE` | `/api/remote/connections/{id}/pairing` | Revoke pairing | + +### Settings shape + +```yaml +outbound_data_policy: block | redact | off # default: redact +outbound_pii_policy: off | standard | strict # default: standard +``` + +### Connection shape + +``` +id, label, enabled, bot_username, state, created_at, updated_at +``` + +Token is write-only; read responses never return it. + +### Pairing shape + +``` +id, connection_id, chat_id, principal_id, state, paired_at +``` + +## Data model + +### `remote_connections` + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID | primary key | +| `label` | text | user-facing name | +| `enabled` | boolean | default true | +| `bot_username` | text | resolved from Telegram API | +| `bot_token_vault_key` | text | OS vault reference, never stored in DB | +| `state` | enum(11) | `disconnected` → `connecting` → `connected` → `paired` … `error` | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +### `remote_pairings` + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID | primary key | +| `connection_id` | UUID | FK → `remote_connections` | +| `chat_id` | bigint | Telegram chat ID | +| `principal_id` | text | authorizes the pairing | +| `destination_id` | text | addresses the pairing | +| `state` | enum | `pending` → `paired` → `revoked` | +| `source_key` | text | idempotency key for `getUpdates` offset | +| `notify_scope` | text | `all` (default) or `remote_only`; controls cross-origin final notifications | +| `paired_at` | timestamp | | + +### OS vault + +Bot tokens are stored in the OS credential vault (keyring) only. The DB stores +a vault key reference, never the raw token. + +## Permissions, security, privacy, and trust + +- **Token storage**: bot token lives in OS credential vault (keyring) only; never + in the database, environment variables, or config files. +- **Remote permission replies**: limited to `once` and `reject`. There is no + `always` option for remote — every remote approval is single-use. +- **Outbound redaction**: all outbound text passes through + `protect_outbound_text(channel="remote")` before reaching Telegram. +- **Safe HTML response cards**: the status, done, and error-card builders use + Telegram HTML parse mode only after outbound redaction. Every non-static + value passed to those builders is escaped by `app/remote/formatting.py`, so + agent or user content cannot create markup, links, or mentions. +- **Private chats only**: the adapter only processes private (non-group) chats. +- **Authorization model**: `principal_id` authorizes who can reply; + `destination_id` addresses which pairing receives the message. +- **On-demand detail**: a Full diff or Tool log token carries no content, + path, or session identifier. It is in-memory only, expires after ten + minutes, is bound to the connection, principal, and destination, and reads + only the detail captured for its own completed turn. +- **One connection per installation** (v1): only a single remote connection is + allowed at a time. + +## Concurrency, failure, recovery, and idempotency + +- **SQLite deadlock fix**: the adapter avoids holding write locks during I/O + (Telegram API calls happen outside the transaction). +- **Per-pairing locks**: each pairing has its own lock to prevent concurrent + `getUpdates` processing from corrupting state. +- **Source key idempotency**: the `source_key` column on `remote_pairings` + tracks the last processed Telegram update offset so restarts do not re-deliver + messages. +- **Adapter crash recovery**: on startup the adapter resumes polling from the + last persisted offset; no messages are lost if the process restarts. +- **Lifecycle ordering**: outbound delivery serializes queued status sends, + finalization, and edits. A final card falls back to a new message only when + its original status card could not be delivered. +- **Cross-origin authorization**: the synchronous stream observer queues an + unregistered completion, then the asynchronous delivery path loads the + session and applies the shared addressability predicate before sending. + +## Observability + +### Connection states + +The `state` column covers 11 values: + +`disconnected`, `connecting`, `connected`, `verifying`, `ready`, `paired`, +`polling`, `paused`, `error_token`, `error_network`, `error_api` + +### Adapter status + +Adapter health is reported through the standard `/api/health` and Diagnostics +routes. The remote section shows connection state, last poll time, and error +count. + +## Compatibility, rollout, and rollback + +- **One connection per installation** (v1): the API rejects a second connection + creation with `409`. +- **Migration**: Alembic migration `00000064` adds `remote_connections` and + `remote_pairings` tables. +- **Rollback**: removing the connection through the API or reverting the + migration cleanly drops the tables. + +## Ownership and source map + +| Layer | Files | +|---|---| +| Adapter and polling | `app/remote/adapter.py`, `app/remote/poller.py` | +| Telegram client | `app/remote/telegram_client.py` | +| Connection and pairing services | `app/remote/connection_service.py`, `app/remote/pairing.py` | +| Outbound projection | `app/remote/projection.py` | +| Redaction | `app/remote/redaction.py` (wraps `protect_outbound_text`) | +| API routes | `app/api/routes/remote.py`, `app/api/routes/settings_remote.py` | +| Data models | `app/models/remote_connection.py`, `app/models/remote_pairing.py` | +| Migration | `app/migrations/versions/00000064_*.py` | +| Tests | `tests/remote/` | +| Frontend API client | `web/src/api/client/remote.ts` | +| Frontend settings page | `web/src/routes/settings.remote-access.tsx` | diff --git a/documents/features/security-and-permissions.md b/documents/features/security-and-permissions.md index 7ea50932..2c5fa37a 100644 --- a/documents/features/security-and-permissions.md +++ b/documents/features/security-and-permissions.md @@ -75,6 +75,25 @@ deliver policy-scoped telemetry. `report` mode surfaces drift without blocking; `enforce` applies governed resource policy. Credentials live outside normal settings payloads, and managed provenance is displayed in Settings. +## Remote access + +Remote access adds an outbound-only Telegram bridge with these constraints: + +- Bot tokens are stored in the OS credential vault (keyring) only; the database + stores a vault key reference, never the raw token. +- Remote permission replies are limited to `once` and `reject`. There is no + `always` option — every remote approval is single-use. +- All outbound text passes through `protect_outbound_text(channel="remote")` + redaction before reaching Telegram. +- Telegram messages are sent without parse mode so model output is never + interpreted as markup. +- The adapter processes private chats only. `principal_id` authorizes who can + reply; `destination_id` addresses which pairing receives the message. +- One remote connection per installation is allowed (v1). + +Primary code: `app/remote/`, `app/api/routes/remote.py`, +`app/api/routes/settings_remote.py`. + ## Source and tests Primary code: `app/agent/permission.py`, `sandbox.py`, `sandbox_config.py`, diff --git a/documents/plans/remote-access-task-6-brief.md b/documents/plans/remote-access-task-6-brief.md new file mode 100644 index 00000000..6c7e38cd --- /dev/null +++ b/documents/plans/remote-access-task-6-brief.md @@ -0,0 +1,325 @@ +# Task 6 brief: natural-language ingress and current-task behavior + +Status: ready after Tasks 1-4; may be implemented while Task 5 is in progress + +Parent specification: [Remote access through Telegram](remote-channel-telegram.md) + +Implementation plan: [Remote access through Telegram implementation](remote-access-telegram-implementation.md#task-6-natural-language-ingress-and-current-task-behavior) + +## Objective + +Implement the provider-neutral service that turns an authorized remote text or +task action into EvoFlux session behavior. A paired user can send ordinary text +from their phone, continue one current desktop task, clear that selection for a +new Work task, or stop only the current live turn. + +This task covers `AC-14`, `AC-15`, `AC-16`, `AC-17`, `AC-18`, and `AC-29`. + +## Observable result + +- The first authorized plain-text update creates one top-level Work session, + marks it with remote provenance tags, stores it as the pairing's current + session, and submits the text through the shared interactive ingress. +- Later text for the pairing uses that current session and reports the real + `accepted`, `pending`, or `queued` result. +- Redelivery of the same Telegram update has at most one persisted user-message + effect, including the recovery path after persistence but before the adapter + advances its update offset. +- **New task** clears only the pairing's current-session pointer. It does not + delete, hide, archive, interrupt, or otherwise mutate the previous session. +- **Continue this task** changes only that pointer and accepts only a + user-visible, top-level Work or Coding session. +- `/stop` interrupts only a live turn in the pairing's current session. It + reports a distinct no-active-turn outcome when there is nothing to stop. + +## Ownership and coordination + +The Task 6 implementer owns: + +- Create `app/remote/inbound.py`. +- Modify `app/services/interactive_message_service.py`. +- Modify `app/services/chat_service.py` only to make channel-source delivery + state recognize `interactive_source` while retaining legacy + `webbridge_source` behavior. +- Create `tests/remote/test_inbound.py`. +- Modify or create focused service tests under + `tests/services/test_interactive_message_service.py` and + `tests/services/test_chat_service.py` as needed for source compatibility. + +Claude's Task 5 owns these files; Task 6 must not edit them: + +- `app/api/schemas/remote.py` +- `app/api/routes/remote.py` +- `app/remote/runtime.py` +- `app/api/app.py` +- `tests/api/routes/test_remote.py` +- `tests/remote/test_runtime.py` + +Do not stage, commit, reset, restore, or rewrite unrelated work. Re-read +`git status --short` before editing because the worktree is shared and dirty. + +## Available prerequisite contracts + +Use the existing contracts directly: + +- `RemoteInboundAction` and `RemotePrincipal` from `app.remote.contracts`. + The Telegram adapter already supplies a source key in the form + `telegram::`, which contains all three identity + parts required by `AC-16`. Preserve this key instead of deriving it from + message text or provider display values. +- `PairingService.authorize(db, connection_id=..., principal_id=...)` from + `app.remote.pairing`. It reads the pairing fresh and returns `None` for every + unauthorized or rate-limited request. +- `RemotePairing.active_session_id`. Its foreign key uses `ON DELETE SET NULL`, + so deleted-current-session recovery should use the resulting null pointer. +- `create_chat_session`, `resolve_team_for_session`, and + `submit_persisted_interactive_message` from the existing service layer. +- `agent_service.interrupt_team(team, session_id)` for a live current team. + Do not use `team_manager.stop_sessions`: it evicts sessions and is broader + than the user-level interrupt required by `AC-29`. + +Task 6 has no code dependency on Task 5's routes or lazy runtime. Do not import +from `app.api` or `app.remote.runtime`. + +## Service contract + +Add `RemoteInboundService` in `app/remote/inbound.py` with four asynchronous, +provider-neutral operations: + +```python +async def handle_text( + db: AsyncSession, + action: RemoteInboundAction, +) -> RemoteInboundResult: ... + +async def new_task( + db: AsyncSession, + action: RemoteInboundAction, +) -> RemoteInboundResult: ... + +async def continue_task( + db: AsyncSession, + action: RemoteInboundAction, + session_id: UUID, +) -> RemoteInboundResult: ... + +async def stop_current( + db: AsyncSession, + action: RemoteInboundAction, +) -> RemoteInboundResult: ... +``` + +The methods may share one private authorization helper. Each public operation +must authorize the action's `connection_id` and `principal.principal_id` before +reading or changing the pointer or resolving a team. + +Define a small immutable result type in the same module. It must carry a bounded +outcome code plus optional `session_id` and `message_id`, without Telegram text +or payload types. Required outcomes are: + +- `accepted`, `pending`, and `queued` for text admission; +- `current_task_cleared` and `current_task_selected`; +- `interrupted` and `no_active_turn`; +- `unauthorized` and `session_not_addressable`. + +The later adapter-integration task owns user-facing Telegram wording. + +## Session eligibility rule + +Use one private predicate for every remote selection and current-session +revalidation. A session is addressable only when all conditions hold: + +```python +session.parent_session_id is None +session.session_type == "main" +session.mode in {"work", "coding"} +``` + +This excludes team-member, Side Chat, and any future internal session types by +default. Do not infer eligibility from the title, agent name, tags, or whether +the session currently has a live in-memory team. + +If an existing current pointer references a row that is missing or no longer +addressable, clear the pointer and treat the next text as a first message. + +## First-message and current-message flow + +Serialize pointer decisions for one pairing with a connection/pairing-keyed +`asyncio.Lock`. Without this, two simultaneous first messages can both observe +a null pointer and create two current sessions. Keep the lock scoped to this +single-process service and clean unused keyed locks when practical. + +For an authorized `handle_text` call: + +1. Reject empty text after the adapter's normalization; never create a session + for it. +2. Load and revalidate `active_session_id` under the per-pairing lock. +3. If no valid current session exists, call `create_chat_session`, then set: + - `mode = "work"`; + - `parent_session_id = None`; + - `session_type = "main"`; + - `tags = ["remote_origin", f"remote_connection:{connection_id}"]`; + - `pairing.active_session_id = session.id`. +4. Commit the created session and pointer together before team resolution or + message dispatch. Do not hold a database transaction across filesystem, + model, team-start, or agent work. +5. Resolve the persisted session/team with + `resolve_team_for_session(..., require_existing=True)`. +6. Submit with `submit_persisted_interactive_message`, passing the original + plain text, `source_key=action.source_key`, the request hash, and the source + metadata described below. +7. Map the existing `InteractiveMessageResult` without changing its + `accepted`/`pending`/`queued` meaning. + +Use the normal Work session defaults for model, permission mode, workspace, and +follow-up delivery. Remote ingress must not grant broader permissions or select +a special model. + +## Channel-neutral source metadata + +Store new remote source information under `interactive_source`: + +```python +{ + "interactive_source": { + "channel": "remote", + "adapter": "telegram", + "connection_id": str(action.connection_id), + "key": action.source_key, + "request_hash": request_hash, + "state": "persisted", + } +} +``` + +Calculate `request_hash` deterministically from the admitted semantic request; +for text, SHA-256 of the normalized UTF-8 text is sufficient. Never include a +bot token, pairing token, callback token, display name, or destination ID. + +Generalize source handling through one helper that selects +`interactive_source` first and falls back to legacy `webbridge_source`. +Apply it consistently to: + +- lookup by source key; +- request-hash conflict checking; +- delivered-state replay detection; +- `mark_channel_source_delivered` for immediate delivery, post-turn queued + activation, and in-turn queued injection. + +Do not rename or rewrite existing `webbridge_source` rows. Existing WebBridge +callers may continue writing that key, and all current WebBridge retry tests +must remain green. + +## New, continue, and stop behavior + +`new_task` authorizes, locks the pairing, sets `active_session_id = None`, and +commits. It does not resolve or stop a team and does not create a replacement +session until the next text arrives. + +`continue_task` authorizes, loads the requested `ChatSession`, applies the exact +eligibility predicate, then changes and commits only `active_session_id`. It +does not resend history, start a team, or mutate the selected session. + +`stop_current` authorizes and revalidates the current session. Resolve the team +using existing session behavior, then call `agent_service.interrupt_team` only +when that team has an active user turn. Return `no_active_turn` when there is no +current addressable session or no active turn. Do not clear the pointer, stop +the sidecar, delete a session, call `stop_sessions`, or report `interrupted` +when no work was canceled. + +## Required tests + +Write tests before implementation and record the failing test command. + +### Source compatibility + +- `interactive_source` rows are found by source key. +- Legacy `webbridge_source` rows are still found. +- An `interactive_source` request-hash mismatch raises + `InteractiveMessageConflict`. +- Replaying a delivered `interactive_source` row returns `accepted` without a + second dispatch. +- Both source formats transition to `delivered` through + `mark_channel_source_delivered`. +- Existing WebBridge source tests remain unchanged and pass. + +### Text ingress + +- Unauthorized principal produces no session, pointer, message, or team work. +- First text creates exactly one top-level Work session with exactly the two + required provenance tags and sets the pointer. +- Later text reuses the pointed session. +- `accepted`, `pending`, and `queued` pass through accurately. +- Two concurrent first texts create one current session rather than two. +- Duplicate source-key delivery persists one user message and does not dispatch + twice. +- Recovery after a message was persisted but before update acknowledgement + returns the prior result without another user-message effect. +- A null pointer left by session deletion causes the next text to create a new + Work session. + +### Pointer actions and boundary + +- **New task** clears the pointer and leaves the previous session unchanged. +- **Continue this task** accepts a top-level Work session and a top-level Coding + session. +- It rejects a child/team-member session, Side Chat, unsupported/internal + session type, and unsupported mode without changing the existing pointer. +- A missing requested session returns `session_not_addressable`. +- Authorization is checked again on every action. + +### Stop + +- A live current turn calls `interrupt_team` for that session only and returns + `interrupted`. +- No current session, a deleted/non-addressable session, or an idle team returns + `no_active_turn` and never claims interruption. +- The pointer and session remain after interruption. +- No unrelated session or team is stopped. + +Use fakes/mocks for team resolution and dispatch where a real team would start +model or filesystem work. Database behavior, pointer persistence, foreign-key +cleanup, and message idempotency should use the repository's real async SQLite +test fixture. + +## Execution order + +1. Read the current shared files and status; incorporate non-conflicting changes + made since this brief was written. +2. Add failing compatibility tests for `interactive_source` plus legacy source + behavior. +3. Add failing inbound service tests for all flows and boundaries above. +4. Implement the smallest channel-neutral source helper and inbound service. +5. Run focused tests and lint. +6. Inspect the complete diff for overlap and accidental changes. + +## Verification evidence + +Run at minimum: + +```powershell +uv run pytest --no-cov -q tests/services/test_interactive_message_service.py tests/services/test_chat_service.py tests/remote/test_inbound.py +uv run pytest --no-cov -q tests/api/test_webbridge.py -k "source or idempot or queued" +uv run ruff check app/remote/inbound.py app/services/interactive_message_service.py app/services/chat_service.py tests/remote/test_inbound.py tests/services/test_interactive_message_service.py tests/services/test_chat_service.py +uv run ruff format --check app/remote/inbound.py app/services/interactive_message_service.py app/services/chat_service.py tests/remote/test_inbound.py tests/services/test_interactive_message_service.py tests/services/test_chat_service.py +uv run ty check app/ +git diff --check +``` + +If a listed test file does not yet exist, create the focused file or place the +cases in the nearest existing service test and report the actual path. Do not +broaden to the full suite until focused behavior is green. + +## Handoff requirements + +Return all of the following to the lead: + +1. Outcome and evidence mapped to `AC-14`, `AC-15`, `AC-16`, `AC-17`, `AC-18`, + and `AC-29`. +2. Files changed and any public/internal contracts introduced. +3. Exact red and green commands with their results. +4. Assumptions or deviations from this brief and why they were necessary. +5. Remaining risks, blockers, or follow-up work for adapter integration. +6. Confirmation that Task 5 and unrelated dirty-worktree files were preserved. + +Do not commit. diff --git a/documents/plans/remote-access-telegram-implementation.md b/documents/plans/remote-access-telegram-implementation.md new file mode 100644 index 00000000..1e002da3 --- /dev/null +++ b/documents/plans/remote-access-telegram-implementation.md @@ -0,0 +1,778 @@ +# Remote Access over Telegram Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one-tap, outbound-only phone access to a running EvoFlux installation through one user-owned Telegram bot. + +**Architecture:** A connection-aware `app/remote/` core owns authorization, lifecycle, routing, projection, and ephemeral capabilities; a Telegram adapter owns only Bot API translation and polling. Two database tables hold non-secret connection/pairing metadata, while a new, independent OS-vault abstraction stores tokens by connection ID. Phone text enters the existing persisted interactive path and normalized stream events leave through an explicit redaction boundary. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel/Alembic, asyncio, httpx, keyring, Pydantic v2, pytest; React 19, TypeScript, TanStack Query, existing Settings primitives, Bun/Vitest. + +**Spec:** [`remote-channel-telegram.md`](remote-channel-telegram.md) + +## Global Constraints + +- Telegram is the only v1 adapter; public storage and service contracts remain connection-aware. +- One configured connection and one paired Telegram account are allowed per installation in v1. +- Each installation uses a user-supplied personal bot token; there is no shared bot or relay. +- No inbound listener, tunnel, public URL, LAN mode, or loopback self-call is introduced. +- Tokens live only in the OS credential vault; vault failure leaves no partial configuration and has no `.env` fallback. +- Private chats only; principal ID authorizes and destination ID addresses replies. +- Messages received while EvoFlux is stopped are discarded at the next startup. +- Remote permission replies are limited to `once` and `reject`; remote access cannot widen durable policy or configuration. +- All outbound user/agent text uses explicit remote-channel redaction, defaults to `redact`, and is sent without Telegram parse mode. +- Stream observers are synchronous, bounded, and non-blocking; no network or database work runs under stream locks. +- **`app/conductor/client.py` and `app/conductor/service.py` are never modified.** Conductor is relied on externally by `evo-conductor`, including the exact public constructor signature of its existing credential wrapper; the new OS-vault abstraction (`app/core/credential_store.py`) is standalone code used only by `app/remote/`, not a refactor Conductor is migrated onto. +- Every task cites its ACs, adds focused evidence first, and leaves the application usable at its checkpoint. +- Commit all resulting changes in the repository, including unrelated pre-existing modifications already in the working tree, as a single commit once implementation is verified. + +--- + +## File and interface map + +New backend units: + +- `app/core/credential_store.py` — keyed OS-vault protocol and implementation, standalone and used only by `app/remote/` (Conductor keeps its own separate implementation, untouched). +- `app/remote/contracts.py` — provider-neutral dataclasses/enums/protocols. +- `app/remote/connection_service.py` — durable connection CRUD, v1 cardinality, vault coordination. +- `app/remote/pairing.py` — deep-link capability issuance and principal binding. +- `app/remote/inbound.py` — authorization, commands, current-task selection, persisted chat admission. +- `app/remote/outbound.py` — allowlisted event projection, redaction, splitting, delivery queues. +- `app/remote/gates.py` — opaque callbacks and existing gate-service resolution. +- `app/remote/actions.py` — optional Workflow/Coding/EASD/Scheduler menus. +- `app/remote/runtime.py` — process lifecycle and per-connection adapter ownership. +- `app/remote/telegram/models.py` — bounded Pydantic payloads with `extra="ignore"`. +- `app/remote/telegram/client.py` — raw Bot API HTTP calls and error classification. +- `app/remote/telegram/adapter.py` — polling loop and normalized action translation. +- `app/api/schemas/remote.py`, `app/api/routes/remote.py` — authenticated desktop API. +- `tests/core/test_credential_store.py`, `tests/remote/`, `tests/api/routes/test_remote.py` — focused backend evidence. + +New frontend units: + +- `web/src/api/client/remote.ts` — typed remote Settings/connection API. +- `web/src/components/settings/RemoteAccessSettings.tsx` — setup, QR/link, pairing, status, removal. +- `web/src/__tests__/components/settings/RemoteAccessSettings.test.tsx` — primary UI states and secret handling. + +Changed shared units: + +- `app/models/__init__.py`, `app/migrations/env.py`, `app/core/schema_version.py` — model/migration registration. +- `app/core/runtime_settings.py`, `app/api/schemas/settings.py`, `app/api/routes/settings.py` — remote outbound policy. +- `app/services/memory_stream_store.py` — synchronous global observer registry. +- `app/services/interactive_message_service.py` — channel-neutral source metadata lookup. +- `app/agent/outbound_redaction.py` — add `remote` to `OutboundChannel`. +- `app/api/app.py` — lazy optional remote startup and structured shutdown. +- Settings navigation/client exports, three Help locales, and current docs named by the spec. + +--- + +### Task 1: Keyed OS-vault abstraction and connection-aware schema + +**ACs:** AC-3, AC-4, AC-5, AC-6, AC-17 + +**Files:** + +- Create: `app/core/credential_store.py` (standalone; Conductor is not touched or migrated onto it) +- Replace unfinished content: `app/models/remote.py` +- Replace unfinished content: `app/migrations/versions/00000064_create_remote_pairings.py` +- Modify: `app/models/__init__.py` +- Modify: `app/migrations/env.py` +- Keep/verify: `app/core/schema_version.py` +- Create: `tests/core/test_credential_store.py` +- Create: `tests/remote/test_models.py` +- Modify: `tests/core/test_alembic_migrations.py` + +**Interfaces:** + +- Produces `CredentialStoreProtocol.load/save/delete`, keyed by service/account at construction. +- Produces `RemoteConnection` and `RemotePairing`; later tasks import them from `app.models`. +- No network or runtime service is introduced in this task. + +- [ ] **Step 1: Write vault tests that prove key isolation and secret-safe errors** + +```python +def test_keyed_store_uses_supplied_service_and_account(fake_keyring): + store = CredentialStore(service="EvoFlux Remote", account="connection:abc") + store.save("secret") + assert fake_keyring.saved == ("EvoFlux Remote", "connection:abc", "secret") + +def test_vault_error_does_not_echo_secret(fake_keyring): + fake_keyring.set_password.side_effect = RuntimeError("boom") + with pytest.raises(CredentialStoreError) as exc: + CredentialStore(service="EvoFlux Remote", account="connection:abc").save("secret") + assert "secret" not in str(exc.value) +``` + +- [ ] **Step 2: Run the vault tests and confirm they fail before the abstraction exists** + +```powershell +uv run pytest --no-cov -q tests/core/test_credential_store.py +``` + +Expected: collection/import failure for `app.core.credential_store`. + +- [ ] **Step 3: Implement the generic vault as new, standalone infrastructure** + +```python +class CredentialStoreProtocol(Protocol): + def load(self) -> str | None: ... + def save(self, credential: str) -> None: ... + def delete(self) -> None: ... + +class CredentialStore: + def __init__(self, *, service: str, account: str) -> None: ... + def load(self) -> str | None: ... + def save(self, credential: str) -> None: ... + def delete(self) -> None: ... +``` + +Use `keyring.get_password/set_password/delete_password`, preserve missing-entry delete behavior, and wrap all other failures in `CredentialStoreError` with fixed messages. **Do not touch Conductor.** `app/conductor/client.py` and `app/conductor/service.py` keep their own separate `CredentialStore`/`CredentialStoreError`/`CredentialStoreProtocol` definitions and parameterless constructor exactly as they are today — Conductor is relied on externally by `evo-conductor`, and this module exists purely for `app/remote/` to construct as `CredentialStore(service="EvoFlux Remote", account=f"connection:{connection_id}")`. + +- [ ] **Step 4: Write model and migration tests before replacing the partial schema** + +```python +def test_pairing_keeps_authorization_and_destination_separate(): + pairing = RemotePairing( + connection_id=uuid4(), principal_id="user-1", destination_id="chat-9", label="Phone" + ) + assert pairing.principal_id != pairing.destination_id +``` + +Extend migration evidence to assert head `00000064`, tables `remote_connections` and `remote_pairings`, both uniqueness constraints, connection cascade, and session `SET NULL`. + +- [ ] **Step 5: Implement and register the exact models** + +```python +class RemoteConnection(SQLModel, table=True): + id: UUID + adapter: str + label: str + enabled: bool + adapter_principal_id: str + adapter_username: str + created_at: datetime + updated_at: datetime + +class RemotePairing(SQLModel, table=True): + id: UUID + connection_id: UUID + principal_id: str + destination_id: str + label: str + display: str + active_session_id: UUID | None + created_at: datetime + last_seen_at: datetime +``` + +The migration creates both tables in dependency order. Do not retain the unfinished `channel`, `account_id`, or `revoked_at` columns from the earlier draft. + +- [ ] **Step 6: Run focused schema tests and confirm Conductor is untouched** + +```powershell +uv run pytest --no-cov -q tests/core/test_credential_store.py tests/remote/test_models.py tests/core/test_alembic_migrations.py tests/conductor/test_client_service.py +uv run ruff check app/core/credential_store.py app/models/remote.py tests/core/test_credential_store.py tests/remote/test_models.py +uv run ty check app/ +git diff --stat -- app/conductor +``` + +Expected: all tests pass; the last command prints no output, proving `app/conductor/` has no diff. + +--- + +### Task 2: Remote settings, core contracts, and durable connection service + +**ACs:** AC-3, AC-4, AC-5, AC-6, AC-23, AC-32, AC-34 + +**Files:** + +- Create: `app/remote/__init__.py` +- Create: `app/remote/contracts.py` +- Create: `app/remote/connection_service.py` +- Modify: `app/core/runtime_settings.py` +- Modify: `app/api/schemas/settings.py` +- Modify: `app/api/routes/settings.py` +- Modify: `app/agent/outbound_redaction.py` +- Create: `tests/remote/test_connection_service.py` +- Modify: `tests/api/test_settings_routes.py` +- Modify: `tests/agent/test_outbound_redaction.py` + +**Interfaces:** + +- Produces `RemoteSettings(outbound_data_policy="redact", outbound_pii_policy="standard")`. +- Produces `RemoteConnectionService.create/update_token/set_enabled/remove/list/get`. +- Consumes a `RemoteAdapterFactory.validate_token(adapter, token)` protocol so tests never call Telegram. +- Produces `OutboundChannel` value `remote` for Tasks 6–11. + +- [ ] **Step 1: Write failing settings and connection-transaction tests** + +Cover default redaction, settings round trip, one-connection `409` domain error, token validation before vault save, vault failure rollback, different-bot replacement invalidating pairing, same-bot replacement retaining it, and removal deleting the vault value. + +```python +async def test_create_rolls_back_when_vault_save_fails(session, service): + service.credentials.save.side_effect = CredentialStoreError("vault unavailable") + with pytest.raises(RemoteCredentialError): + await service.create_connection(session, token="bot-token", label="My phone") + assert (await session.exec(select(RemoteConnection))).all() == [] +``` + +- [ ] **Step 2: Run the focused failures** + +```powershell +uv run pytest --no-cov -q tests/remote/test_connection_service.py tests/api/test_settings_routes.py -k "remote or outbound" +``` + +- [ ] **Step 3: Define provider-neutral contracts** + +```python +class RemoteAdapterKind(StrEnum): + TELEGRAM = "telegram" + +@dataclass(frozen=True) +class ValidatedRemoteIdentity: + adapter: RemoteAdapterKind + principal_id: str + username: str + +class RemoteAdapterFactory(Protocol): + async def validate_token(self, adapter: RemoteAdapterKind, token: str) -> ValidatedRemoteIdentity: ... +``` + +Also define bounded enums for lifecycle state, safe error class, inbound action kind, outbound priority, and immutable principal/message/button values. External payload types do not escape adapter modules. + +- [ ] **Step 4: Implement settings and connection coordination** + +Add `RemoteSettings` to `RuntimeSettings`; add exact GET/PUT schemas/routes at `/api/settings/remote`. Construct vault accounts as `connection:`. Do not persist the token or a fingerprint. Keep DB transactions free of vault and network calls by validating first, storing the vault value, then committing metadata with compensating vault deletion on commit failure. + +- [ ] **Step 5: Run focused checks** + +```powershell +uv run pytest --no-cov -q tests/remote/test_connection_service.py tests/api/test_settings_routes.py tests/agent/test_outbound_redaction.py +uv run ruff check app/remote app/core/runtime_settings.py app/api/routes/settings.py app/api/schemas/settings.py app/agent/outbound_redaction.py tests/remote +``` + +--- + +### Task 3: Telegram Bot API client and adapter lifecycle + +**ACs:** AC-1, AC-6, AC-11, AC-12, AC-13, AC-24, AC-34 + +**Files:** + +- Create: `app/remote/telegram/__init__.py` +- Create: `app/remote/telegram/models.py` +- Create: `app/remote/telegram/client.py` +- Create: `app/remote/telegram/adapter.py` +- Create: `tests/remote/telegram/test_client.py` +- Create: `tests/remote/telegram/test_adapter.py` + +**Interfaces:** + +- Produces `TelegramClient.get_me/delete_webhook/get_updates/send_text/edit_text/answer_callback/set_commands`. +- Produces `TelegramAdapter.start/stop` and normalized inbound callback delivery. +- Consumes a token and callbacks; owns `httpx.AsyncClient`, offsets, polling, and Bot API error classification. + +- [ ] **Step 1: Write client translation and safe-error tests** + +Mock `httpx.MockTransport` for successful `getMe`, `getUpdates`, send/edit/ack, invalid-token `401`, conflict `409`, `429` with `parameters.retry_after`, chat `403`, malformed response, and transport failure. Assert exception strings never contain the request URL because it embeds the token. + +- [ ] **Step 2: Run the client tests and verify failure** + +```powershell +uv run pytest --no-cov -q tests/remote/telegram/test_client.py +``` + +- [ ] **Step 3: Implement bounded external payloads and the client** + +```python +class TelegramResponse(BaseModel, Generic[T]): + model_config = ConfigDict(extra="ignore") + ok: bool + result: T | None = None + error_code: int | None = None + description: str | None = None + parameters: TelegramResponseParameters | None = None +``` + +Build requests without logging full URLs, omit `parse_mode`, enforce the 1–64 byte callback payload contract before send, and translate failures to safe internal error classes. + +- [ ] **Step 4: Write poll lifecycle tests with injected clock/jitter** + +Prove webhook deletion uses `drop_pending_updates=True`; only `message` and `callback_query` are requested; updates are ordered; accepted update offsets advance; conflict/rate-limit/transport backoff follows policy; stop interrupts long poll/backoff promptly; duplicate start/stop is safe. + +- [ ] **Step 5: Implement the adapter state machine** + +```python +class TelegramAdapter: + async def start(self, on_action: Callable[[RemoteInboundAction], Awaitable[Admission]]) -> None: ... + async def stop(self) -> None: ... + def status(self) -> RemoteAdapterStatus: ... +``` + +Use an `asyncio.Event` for interruptible waits and a single owner task for polling. Never retry `invalid_token` until token replacement or explicit restart. + +- [ ] **Step 6: Run adapter checks** + +```powershell +uv run pytest --no-cov -q tests/remote/telegram +uv run ruff check app/remote/telegram tests/remote/telegram +uv run ty check app/ +``` + +--- + +### Task 4: One-tap pairing and principal authorization + +**ACs:** AC-7, AC-8, AC-9, AC-10 + +**Files:** + +- Create: `app/remote/pairing.py` +- Create: `tests/remote/test_pairing.py` + +**Interfaces:** + +- Produces `PairingService.issue_link/consume/unpair/authorize`. +- Consumes connection metadata and normalized `RemotePrincipal`. +- Issues opaque base64url tokens and returns `PairingLink(url, qr_payload, expires_at)`. + +- [ ] **Step 1: Write pairing capability tests** + +```python +def test_issued_token_is_url_safe_bounded_and_single_use(pairing_service): + link = pairing_service.issue_link(connection) + token = parse_qs(urlparse(link.url).query)["start"][0] + assert re.fullmatch(r"[A-Za-z0-9_-]{22,64}", token) + assert pairing_service.consume(token, principal) + assert not pairing_service.consume(token, principal) +``` + +Add expiry, private-chat, bot sender, wrong connection, one-pairing limit, separate destination, per-principal/global rate limit, silence decision, and immediate token invalidation cases. + +- [ ] **Step 2: Run and confirm focused failures** + +```powershell +uv run pytest --no-cov -q tests/remote/test_pairing.py +``` + +- [ ] **Step 3: Implement in-memory pairing tokens and durable binding** + +Use `secrets.token_urlsafe(16)` or stronger, a ten-minute monotonic expiry, constant-time token comparison where lookup does not already provide equivalent protection, and fixed refusal results that expose no connection state. Persist principal/destination only after every check passes. + +- [ ] **Step 4: Run pairing checks** + +```powershell +uv run pytest --no-cov -q tests/remote/test_pairing.py tests/remote/test_connection_service.py +uv run ruff check app/remote/pairing.py tests/remote/test_pairing.py +``` + +--- + +### Task 5: Desktop API and lazy runtime lifecycle + +**ACs:** AC-1, AC-2, AC-3, AC-5, AC-7, AC-10, AC-12, AC-13, AC-33, AC-34 + +**Progress:** Complete. + +**Files:** + +- Create: `app/api/schemas/remote.py` +- Create: `app/api/routes/remote.py` +- Create: `app/remote/runtime.py` +- Modify: `app/api/app.py` +- Create: `tests/api/routes/test_remote.py` +- Create: `tests/remote/test_runtime.py` + +**Interfaces:** + +- Produces the authenticated routes listed in the specification. +- Produces singleton `remote_runtime.start/stop/reconcile_connection/status`. +- Consumes connection, pairing, vault, and adapter services from Tasks 1–4. + +- [x] **Step 1: Write route/auth/secret-shape tests** + +Cover zero-or-one list, create, patch enable/label, token replacement, remove, pairing-link issue, pairing read/revoke, status, second-connection `409`, invalid UUID, missing resource, desktop auth, and OpenAPI absence of returned token fields. + +- [x] **Step 2: Write disabled-lifespan and shutdown tests** + +```python +async def test_disabled_start_does_not_import_telegram(monkeypatch): + sys.modules.pop("app.remote.telegram.adapter", None) + await remote_runtime.start() + assert "app.remote.telegram.adapter" not in sys.modules +``` + +Prove optional startup failure does not fail health readiness and shutdown stops the remote runtime after pending optional startup completes. + +- [x] **Step 3: Implement thin routes and lazy runtime construction** + +Routes validate HTTP shape and call services; they do not call Telegram directly. Import `app.remote.telegram.adapter` inside the enabled connection factory only. Add remote startup beside other optional services and explicit shutdown beside Conductor/Scheduler cleanup. + +- [x] **Step 4: Run route and lifecycle evidence** + +```powershell +$env:EVOFLUX_DESKTOP_TOKEN=$null +uv run pytest --no-cov -q tests/api/routes/test_remote.py tests/remote/test_runtime.py tests/api/test_app_lifespan.py +uv run ruff check app/api/routes/remote.py app/api/schemas/remote.py app/remote/runtime.py app/api/app.py tests/api/routes/test_remote.py tests/remote/test_runtime.py +``` + +--- + +### Task 6: Natural-language ingress and current-task behavior + +**ACs:** AC-14, AC-15, AC-16, AC-17, AC-18, AC-29 + +**Progress:** Complete. + +**Files:** + +- Create: `app/remote/inbound.py` +- Modify: `app/services/interactive_message_service.py` +- Modify: `app/services/chat_service.py` (channel-source delivery compatibility only) +- Create: `tests/remote/test_inbound.py` +- Modify: `tests/services/test_interactive_message_service.py` +- Modify or create: `tests/services/test_chat_service.py` + +**Interfaces:** + +- Produces `RemoteInboundService.handle_text/new_task/continue_task/stop_current`. +- Consumes `PairingService.authorize`, `create_chat_session`, `resolve_team_for_session`, `submit_persisted_interactive_message`, and existing interrupt behavior. +- Generalizes persisted source metadata from `webbridge_source` to channel-neutral `interactive_source` while reading legacy metadata for compatibility, including the shared delivered-state helper used by immediate and queued delivery. + +- [x] **Step 1: Write channel-neutral idempotency compatibility tests** + +Persist one `interactive_source` message and prove lookup/dedup; retain a regression that legacy `webbridge_source` rows still deduplicate WebBridge retries. + +- [x] **Step 2: Write inbound behavior tests** + +Cover first plain text creating a top-level Work session with provenance tags; subsequent text using the current session; queued/pending/accepted status; duplicate update effect-once; deleted current session recovery; **New task** clearing without deletion; **Continue this task** selecting only top-level Work/Coding; refusal of team-member, Side Chat, and internal sessions; `/stop` interrupting only the current live turn. + +- [x] **Step 3: Run failures** + +```powershell +uv run pytest --no-cov -q tests/services/test_interactive_message_service.py tests/services/test_chat_service.py tests/remote/test_inbound.py +``` + +- [x] **Step 4: Implement the channel-neutral source record** + +```python +message_extra = { + "interactive_source": { + "channel": "remote", + "adapter": "telegram", + "connection_id": str(action.connection_id), + "key": action.source_key, + "request_hash": request_hash, + "state": "persisted", + } +} +``` + +Do not make HTTP self-calls. Reuse existing team/session defaults and message locking. + +- [x] **Step 5: Run ingress evidence** + +```powershell +uv run pytest --no-cov -q tests/services/test_interactive_message_service.py tests/services/test_chat_service.py tests/remote/test_inbound.py +uv run ruff check app/remote/inbound.py app/services/interactive_message_service.py app/services/chat_service.py tests/remote/test_inbound.py +``` + +--- + +### Task 7: Global stream observation and safe completion delivery + +**ACs:** AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-34 + +**Files:** + +- Modify: `app/services/memory_stream_store.py` +- Create: `app/remote/outbound.py` +- Create: `tests/services/test_memory_stream_observers.py` +- Create: `tests/remote/test_outbound.py` + +**Interfaces:** + +- Produces `register_observer(callback) -> Callable[[], None]`. +- Produces `RemoteProjection.observe`, delivery workers, redaction, completion lookup, Unicode-safe plain-text splitting, and one lifecycle-message correlation per phone-admitted turn. +- Consumes normalized `StreamEnvelope`, database session factory, and adapter send/edit methods. + +- [ ] **Step 1: Write observer isolation tests** + +Prove registration/unregistration, ordering, observer exception isolation, no coroutine acceptance, no await under the stream lock, and unchanged subscriber behavior with no observer. + +- [ ] **Step 2: Implement the minimal synchronous observer registry** + +```python +Observer = Callable[[str, StreamEnvelope], None] + +def register_observer(observer: Observer) -> Callable[[], None]: ... +``` + +Invoke observers after state mutation and before leaving the existing lock only if the callback performs a pure `put_nowait`; alternatively snapshot observers under lock and invoke immediately after releasing it. Tests must establish the chosen ordering and non-blocking behavior. + +- [ ] **Step 3: Write exhaustive projection tests** + +Drive every known event family plus an unknown type. Assert only gates/replies, terminal errors, `done`, and relevant desktop notifications enqueue. Assert child/Side Chat/internal sessions are excluded, duplicate completion sources collapse, finalized assistant text is queried after `done`, missing text uses a stub, and desktop tasks emit no activity chatter. + +- [ ] **Step 4: Write redaction and rendering tests** + +Test task titles, gate fields, question options, plans, errors, and final text with planted secret/PII markers under `redact`, `block`, and `off`. Verify fixed block stubs, no parse mode, 4096-character provider bound, Unicode safety, and callback-free agent text. + +- [ ] **Step 5: Implement bounded priority queues and delivery** + +Use separate high-priority and informational queues. `observe` may only normalize bounded scalar fields and call `put_nowait`. Delivery workers perform DB lookup, explicit `protect_outbound_text(..., context=OutboundContext(channel="remote", ...))`, splitting, and Telegram sends. High-priority overflow increments a critical counter and leaves gates unresolved. + +- [ ] **Step 6: Run observer/outbound evidence** + +```powershell +uv run pytest --no-cov -q tests/services/test_memory_stream_observers.py tests/remote/test_outbound.py +uv run ruff check app/services/memory_stream_store.py app/remote/outbound.py tests/services/test_memory_stream_observers.py tests/remote/test_outbound.py +uv run ty check app/ +``` + +--- + +### Task 8: Gate cards, callback capabilities, and race handling + +**ACs:** AC-10, AC-23, AC-25, AC-26, AC-27, AC-28 + +**Files:** + +- Create: `app/remote/gates.py` +- Create: `tests/remote/test_gates.py` + +**Interfaces:** + +- Produces `RemoteGateBridge.on_gate/on_reply/handle_callback`. +- Consumes existing permission/question/plan lookup and reply services directly. +- Consumes adapter `answer_callback` before any resolution and `edit_text` after it. + +- [ ] **Step 1: Write all gate rendering tests** + +Cover permission `once/reject` only, multi-question validated answers, plan approve/reject, every callback under 64 bytes, redacted fields, and fixed provenance headings. + +- [ ] **Step 2: Write callback ordering and ownership tests** + +```python +async def test_callback_is_acknowledged_before_resolution(bridge, adapter): + await bridge.handle_callback(action) + assert adapter.calls.index("answer_callback") < adapter.calls.index("resolve_gate") +``` + +Add wrong principal, destination, connection, action, expired token, restart-empty map, already-resolved gate, duplicate tap, desktop-wins race, phone-wins race, and reply-event button removal. + +- [ ] **Step 3: Implement opaque capability records and service adapters** + +Use random compact tokens mapped to records containing connection/principal/destination/session/request IDs, allowed action set, Telegram message reference, and expiry. Do not serialize internal IDs into callback data. Resolve via the active `PermissionService`, `AskUserService`, or `PlanApprovalService` registry. + +- [ ] **Step 4: Run gate evidence** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py tests/agent/test_permission.py tests/agent/test_ask_user_validation.py tests/agent/test_ask_user_sse.py tests/agent/test_plan_mode.py +uv run ruff check app/remote/gates.py tests/remote/test_gates.py +``` + +--- + +### Task 9: Secondary actions without widening owner contracts + +**ACs:** AC-18, AC-25, AC-30, AC-31, AC-32 + +**Files:** + +- Create: `app/remote/actions.py` +- Create: `tests/remote/test_actions.py` + +**Interfaces:** + +- Produces `/start`, `/help`, `/status`, `/new`, `/stop`, `/unpair`, and **More actions** dispatch. +- Consumes existing read/start/fire services for sessions, Coding projects, Workflows, Evo Agent Specs, and Scheduler. +- Reuses the opaque capability store from Task 8 for list choices. + +- [ ] **Step 1: Write command allowlist and refusal tests** + +Assert no command accepts credentials, repository paths, settings changes, permission mode, workflow approval, or arbitrary identifiers. Unknown slash commands return bounded help only to the paired principal. + +- [ ] **Step 2: Write one happy and one denied path per owner** + +Prove approved Workflow starts and changed hash refuses; authorized Coding project creates a task and unknown/unowned project refuses; eligible EASD action starts and blocked action preserves structured refusal; manually-triggerable schedule fires and unavailable task refuses. + +- [ ] **Step 3: Implement small owner-specific adapters** + +Each menu loader returns bounded `RemoteMenuItem(token, label, description)` and each action calls one existing service entry point. Do not copy owner validation into `actions.py`; translate owning exceptions into safe remote messages. + +- [ ] **Step 4: Run action evidence** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py tests/workflow tests/scheduler -k "remote or approval or trigger" +uv run ruff check app/remote/actions.py tests/remote/test_actions.py +``` + +--- + +### Task 10: Settings UI, one-tap connection, and status + +**ACs:** AC-3, AC-5, AC-7, AC-10, AC-12, AC-34, AC-35, AC-36 + +**Files:** + +- Create: `web/src/api/client/remote.ts` +- Modify: `web/src/api/types.ts` +- Modify: `web/src/api/client/settings.ts` +- Create: `web/src/components/settings/RemoteAccessSettings.tsx` +- Modify: Settings navigation/layout owning files identified during implementation +- Create: `web/src/__tests__/components/settings/RemoteAccessSettings.test.tsx` +- Modify: `web/package.json` and `web/bun.lock` only if no existing QR renderer can safely render the payload + +**Interfaces:** + +- Produces typed hooks/client calls for connection CRUD, pairing link, pairing state, and status. +- Consumes existing Settings overlay cards, form controls, secret inputs, confirmation dialog, and query cache conventions. + +- [ ] **Step 1: Write UI tests for the primary states** + +Render unconfigured, validating, vault failure, configured-disabled, connecting QR/link, paired, invalid token, used elsewhere, phone unreachable, and removal confirmation. Assert the token input clears after submit and never appears in rendered status or cached GET data. + +- [ ] **Step 2: Run the tests and confirm failure** + +```powershell +Set-Location web +bun test src/__tests__/components/settings/RemoteAccessSettings.test.tsx +``` + +- [ ] **Step 3: Implement the typed client and Settings page** + +The primary copy is: EvoFlux must stay running; create a personal bot; paste its token once; select **Connect phone**; scan or open Telegram. Render the QR from the returned HTTPS payload locally, never through a remote image service. Use connection-neutral labels except where identifying Telegram as the selected adapter. + +- [ ] **Step 4: Verify keyboard, focus, and narrow layout behavior** + +Test labels, error announcements, tab order, QR alternative link, secret-input autocomplete, destructive removal confirmation, and mobile-width wrapping. Avoid adding a new app route; Settings remains an overlay. + +- [ ] **Step 5: Run frontend evidence** + +```powershell +Set-Location web +bun test src/__tests__/components/settings/RemoteAccessSettings.test.tsx +bun run lint +bun run typecheck +bun run build +``` + +--- + +### Task 11: Current documentation, Help, and operator guidance + +**ACs:** AC-3, AC-11, AC-12, AC-30, AC-32, AC-35, AC-36 + +**Files:** + +- Create: `documents/features/remote-access.md` +- Modify: `documents/features/README.md` +- Modify: `documents/features/security-and-permissions.md` +- Modify: `documents/architecture/system-overview.md` +- Modify: `documents/reference/configuration.md` +- Modify: `documents/reference/http-api.md` +- Modify: `web/src/help/locales/en.ts` +- Modify: `web/src/help/locales/vi.ts` +- Modify: `web/src/help/locales/ja.ts` + +**Interfaces:** + +- Produces current-state documentation only after runtime behavior exists. +- Keeps this plan in `documents/plans/` as historical design rationale. + +- [ ] **Step 1: Write the current feature contract from verified behavior** + +Document setup, one-bot-per-computer scope, one-tap pairing, current task, automatic gates/completion, offline discard, permission limits, Telegram retention, revocation, states, source map, and focused tests. Mark the feature Optional because it needs a user-owned Telegram bot and network access. + +- [ ] **Step 2: Reconcile architecture and public references** + +Add the outbound remote adapter to the system topology/trust boundaries, exact `remote` settings fields, exact HTTP route family, lifecycle states, and secret masking. Link rather than duplicate long explanations. + +- [ ] **Step 3: Add equivalent Help in all three locales** + +Each locale must explain the same steps and warnings: EvoFlux must remain running; messages sent while stopped do not run later; bot chats leave the machine; **Allow once** is the maximum remote permission; each computer needs its own bot; revoke from Settings or `/unpair`. + +- [ ] **Step 4: Validate documentation and Help consumers** + +```powershell +Set-Location web +bun run lint +bun run typecheck +bun run build +Set-Location .. +rg -n "Remote access|Telegram|remote_connections|/api/remote" documents web/src/help/locales +``` + +Expected: current docs agree with implemented names and every local link resolves. + +--- + +### Task 12: Cross-boundary acceptance and clean handoff + +**ACs:** AC-1–AC-37 + +**Files:** + +- Modify only files needed to fix failures caused by Tasks 1–11. +- Do not alter unrelated desktop-generated permissions or user documentation files. + +**Interfaces:** + +- Validates the complete path: vault → connection → one-tap pair → phone text → persisted task → gate/completion projection → callback resolution → revoke/shutdown. + +- [ ] **Step 1: Add one end-to-end mocked Telegram integration test** + +Use a real temporary database and fake vault/Telegram transport. Create a connection, issue/consume a pairing link, submit text, observe one persisted message, push a permission gate, resolve it by callback, persist assistant completion, observe one completion, then unpair and prove later input is refused. + +- [ ] **Step 2: Run all focused remote and seam tests** + +```powershell +$env:EVOFLUX_DESKTOP_TOKEN=$null +uv run pytest --no-cov -q tests/remote tests/core/test_credential_store.py tests/core/test_alembic_migrations.py tests/services/test_interactive_message_service.py tests/services/test_memory_stream_observers.py tests/api/routes/test_remote.py +``` + +- [ ] **Step 3: Run backend quality gates** + +```powershell +uv run ruff check app/ tests/ +uv run ruff format --check app/ tests/ +uv run ty check app/ +uv run pytest --no-cov -q +``` + +- [ ] **Step 4: Run frontend quality gates** + +```powershell +Set-Location web +bun run lint +bun run typecheck +bun run build +Set-Location .. +``` + +- [ ] **Step 5: Inspect schema, privacy, and diff boundaries** + +```powershell +uv run alembic heads +uv run pytest --no-cov -q tests/core/test_alembic_migrations.py +rg -n "BOT_TOKEN|api\.telegram\.org/bot|pairing_token|callback_token" app tests documents web/src +git diff --check +git status --short +``` + +Inspect every match so tests/examples remain synthetic and no real secret, raw update, or token-bearing URL reaches logs, diagnostics, committed fixtures, or GET responses. Confirm only the intended migration head exists and unrelated worktree changes remain untouched. + +- [ ] **Step 6: Produce the AC evidence handoff, then commit everything together** + +Report changed files, exact commands/results, evidence grouped by AC, any pre-existing failures, checks not run, and remaining risk. Per the user's instruction, once implementation is verified, stage and commit the full working tree — this feature's changes together with any pre-existing unrelated modifications already present — as a single commit, rather than isolating this feature into its own commit. + +## Rollback + +Disable the connection first to stop the poller. If code rollback is required, +remove the remote router/lifecycle hook, `app/core/credential_store.py`, and +Settings surface, unregister the stream observer, and downgrade migration +`00000064` to drop only `remote_pairings` and `remote_connections`. Conductor +is never part of this rollback since it was never modified. Existing chat +sessions and their normal messages remain intact; provenance tags on +remote-created sessions are harmless if retained or may be removed by a +bounded migration-independent cleanup. diff --git a/documents/plans/remote-channel-telegram.md b/documents/plans/remote-channel-telegram.md new file mode 100644 index 00000000..a257f910 --- /dev/null +++ b/documents/plans/remote-channel-telegram.md @@ -0,0 +1,776 @@ +# Remote access over Telegram + +Status: proposed + +> **Amended by `documents/plans/remote-telegram-response-ui.md`:** AC-24 (no +> parse mode) and the outbound-data-policy portion of Non-goals/AC-32 are +> revised there. Read that document's amendment note before treating those +> two items as current. + +## Problem and outcome + +EvoFlux is a local-first desktop application. The Tauri shell starts the +FastAPI sidecar on loopback with a per-launch bearer token, so a phone cannot +reach the application directly. When the user leaves a long-running task and +takes only their phone, a permission request, question, or plan review can +block the run until they return. Completion notices also remain on the desktop. + +The outcome is a personal remote connection that dials out from a running +EvoFlux installation to Telegram. Each user creates and configures their own +bot on their own computer. After that one-time setup, they select **Connect +phone**, scan a QR code or open a link, and press Telegram's Start button. The +phone is paired without entering server addresses, session identifiers, or a +manual code. + +Once paired, the user opens the bot and writes naturally. EvoFlux keeps one +current remote task for that connection, creates a Work task for the first +message when needed, and lets the user start a fresh task with one button. +Blocking gates and completion notices from user-visible desktop tasks arrive +automatically with the task name. Selecting **Continue this task** makes that +desktop task the phone's current conversation. + +Telegram is the only adapter in the first release and each installation permits +one configured connection and one paired Telegram account. The core is +connection-aware: stored records, credential keys, service interfaces, source +keys, and callback ownership include a connection identifier. A later release +can permit several connections or add another adapter without changing those +ownership contracts. This is a narrow adapter seam, not a general plugin +framework. + +Telegram's `getUpdates` long poll is outbound. The feature adds no listener, +tunnel, public URL, or LAN requirement. Telegram deep links carry a private +`start` parameter of at most 64 base64url characters, which is sufficient for a +single-use pairing token. Protocol constraints are defined by the official +[Bot API](https://core.telegram.org/bots/api) and +[deep-linking contract](https://core.telegram.org/bots/features#deep-linking). + +## Goals + +- Let a user operate their running EvoFlux installation from a phone without an + inbound network path. +- Make first connection a scan-or-tap flow after the bot token is configured on + the desktop. +- Accept natural-language work without requiring the user to understand + sessions, IDs, modes, or command syntax. +- Deliver and resolve `permission_asked`, `question_asked`, and + `plan_approval_requested` gates from the phone. +- Deliver one completion report for each user-visible top-level task, including + tasks started on the desktop. +- Allow the user to continue a desktop task or start a new remote Work task with + one explicit action. +- Keep approved workflows, Coding projects, Evo Agent Specs runs, and scheduled + tasks available through a secondary menu without making that menu part of the + primary flow. +- Preserve all existing permission, sandbox, workspace, workflow-approval, and + outbound-data policies. +- Keep the feature free at rest: when unconfigured or disabled, it performs no + imports of adapter code, creates no tasks, and makes no network calls. +- Make every installation responsible for its own bot and credential; EvoFlux + provides no shared relay or hosted bot. +- Keep the first implementation ready for multiple connection records while + enforcing a one-connection product limit in v1. + +## Non-goals + +- No inbound HTTP endpoint, public listener, tunnel, reverse proxy, or + `evoflux start --lan` requirement. +- No EvoFlux-hosted Telegram bot, shared bot token, account service, cloud + relay, or cross-installation routing. +- No more than one configured remote connection or one paired Telegram account + per installation in v1. +- No group chats, channel chats, topics, inline mode, Mini Apps, guest mode, or + business-bot behavior. +- No attachments, voice, photos, files, location, contacts, reactions, or media + in either direction. +- No remote entry or modification of provider credentials, bot credentials, + sandbox policy, outbound-data policy, connection settings, model-provider + configuration, or `permission_mode: bypass`. +- No durable **Always allow** permission decision from the phone. +- No remote approval of a new or changed Workflow definition. A remotely + started Workflow must already satisfy the existing definition-hash approval + contract. +- No execution of Telegram messages accumulated while EvoFlux is stopped. +- No complete session browser or transcript export. The channel sends bounded + event projections and finalized replies, not historical session content. +- No durable outbound outbox, Telegram update archive, callback table, or + remote-interaction table in v1. +- No general remote-channel plugin SDK, adapter discovery protocol, or + third-party adapter loading. + +## User flows and states + +### Configure the personal connection + +The user creates a bot with `@BotFather`, opens **Settings → Remote access**, +and pastes the token. EvoFlux calls `getMe` before storing anything. A valid +response supplies the immutable bot identifier and display username shown for +review. EvoFlux then stores the token in the OS credential vault under the new +connection ID and creates the connection record. + +If validation or vault storage fails, no connection record remains and the UI +shows an actionable error. The token is never written to `settings.yaml`, the +application database, logs, diagnostics, or the config-directory `.env` file. + +The installation permits one configured connection in v1. The service returns +a stable conflict response if a second connection is requested. This is a +service-level product limit rather than a database uniqueness constraint. + +### Connect the phone + +The user selects **Connect phone**. EvoFlux creates an in-memory, single-use +pairing token with at least 128 bits of entropy and a ten-minute expiry. The UI +shows both a QR code and an **Open Telegram** action for: + +```text +https://t.me/?start= +``` + +Opening the link presents Telegram's normal Start action. The resulting +`/start ` update must come from a private chat. EvoFlux binds the +transport-reported principal ID and destination/chat ID to the connection, +deletes the pairing token, sends **Connected to EvoFlux on **, +and displays the paired Telegram account on the desktop. + +The principal ID authorizes actions. The destination ID addresses replies. +They are stored separately and are never assumed to be equal, even when the +first adapter commonly reports matching values for a private chat. + +A plain `/start` without a valid token does not pair. An invalid or expired +token receives no installation details. The UI can mint a replacement without +changing the connection or bot token. + +### Send the first task + +After pairing, the user sends ordinary text. If the pairing has no current +task, EvoFlux creates a top-level Work session using the existing default team, +model, permission mode, and Work workspace behavior. The message is persisted +and dispatched through the same interactive ingress used by the desktop. The +bot replies with a short accepted or queued acknowledgement. + +The created session receives channel-neutral provenance tags +`remote_origin` and `remote_connection:`. These tags support +diagnostics and future ownership checks; they do not weaken tool policy and are +not required for desktop sessions to emit remote notices. + +Later plain text continues the pairing's current task. If the task is already +running, existing follow-up delivery policy decides whether the message is +spliced into the turn or queued. The phone reports the actual accepted, +pending, or queued result. + +### Observe and continue a desktop task + +While the connection is enabled and paired, the remote service observes +user-visible top-level Work and Coding sessions. Team-member child sessions, +Side Chat sessions, internal sessions, and raw specialist activity are not +remote-addressable. + +Blocking gates and terminal completion notices are projected automatically. +Each message names the task and includes an opaque **Continue this task** +button. Pressing it changes only that pairing's `active_session_id`; it does not +modify the session, cancel work, or resend history. The bot confirms the new +current task. Subsequent plain text follows up in that session through the +normal persisted ingress. + +The phone never needs a `/sessions` command for the primary flow. A secondary +**Recent tasks** menu may list a bounded set of top-level sessions if the user +explicitly opens the menu. + +### Start a new task + +The persistent **New task** action clears the current binding and creates a new +top-level Work session when the user submits their next message. It never +deletes, interrupts, archives, or hides the previous task. + +Starting a Coding task requires selecting an existing authorized Coding +project from the secondary menu. Remote input cannot add a repository or widen +the selected project's authorized workspace paths. + +### Answer a blocking gate + +Permission cards identify the task, tool, and complete policy-relevant patterns +after outbound redaction. Choices are **Allow once** and **Deny**. There is no +remote **Always allow** action. + +Question cards render every required question and its allowed choices. Freeform +answers are accepted only when the existing question schema permits them. Plan +cards show a bounded plan summary and offer the same transient approval/reject +decisions as the existing plan-review service. + +The callback query is acknowledged immediately, before database access or gate +resolution. The card is then edited to a terminal state. If the gate was +answered on the desktop first, the buttons disappear and the card says that it +was answered elsewhere. + +### Receive status and completion + +A message admitted from the phone creates one short lifecycle message for that +turn. EvoFlux edits it only when the admission state changes, the turn fails, or +the turn completes. It does not send periodic progress or mirror activity. +Token deltas, model thinking, tool output, specialist mail, usage events, and +raw stream envelopes are never copied to Telegram. + +All user-visible top-level tasks may send a terminal completion notice. On +`done`, the service reads the finalized persisted assistant message rather than +reconstructing a result from stream deltas. It sends the task name, bounded +final text, and **Continue this task**. If no finalized assistant message is +available, it sends a completion stub that directs the user to EvoFlux. + +Long text is split at safe text boundaries within Telegram's message limit. +Messages are sent without a parse mode so model text cannot manufacture links, +mentions, or formatting through Telegram markup. + +### Advanced actions + +The bot profile exposes only `/start`, `/help`, `/status`, `/new`, `/stop`, and +`/unpair`. An optional **More actions** menu provides: + +- recent top-level tasks; +- existing Coding projects; +- approved Workflows; +- eligible Evo Agent Specs runs and currently available actions; +- existing scheduled tasks that permit manual triggering. + +Every list uses opaque, expiring choice tokens. It never embeds repository +paths, database UUIDs, definition hashes, or credentials in `callback_data`. +The menu is secondary: a user can pair, work, answer gates, receive results, +continue a desktop task, and start a new task without opening it. + +### Disable, unpair, and remove + +- **Disable connection** stops polling and outbound delivery but retains the + connection record, vault credential, and pairing. +- `/unpair` from the paired private account deletes the pairing and invalidates + its active callback tokens. It works even when other remote commands are + restricted. +- **Unpair phone** on the desktop performs the same deletion. +- **Remove connection** stops the adapter, deletes the pairing and connection, + deletes the vault credential, and invalidates all connection-owned tokens. + It does not delete or modify chat sessions. + +### Stale and refused states + +- An unpaired sender receives no response except when presenting a valid live + pairing token. +- A group, channel, bot-authored, edited, media-only, or unsupported update is + ignored and counted by reason. +- A callback after restart reports that the action expired. +- A callback after another surface resolved the gate reports that it was + already answered. +- A callback owned by another connection or principal is refused without + revealing its target. +- A revoked bot token stops the poller and produces an **Invalid token** state. +- Another consumer polling the same bot produces a **Used elsewhere** state and + bounded retries; the UI explains that each EvoFlux installation needs its + own bot. +- If the paired account blocks the bot or the chat becomes unavailable, + Settings shows **Phone unreachable** while the poller remains diagnosable. +- Messages accumulated while EvoFlux was stopped are discarded before live + polling starts and never execute later. + +## Requirements and acceptance criteria + +- **AC-1 — Off and free by default:** With no enabled connection, importing + `app.api.app` leaves Telegram adapter modules absent from `sys.modules`, no + remote task is created, and no network request is made. +- **AC-2 — Outbound only:** Enabling remote access opens no listening socket and + introduces no route authenticated by a Telegram token. Every new HTTP route + uses normal desktop authentication. +- **AC-3 — Per-installation ownership:** Setup creates a connection owned by the + local installation. No shared service or default bot exists, and v1 returns a + defined `409` response when a second connection is requested. +- **AC-4 — Connection-aware contracts:** Every pairing, credential key, inbound + source key, callback token, queue item, and runtime status carries a + connection ID. Tests can instantiate two service-level connections and prove + that messages and callbacks never cross them even though the public v1 API + prevents configuring the second. +- **AC-5 — Token custody:** A bot token is validated before persistence and + stored only in the OS credential vault under its connection ID. Vault failure + leaves no partial connection. No settings, model, API response, log, trace, + diagnostic, or exception string contains the token. +- **AC-6 — Bot identity binding:** `getMe` must report a bot account. The + connection stores its immutable bot ID and current username. Token replacement + that resolves to another bot invalidates the existing pairing and outstanding + callbacks before the new credential becomes active. +- **AC-7 — One-tap pairing:** **Connect phone** returns a QR payload and HTTPS + deep link whose `start` parameter has at least 128 bits of entropy, contains + only Telegram-permitted characters, is at most 64 characters, expires after + ten minutes, is single-use, and exists only in process memory. +- **AC-8 — Pairing authorization:** Pairing succeeds only for a valid token in a + private chat. The stored principal ID authorizes every message and callback; + the separately stored destination ID addresses replies. V1 permits one active + pairing per connection. +- **AC-9 — Silence and rate limits:** An unpaired sender, invalid token, group + chat, and unsupported update reveal no installation state. Pairing attempts + and accepted inbound actions have per-principal and connection-wide rate + limits that cannot be used as a response amplifier. +- **AC-10 — Immediate revocation:** Desktop unpair, `/unpair`, token replacement, + and connection removal invalidate applicable callback and menu tokens before + returning. A later update from the former principal is unauthorized. +- **AC-11 — No stale replay:** Startup removes any webhook, drops pending + updates, and establishes a fresh offset before accepting live input. An update + sent while EvoFlux is stopped never creates a session, message, or run. +- **AC-12 — Telegram error taxonomy:** Webhook conflict is cleared once; + concurrent-consumer conflict enters bounded backoff and a visible + `used_elsewhere` state; authentication failure stops polling in + `invalid_token`; `429` honors `retry_after`; transport errors use exponential + backoff with jitter; an unreachable paired chat affects delivery state without + crashing inbound polling. +- **AC-13 — Prompt shutdown:** Disabling or stopping the service cancels long + polling and interruptibly exits backoff without waiting for the configured + poll timeout or remaining retry delay. +- **AC-14 — Natural first message:** A valid plain-text message with no active + task creates one top-level Work session and submits the text without requiring + a command, mode, session ID, or desktop action. +- **AC-15 — Correct interactive ingress:** Remote text resolves the persisted + session/team and uses `submit_persisted_interactive_message`. Accepted, + pending, and queued outcomes are reported accurately. It never calls EvoFlux's + own HTTP API over loopback. +- **AC-16 — Inbound idempotency:** The source key includes adapter, connection + ID, and Telegram update ID. Redelivery within a process and replay after a + crash between persistence and offset acknowledgement produce at most one + persisted user message. +- **AC-17 — Current-task behavior:** Each pairing has at most one current task. + **Continue this task** changes only that pointer; **New task** clears it; the + next plain message creates the replacement Work session; deleting a current + session sets the pointer to null. +- **AC-18 — Addressable session boundary:** Only user-visible, top-level Work and + Coding sessions may be selected or notified. Team-member, Side Chat, and + internal sessions are neither listed nor addressable. +- **AC-19 — Non-perturbing observation:** Stream observation performs no await, + network, database, model, filesystem, or blocking work. It uses `put_nowait` + into bounded connection-owned queues and records overflow without delaying + the producing turn. +- **AC-20 — Explicit event allowlist:** Only gate asks/replies, terminal errors, + `done`, and relevant `desktop_notification` events are candidates for stream + projection. Thinking, content/tool deltas, widget deltas, summarization, + usage, inbox, delegation, handoff, member status, goal status, and unknown + event types are dropped. +- **AC-21 — Completion deduplication:** A turn emitting both `done` and + `desktop_notification(kind="assistant_done")` produces one completion notice. + Its body comes from the finalized persisted assistant message, not accumulated + stream chunks. +- **AC-22 — Quiet lifecycle status:** A phone-admitted turn owns at most one + lifecycle message. Accepted, queued, running, failed, and completed + transitions edit that message instead of appending progress. Desktop-started + tasks produce no activity chatter before a gate, error, or completion. +- **AC-23 — Explicit outbound policy:** Every user-controlled string and every + agent/session-derived field passes through the policy configured for the + `remote` outbound channel. Redaction is the default. A block decision emits a + fixed withheld-content stub and leaves observation running. +- **AC-24 — Safe text rendering:** Model text is sent without Telegram parse + mode, is split within provider limits without breaking Unicode, and never + becomes a gate or command solely because its content resembles EvoFlux UI. +- **AC-25 — Gate coverage:** Permission, question, and plan-review gates render + defined cards. Each `callback_data` payload is opaque, connection-owned, + principal-bound, process-memory-only, expiring, and at most 64 bytes. +- **AC-26 — Acknowledge first:** A callback query is answered before gate lookup, + database work, or execution. The subsequent edit communicates success, stale, + already answered, expired, or refused state. +- **AC-27 — Least durable authority:** Remote permission replies offer only + `once` or `reject`. Plan approval uses the existing exact request and session; + questions use the existing validated answer schema. Remote actions cannot + create durable permission rules. +- **AC-28 — Answered elsewhere:** Existing permission, question, and plan reply + events remove buttons from the matching Telegram card and mark it answered, + regardless of whether the resolution came from desktop or phone. +- **AC-29 — Safe interruption:** `/stop` interrupts only the pairing's current + active turn through the existing team interrupt path. It does not stop the + sidecar, delete the task, cancel unrelated sessions, or imply success when no + turn is active. +- **AC-30 — Existing approval boundaries:** An unapproved or changed Workflow + definition retains its existing refusal. Remote access cannot approve the + definition, add a Coding repository, change workspace authorization, or set + bypass permission mode. +- **AC-31 — Secondary run coverage:** Through the optional menu, an approved + Workflow, an authorized Coding project task, an eligible Evo Agent Specs + action, and an existing manually-triggerable scheduled task can be started. + Every refusal from the owning service is preserved and rendered safely. +- **AC-32 — No remote secrets or settings writes:** No Telegram command, menu + action, or natural-language shortcut accepts a credential value or changes + connection/provider/sandbox/outbound settings. Status output contains no + environment values or secret presence details beyond the current connection's + configured state. +- **AC-33 — Local API posture:** Packaged desktop authentication remains + unchanged. An external/LAN sidecar configuration must satisfy its existing + access-key policy; remote access neither bypasses nor self-calls that API. +- **AC-34 — Diagnosable:** Connection status reports lifecycle state, last safe + error class, last successful poll time, pairing state, phone reachability, and + bounded queue-drop counters. Logs contain no bot token, pairing token, + callback token, forwarded content, or raw Telegram payload. +- **AC-35 — Retention is explicit:** Setup and Help state that Telegram stores + bot chats on its service and that pairing permits bounded task information to + leave the machine. EvoFlux persists only normal session messages and the + connection/pairing metadata defined below. +- **AC-36 — Documented and localized:** The shipped feature contract, + configuration and HTTP references, Settings copy, and in-app Help in English, + Vietnamese, and Japanese describe setup, offline behavior, privacy, revocation, + and desktop-only operations. +- **AC-37 — Clean integration:** Focused backend/frontend tests, schema-head and + upgrade-path tests, Ruff, format check, ty, frontend lint/typecheck/build, and + `git diff --check` pass without incorporating unrelated worktree changes. + +## API, event, tool, and UI contracts + +### Desktop HTTP API + +All routes use existing desktop authentication and expose no Telegram-authenticated +HTTP surface. + +| Route | Purpose | +|---|---| +| `GET/PUT /api/settings/remote` | Read or update `outbound_data_policy` and `outbound_pii_policy`; connection enablement remains on the connection record | +| `GET /api/remote/connections` | Return the zero-or-one v1 connection summary and safe runtime state | +| `POST /api/remote/connections` | Validate a write-only token, save it to the vault, and create the connection; `409` when one already exists | +| `PATCH /api/remote/connections/{id}` | Change label or enabled state; adapter kind and bot identity are immutable | +| `PUT /api/remote/connections/{id}/token` | Validate and atomically replace the write-only token; re-pair if bot identity changes | +| `DELETE /api/remote/connections/{id}` | Stop and remove the connection, pairing, tokens, and vault credential | +| `POST /api/remote/connections/{id}/pairing-links` | Mint a single-use deep link and return link, QR payload, and expiry | +| `GET /api/remote/connections/{id}/pairing` | Return safe paired-account decoration or `null` | +| `DELETE /api/remote/connections/{id}/pairing` | Revoke the paired account and outstanding interaction tokens | +| `GET /api/remote/connections/{id}/status` | Return adapter lifecycle, last safe error, poll time, reachability, and drop counts | + +Secret-bearing request models use write-only fields. OpenAPI examples and error +payloads contain placeholders, never realistic tokens. `GET` responses report +`token_configured: true|false`, not the credential or its fingerprint. + +### Internal channel seam + +`app/remote/contracts.py` defines provider-neutral values such as: + +- `RemoteAdapterKind`, initially `telegram`; +- `RemotePrincipal` with `principal_id`, `destination_id`, and untrusted display; +- `RemoteInboundAction` for text, callback, and pairing-start updates; +- `RemoteOutboundMessage` with plain text, buttons, correlation, and priority; +- `RemoteAdapterStatus` and safe error classes; +- `RemoteAdapter`, whose lifecycle and send/edit/ack methods never decide + EvoFlux authorization. + +`RemoteService` owns connection limits, credential access, adapter lifecycle, +pairing, principal authorization, current-task routing, source-key generation, +event projection, callback-token ownership, and status aggregation. +`TelegramAdapter` owns Bot API translation, long-poll offsets, provider limits, +and Telegram error classification. Generic services never import Telegram +payload models. + +The adapter is constructed lazily only for an enabled, credential-complete +connection. The first release uses EvoFlux's existing `httpx` dependency rather +than adding a Telegram framework with its own scheduler or global state. + +### Telegram update contract + +Long polling supplies `allowed_updates=["message", "callback_query"]`. The +adapter accepts only new private-chat text messages from non-bot users and +callback queries for bot-authored messages. It ignores edited messages and all +media/service-message variants except the `/start ` pairing input. + +The poller processes updates in update-ID order. It advances the next offset +only after an update is safely classified and any accepted text has reached +durable message admission. A crash between admission and offset confirmation is +safe because the source key deduplicates the repeated update. Restart backlog +discard intentionally provides no across-restart delivery promise for updates +that never reached admission. + +### Stream observer contract + +`app/services/memory_stream_store.py` gains process-wide observer registration. +Observers are synchronous callbacks invoked after the stream state has accepted +an envelope. Registration returns an idempotent unregister handle. Observer +failure is isolated, logged without payload content, and cannot fail the stream +producer. + +The remote observer copies only minimal allowed fields into immutable queue +items. A bounded high-priority queue carries gates and terminal notices. Phone +admission status is produced by the remote service and uses a separate bounded +informational queue. Informational overflow drops the oldest informational +item. High-priority overflow leaves the underlying gate unresolved, increments +a critical delivery-failure counter, and keeps desktop resolution available; +the design does not claim impossible losslessness from a bounded, non-blocking +queue. + +No new SSE event type is needed. The observer consumes existing normalized +events and reply events. + +### Remote interaction contract + +Buttons contain only a compact random token and action code. The in-memory token +record owns connection ID, principal ID, destination ID, session ID, source +event/request ID, allowed action set, Telegram message reference, creation time, +and expiry. Provider callback data never contains a raw session ID, database ID, +path, command, plan, or answer. + +Callback acknowledgement is transport-only and precedes resolution. Resolution +calls the existing permission, question, plan, interrupt, Workflow, Scheduler, +or Evo Agent Specs service directly. The remote service does not make loopback +HTTP requests and does not duplicate owning business rules. + +### Settings UI + +Settings adds one **Remote access** section using the existing Settings overlay +primitives. Its normal state contains: + +- a short explanation that EvoFlux must remain running; +- the one-time personal bot-token field; +- validated bot identity; +- enabled/disabled control; +- **Connect phone** with QR code and **Open Telegram** fallback; +- paired-account display and **Unpair phone**; +- connection health and last safe error; +- **Remove connection**; +- visible disclosure that task text sent through the bot is retained by + Telegram and is not end-to-end encrypted bot chat. + +The primary Telegram keyboard contains **New task**, **Status**, and **More +actions**. Gate and completion messages add contextual inline buttons. Internal +terms such as session UUID, SSE, update offset, adapter, and pairing row are not +shown in the normal user flow. + +## Data model, migration, and retention + +Migration `00000064` with `down_revision = "00000063"` creates two tables and +updates the schema-head marker. + +### `remote_connections` + +| Column | Contract | +|---|---| +| `id` | UUID primary key | +| `adapter` | bounded adapter kind, initially `telegram` | +| `label` | user-editable installation-local label | +| `enabled` | desired lifecycle state | +| `adapter_principal_id` | validated immutable bot identifier, not the token | +| `adapter_username` | current validated bot username, untrusted display/routing decoration | +| `created_at`, `updated_at` | timezone-aware timestamps | + +The vault account key derives from the connection ID and never appears in API +responses. Runtime status, last errors, offsets, queues, and retry state remain +in process memory. + +### `remote_pairings` + +| Column | Contract | +|---|---| +| `id` | UUID primary key | +| `connection_id` | required FK to `remote_connections`, cascade delete | +| `principal_id` | channel-attested account identity used for authorization | +| `destination_id` | channel address used for replies | +| `label` | user-editable local label | +| `display` | channel-reported untrusted decoration | +| `active_session_id` | nullable FK to `chat_sessions`, `ON DELETE SET NULL` | +| `created_at`, `last_seen_at` | timezone-aware timestamps | + +The table is unique on `(connection_id, principal_id)` and on +`(connection_id, destination_id)`. The service enforces one pairing per +connection in v1. Database cardinality remains connection-aware for later +product expansion. + +Revocation deletes the pairing instead of retaining identity history. Removing +a connection cascades its pairing. Session deletion only clears the active +pointer. No remote operation deletes session messages. + +Pairing tokens, callback/menu tokens, update offsets, progress-message IDs, +deduplication caches, outbound queues, rate-limit windows, and error/backoff +state are deliberately ephemeral. The normal persisted user and assistant +messages remain governed by existing session retention. EvoFlux stores no extra +copy of Telegram message content or raw update bodies. + +## Permissions, security, privacy, and trust + +Pairing is installation-level operating authority. A paired account may create +and continue tasks, interrupt its current task, answer transient gates, and +invoke already-approved actions. It may not configure or widen the installation. + +Telegram bot chats are an external retention boundary and are not end-to-end +encrypted. Telegram, anyone controlling the user's Telegram account, and anyone +holding the bot token may be able to read bot-chat content. This consequence is +shown before pairing and documented in Help. Redaction, event allowlisting, and +bounded replies reduce exposure but do not make the channel local or encrypted. + +The bot token is a root credential. Its holder can impersonate the bot and read +updates delivered to it. EvoFlux stores it only through an OS-vault +abstraction modeled on the existing Conductor credential pattern, but +implemented as an independent module used only by remote access. Conductor's +own credential handling is left unchanged: it is an external integration point +relied on by `evo-conductor`, and this feature must not alter its behavior or +its constructor contract. Vault unavailability is a setup failure, not a +reason to create a plaintext fallback. Recovery from suspected token theft is +regeneration through `@BotFather` and token replacement in EvoFlux. + +The Telegram principal ID, not username or display name, authorizes inbound +actions. Usernames and display names can change and remain decoration. Every +callback rechecks connection, pairing, principal, destination, allowed action, +expiry, and underlying EvoFlux request state. + +Remote natural language has the same potential to trigger tools as desktop +natural language. Existing permission mode, sandbox roots, workspace +authorization, outbound model policy, tool policy, and workflow approval remain +authoritative. The adapter cannot invoke tool functions directly. + +Agent-controlled text is untrusted channel content. It is plain text, carries a +fixed EvoFlux/task provenance header, and never creates Telegram buttons. Only +the remote service constructs buttons from normalized internal events. + +Gate context can contain commands, paths, or user data. Every field passes +through the explicit `remote` outbound channel policy. The existing +`OutboundChannel` contract gains `remote`; policy selection is explicit rather +than inherited from ambient sandbox state. + +Pairing and callback tokens are capability secrets with short lifetimes. They +are generated with a cryptographic RNG, compared without logging, invalidated +on successful use or ownership change, and never stored in session content. + +## Concurrency, failure, recovery, and idempotency + +One adapter task owns polling for one connection. Update classification is +sequential to preserve offset ordering. Outbound delivery uses separate worker +tasks so a slow Telegram send cannot hold a stream lock, database transaction, +or inbound poll loop. + +The service never performs network I/O, model calls, process startup, or file +operations within a database transaction. It reads or mutates the smallest +durable unit, commits it, then performs external delivery. Compensating cleanup +removes a connection record if vault persistence fails during creation. + +Telegram allows one `getUpdates` consumer per bot and disallows polling while a +webhook is installed. Startup calls the provider operation that removes an +existing webhook and drops queued updates, then begins long polling with an +explicit allowed-update set. A polling conflict is a visible connection state, +not a tight retry loop. The UI tells the user to configure a different personal +bot for each EvoFlux installation. + +Inbound accepted text is at-least-once within a process but effect-once through +the persisted source key. Restart deliberately discards never-admitted backlog. +Callback resolution is idempotent because the underlying gate service resolves +one request once and the remote token becomes terminal after the first attempt. + +Outbound messages have no durable delivery guarantee. Informational delivery +may be dropped under bounded pressure. A failed gate delivery never auto-allows +or auto-denies the request; the task remains blocked and resolvable on desktop. +The status surface exposes the failure. + +Disable and shutdown follow structured cancellation: stop accepting new +actions, cancel long polling, wake retry waits, stop delivery workers, unregister +the stream observer, clear ephemeral tokens/queues, then close the HTTP client. +Repeated stop is safe. + +## Observability and diagnostics + +Connection state is one of: + +```text +disabled | starting | pairing | polling | backoff | rate_limited | +used_elsewhere | invalid_token | phone_unreachable | credential_missing | error +``` + +The status response includes connection ID, adapter kind, enabled state, +validated bot decoration, paired/unpaired state, phone reachability, last +successful poll time, last safe error class, current backoff deadline, and +informational/high-priority drop counters. It never includes raw provider +responses or credential/token material. + +Metrics cover received updates by accepted/refused reason, admission outcomes, +delivery attempts/results, callback outcomes, rate-limit waits, polling state, +queue depth, queue drops, and redaction/block decisions. Labels use bounded +enums and never principal, destination, session, username, task title, or +message content. + +Logs cover lifecycle transitions, safe provider error classes, pairing creation +and revocation by internal record ID, and bounded counters. Principal and +destination identifiers are omitted or one-way pseudonymized for correlation. +The diagnostics snapshot checks that no token-like URL or authorization header +is present. + +## Compatibility, rollout, and rollback + +The feature is additive and disabled by default. Existing installations create +no adapter runtime until the user configures and enables a connection. No +existing route or SSE shape changes meaning. Process-wide observer registration +is inert when no observer exists. + +The first release permits one connection at the service/API/UI layer. Tables and +internal contracts have connection IDs and no singleton database constraint. +Supporting several bots later requires lifting the admission limit and adding +selection/notification policy; it does not require rekeying pairings, +credentials, callbacks, or inbound idempotency. + +Each computer requires its own bot token. Moving a user to another installation +means configuring another bot there; EvoFlux never transfers a bot token or +pairing automatically. + +Rollout is controlled by the connection record's `enabled` field. Disabling is +the immediate operational rollback. Removing the connection clears its durable +metadata and vault secret. Migration downgrade drops only the two remote tables +and leaves all chat sessions/messages intact. + +Implementation proceeds in independently verifiable vertical slices: + +1. connection/pairing schema, vault abstraction, routes, and Settings setup; +2. Telegram polling, one-tap pairing, authorization, lifecycle, and status; +3. natural-language Work task creation/continuation and inbound idempotency; +4. stream projection, persisted completion delivery, and task continuation; +5. gate cards, races, revocation, and interruption; +6. optional advanced actions, documentation, localization, and final regression. + +## Verification matrix + +| AC | Evidence | +|---|---| +| AC-1, AC-2 | Disabled-lifespan/import/network test; route/auth and socket inventory inspection | +| AC-3, AC-4 | Connection admission tests plus two-connection service isolation tests for credentials, sources, callbacks, and queues | +| AC-5, AC-6 | Credential-store failure/rollback, masked response/log/diagnostic tests, `getMe` bot validation, and bot-identity replacement tests | +| AC-7, AC-8, AC-9 | Pairing token entropy/charset/length/expiry/single-use tests; private-chat principal/destination and rate-limit cases | +| AC-10 | Desktop and remote revocation tests proving callback/menu invalidation before return | +| AC-11, AC-12, AC-13 | Mock-transport tests for webhook/backlog disposal, error classes, retry timing with fake clock/jitter, and interruptible shutdown | +| AC-14, AC-15, AC-16 | Service tests for first-message Work creation, existing interactive ingress, accurate status, and duplicate update/source-key handling | +| AC-17, AC-18 | Current/new/continued/deleted task tests and refusal of child, Side Chat, and internal sessions | +| AC-19, AC-20 | Stream-store observer tests proving no await/blocking work and exhaustive event allowlist/drop behavior | +| AC-21, AC-22, AC-24 | Persisted completion/dedup tests, single-message lifecycle transitions, Unicode-safe splitting, and plain-text rendering tests | +| AC-23 | Remote outbound redaction and blocking-policy tests for every user/agent-derived field | +| AC-25, AC-26, AC-27, AC-28 | All three gate families, callback byte limit/ownership/expiry, acknowledgement ordering, least authority, stale races, and edit-on-reply tests | +| AC-29, AC-30 | Current-turn-only interrupt test and Workflow/repository/policy mutation refusal tests | +| AC-31 | Focused service-contract tests for approved Workflow, Coding task, eligible Evo Agent Specs action, and scheduled-task manual trigger | +| AC-32, AC-33 | Command/route inventory and desktop-auth regression tests | +| AC-34 | Status, bounded-metric-label, log capture, and diagnostics secret-absence tests | +| AC-35, AC-36 | Settings disclosure plus feature/config/API and three-locale Help review | +| AC-37 | Migration head/upgrade tests, focused suites, backend/frontend quality gates, and `git diff --check` | + +## Ownership and source map + +- Connection-aware core, service, adapter contracts, Telegram transport, + pairing, projection, interaction tokens, and status: `app/remote/`. +- OS-vault abstraction modeled on the existing Conductor precedent but kept as + independent, standalone infrastructure used only by remote access: + `app/core/credential_store.py`. `app/conductor/client.py` and + `app/conductor/service.py` are not modified; Conductor is an external + integration point (`evo-conductor`) and keeps its own credential + implementation and constructor contract unchanged. +- Persistence: `app/models/remote.py`, model registration, and + `app/migrations/versions/00000064_create_remote_pairings.py`; schema marker: + `app/core/schema_version.py`. +- Thin schemas/routes and application lifecycle: `app/api/schemas/remote.py`, + `app/api/routes/remote.py`, `app/api/routes/settings.py`, and `app/api/app.py`. +- Stream observation: `app/services/memory_stream_store.py`. +- Existing task/session creation and interactive ingress: + `app/services/chat_service.py`, `app/services/interactive_message_service.py`, + and `app/services/team_manager.py`. +- Gate owners: `app/agent/permission.py`, `app/agent/ask_user.py`, and + `app/agent/plan.py`. +- Explicit remote outbound policy: `app/agent/outbound_redaction.py` and the + existing sandbox/runtime-settings boundary. +- Existing action owners: `app/workflow/`, `app/scheduler/`, and the Evo Agent + Specs services/routes. The remote layer calls services rather than copying + their approval rules. +- Settings client and UI: `web/src/api/types.ts`, `web/src/api/client/settings.ts`, + `web/src/components/settings/`, and the existing Settings navigation. +- In-app Help: `web/src/help/locales/en.ts`, `web/src/help/locales/vi.ts`, and + `web/src/help/locales/ja.ts`. +- Current feature contract to create when implementation ships: + `documents/features/remote-access.md`, plus a catalogue row in + `documents/features/README.md` and trust cross-reference from + `documents/features/security-and-permissions.md`. +- Current references to update when implementation ships: + `documents/reference/configuration.md` and + `documents/reference/http-api.md`. + +The plan remains historical/proposed until implementation is verified and the +current feature/reference/Help documents are reconciled. This file does not by +itself claim that remote access is available. diff --git a/documents/plans/remote-telegram-approvals-implementation.md b/documents/plans/remote-telegram-approvals-implementation.md new file mode 100644 index 00000000..92a88383 --- /dev/null +++ b/documents/plans/remote-telegram-approvals-implementation.md @@ -0,0 +1,998 @@ +# Remote Telegram: Decidable Approvals Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Telegram permission gate decidable (show the real command +and a derived severity instead of just a tool name) and make it actually +resolve (fix the card so it edits into a decided state instead of keeping +live buttons forever) — Phase 1 of the larger control-surface spec, covering +exactly AC-45, AC-46, and AC-47. + +**Architecture:** A new pure module (`app/remote/severity.py`) derives a +display-only severity from tool name and command text. `app/remote/formatting.py` +gains two builders — the ask-card and the resolved-card — that render it. +`app/remote/gates.py`'s permission path is extracted into its own method that +uses both, issues an `always` token alongside `once`/`reject`, stamps every +sent card with a `correlation_id`, and replaces the dead +`chat_id`/`message_id` fields with a generic edit-to-resolved-form path used +by all three gate kinds. + +**Tech Stack:** Python 3.12, FastAPI, asyncio, pytest, pytest-asyncio. + +**Spec:** [`remote-telegram-control-surface.md`](remote-telegram-control-surface.md) +(amends `remote-channel-telegram.md` and `remote-telegram-response-ui.md`) — +this plan implements exactly AC-45, AC-46, and AC-47 of that document. + +## Global Constraints + +- Severity is advisory display only — it must never change which requests + are gated, which replies are accepted, or how any reply resolves (AC-46). + `PermissionService` remains the sole gating authority. +- Every field not authored as a static string in `formatting.py` is redacted + via `_redact_text` (or `protect_outbound_text` directly) and then + HTML-escaped, redaction first, escaping last (AC-24, already accepted). +- `on_gate`/`on_reply` stay synchronous and non-blocking — no database read, + no `await`, inside either (matches the accepted spec's observer contract; + `on_gate` today has no DB access and this plan must not add one). +- Callback tokens stay opaque, `<=64` bytes, connection/principal-bound — + unchanged token issuance mechanism (`_issue_token`), just one more token + per permission request when `always_patterns` is non-empty. +- `answer_callback` is still called before gate resolution (AC-26) — nothing + in this plan changes `handle_callback`'s ordering. +- Every task cites its spec ACs, writes a failing test before implementation, + and leaves the test suite green at its checkpoint. +- Commit at the end of each task. + +--- + +## File and interface map + +New units: + +- `app/remote/severity.py` — `Severity` type alias and `derive_severity()`. +- `tests/remote/test_severity.py`. + +Changed units: + +- `app/remote/formatting.py` — adds `render_permission_card` and + `render_permission_resolved_card`. +- `app/remote/gates.py` — `_PendingGate` drops `chat_id`/`message_id`, gains + `command: str`; `on_gate`'s permission branch becomes + `_on_permission_asked`; `on_reply` becomes gate-kind-generic and always + edits via `correlation_id`; `_resolve_permission` accepts `always`; + `_edit_remove_buttons`/`_do_edit_remove` are replaced by + `_enqueue_resolved_edit`/`_do_resolved_edit`. +- `tests/remote/test_formatting.py`, `tests/remote/test_gates.py` — updated + and new focused evidence. + +--- + +### Task 1: Severity derivation + +**ACs:** AC-46 + +**Files:** + +- Create: `app/remote/severity.py` +- Create: `tests/remote/test_severity.py` + +**Interfaces:** + +- Produces: `Severity = Literal["high", "elevated", "normal"]`, + `derive_severity(*, tool: str, command: str) -> Severity`. +- Consumes: nothing (pure function, no imports from elsewhere in + `app.remote`). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_severity.py +from app.remote.severity import derive_severity + + +def test_destructive_command_is_high_severity(): + assert derive_severity(tool="shell", command="rm -rf build/") == "high" + + +def test_force_push_is_high_severity(): + assert ( + derive_severity(tool="shell", command="git push --force origin main") + == "high" + ) + + +def test_shell_tool_without_destructive_pattern_is_elevated(): + assert derive_severity(tool="shell", command="pytest -q") == "elevated" + + +def test_python_tool_is_elevated(): + assert derive_severity(tool="python", command="print(1)") == "elevated" + + +def test_read_only_tool_is_normal(): + assert derive_severity(tool="read", command="tests/test_auth.py") == "normal" + + +def test_detection_is_case_insensitive(): + assert derive_severity(tool="shell", command="RM -RF /tmp/x") == "high" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_severity.py +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.remote.severity'`. + +- [ ] **Step 3: Implement `severity.py`** + +```python +# app/remote/severity.py +"""Advisory-only command severity for permission cards. + +Display hint alone — never consulted by any gating decision. +``PermissionService`` (``app/agent/permission.py``) is the sole authority on +whether a request blocks; this module exists only so a phone operator sees +"rm -rf" and "read a file" rendered differently, never to decide anything. +""" + +from __future__ import annotations + +from typing import Literal + +Severity = Literal["high", "elevated", "normal"] + +__all__ = ["Severity", "derive_severity"] + +#: Substrings that mark a command as destructive regardless of tool. +_DESTRUCTIVE_SUBSTRINGS = ( + "rm -rf", + "rm -r -f", + "git clean -fd", + "git reset --hard", + "git push --force", + "git push -f", + "drop table", + "drop database", + "truncate table", +) + +#: Tools that run arbitrary code/commands — elevated even without a +#: destructive-pattern match. +_ELEVATED_TOOLS = frozenset({"shell", "bash", "python", "process", "rm"}) + + +def derive_severity(*, tool: str, command: str) -> Severity: + """Derive a display-only severity from a permission request's tool and + command text. Never raises; unknown input degrades to "normal".""" + lowered = command.lower() + if any(pattern in lowered for pattern in _DESTRUCTIVE_SUBSTRINGS): + return "high" + if tool in _ELEVATED_TOOLS: + return "elevated" + return "normal" +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_severity.py +``` + +Expected: PASS (6 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/severity.py tests/remote/test_severity.py +uv run ty check app/remote/severity.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/severity.py tests/remote/test_severity.py +git commit -m "feat(remote): add advisory-only permission severity derivation" +``` + +--- + +### Task 2: Permission card rendering + +**ACs:** AC-45, AC-46 (display half), AC-47 (resolved-card content) + +**Files:** + +- Modify: `app/remote/formatting.py` +- Modify: `tests/remote/test_formatting.py` + +**Interfaces:** + +- Produces: `render_permission_card(*, tool: str, command: str, severity: str, + agent: str, always_glob: str | None, always_token: str | None, + once_token: str, reject_token: str) -> tuple[str, tuple[RemoteButton, ...]]`, + `render_permission_resolved_card(*, command: str, resolution: str) -> + tuple[str, tuple[RemoteButton, ...]]`. +- Consumes: `app.remote.contracts.RemoteButton`, `escape` (already in this + module). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_formatting.py (add) +def test_render_permission_card_shows_command_and_severity_icon(): + text, buttons = formatting.render_permission_card( + tool="shell", + command="rm -rf build/", + severity="high", + agent="evoflux", + always_glob=None, + always_token=None, + once_token="once-tok", + reject_token="reject-tok", + ) + assert "rm -rf build/" in text + assert "\U0001f534" in text # red circle = high severity + assert "shell" in text + assert "evoflux" in text + assert buttons == ( + RemoteButton(text="Allow once", token="once-tok"), + RemoteButton(text="Reject", token="reject-tok"), + ) + + +def test_render_permission_card_escapes_command(): + text, _ = formatting.render_permission_card( + tool="shell", + command="echo ", + severity="elevated", + agent="evoflux", + always_glob=None, + always_token=None, + once_token="once-tok", + reject_token="reject-tok", + ) + assert "" not in text + assert "<script>" in text + + +def test_render_permission_card_adds_allow_for_session_button_with_glob(): + text, buttons = formatting.render_permission_card( + tool="shell", + command="git push origin main", + severity="elevated", + agent="evoflux", + always_glob="git push *", + always_token="always-tok", + once_token="once-tok", + reject_token="reject-tok", + ) + assert "git push *" in text or any("git push *" in b.text for b in buttons) + assert buttons == ( + RemoteButton(text="Allow once", token="once-tok"), + RemoteButton( + text="\U0001f512 Allow for session — git push *", token="always-tok" + ), + RemoteButton(text="Reject", token="reject-tok"), + ) + + +def test_render_permission_card_omits_always_button_without_glob(): + _, buttons = formatting.render_permission_card( + tool="read", + command="tests/test_auth.py", + severity="normal", + agent="evoflux", + always_glob=None, + always_token=None, + once_token="once-tok", + reject_token="reject-tok", + ) + assert len(buttons) == 2 + + +def test_render_permission_resolved_card_states_decision_and_has_no_buttons(): + text, buttons = formatting.render_permission_resolved_card( + command="rm -rf build/", resolution="once" + ) + assert "rm -rf build/" in text + assert "Allowed once" in text + assert buttons == () + + +def test_render_permission_resolved_card_escapes_command(): + text, _ = formatting.render_permission_resolved_card( + command="", resolution="reject" + ) + assert "" not in text + assert "<script>" in text +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py -k permission +``` + +Expected: FAIL with `AttributeError: module 'app.remote.formatting' has no +attribute 'render_permission_card'`. + +- [ ] **Step 3: Implement the builders** + +```python +# app/remote/formatting.py — add to __all__: + "render_permission_card", + "render_permission_resolved_card", +``` + +```python +# app/remote/formatting.py — new module-level constants, near _STATUS_ICON +_SEVERITY_ICON = {"high": "\U0001f534", "elevated": "\U0001f7e0", "normal": "\U0001f527"} +_SEVERITY_LABEL = { + "high": "Dangerous command", + "elevated": "Command", + "normal": "Permission requested", +} +_RESOLUTION_ICON = {"once": "✅", "always": "\U0001f512", "reject": "❌"} +_RESOLUTION_LABEL = { + "once": "Allowed once", + "always": "Allowed for session", + "reject": "Rejected", +} +``` + +```python +# app/remote/formatting.py — new functions +def render_permission_card( + *, + tool: str, + command: str, + severity: str, + agent: str, + always_glob: str | None, + always_token: str | None, + once_token: str, + reject_token: str, +) -> tuple[str, tuple[RemoteButton, ...]]: + icon = _SEVERITY_ICON.get(severity, "\U0001f527") + label = _SEVERITY_LABEL.get(severity, "Permission requested") + text = ( + f"{icon} {escape(label)}\n" + f"
{escape(command)}
\n" + f"{escape(tool)} · {escape(agent)}" + ) + buttons = [RemoteButton(text="Allow once", token=once_token)] + if always_token and always_glob: + buttons.append( + RemoteButton( + text=f"\U0001f512 Allow for session — {escape(always_glob)}", + token=always_token, + ) + ) + buttons.append(RemoteButton(text="Reject", token=reject_token)) + return text, tuple(buttons) + + +def render_permission_resolved_card( + *, command: str, resolution: str +) -> tuple[str, tuple[RemoteButton, ...]]: + icon = _RESOLUTION_ICON.get(resolution, "✅") + label = _RESOLUTION_LABEL.get(resolution, "Resolved") + text = f"{icon} {escape(label)}\n
{escape(command)}
" + return text, () +``` + +Note: `RemoteButton(text=f"...\U0001f512 Allow for session — {escape(always_glob)}", ...)` +already escapes `always_glob` inline — the test's expected button text uses +the literal glob `"git push *"` which contains no HTML-special characters, +so `escape("git push *") == "git push *"` and the equality assertion holds. + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py +``` + +Expected: PASS (all tests in the file, including the 6 new ones). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py +uv run ty check app/remote/formatting.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): render permission cards with the real command and severity" +``` + +--- + +### Task 3: Wire the gate bridge — real cards, `always` reply, and visible resolution + +**ACs:** AC-45, AC-46, AC-47, AC-28 (revised) + +**Files:** + +- Modify: `app/remote/gates.py` +- Modify: `tests/remote/test_gates.py` + +**Interfaces:** + +- Changes `_PendingGate`: removes `chat_id`, `message_id`; adds + `command: str = ""`. +- Changes `RemoteGateBridge._resolve_permission`: accepts `"always"` as a + valid `cap.action` in addition to `"once"`/`"reject"`. +- Produces (private, no external callers): `_on_permission_asked`, + `_enqueue_resolved_edit`, `_do_resolved_edit`, `_resolution_text`. +- Consumes: `app.remote.severity.derive_severity`, + `app.remote.formatting.render_permission_card`, + `app.remote.formatting.render_permission_resolved_card`. + +- [ ] **Step 1: Write failing tests for the real permission card** + +```python +# tests/remote/test_gates.py (add near the permission-card tests) +# Async + a drain sleep, matching this file's existing convention for +# on_gate tests (_enqueue_send schedules a fire-and-forget task; a plain +# sync test has no running loop for that task to be scheduled on at all). +@pytest.mark.asyncio +async def test_on_gate_permission_renders_the_real_command( + bridge: RemoteGateBridge, adapter: FakeAdapter +) -> None: + bridge.on_gate( + session_id="sess-1", + event_type="permission_asked", + data={ + "request_id": "req-1", + "tool": "shell", + "patterns": ["rm -rf build/"], + "always_patterns": [], + "metadata": {"agent": "evoflux"}, + }, + connection_id=uuid4(), + destination_id="chat-1", + ) + await asyncio.sleep(0.05) + + assert len(adapter.sent) == 1 + assert "rm -rf build/" in adapter.sent[0].text + assert "Permission requested: shell" not in adapter.sent[0].text + assert adapter.sent[0].correlation_id == "gate:req-1" + assert [b.text for b in adapter.sent[0].buttons] == ["Allow once", "Reject"] + + +@pytest.mark.asyncio +async def test_on_gate_permission_offers_allow_for_session_with_glob( + bridge: RemoteGateBridge, adapter: FakeAdapter +) -> None: + bridge.on_gate( + session_id="sess-1", + event_type="permission_asked", + data={ + "request_id": "req-1", + "tool": "shell", + "patterns": ["git push origin main"], + "always_patterns": ["git push *"], + "metadata": {"agent": "evoflux"}, + }, + connection_id=uuid4(), + destination_id="chat-1", + ) + await asyncio.sleep(0.05) + + button_texts = [b.text for b in adapter.sent[0].buttons] + assert len(button_texts) == 3 + assert "git push *" in button_texts[1] +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py -k "real_command or allow_for_session" +``` + +Expected: FAIL — `adapter.sent[0].text` still says +`"Permission requested: shell"`, no `correlation_id`. + +- [ ] **Step 3: Extract and rewrite the permission path in `on_gate`** + +```python +# app/remote/gates.py — imports, add: +import asyncio + +from app.remote.formatting import ( + escape, + render_permission_card, + render_permission_resolved_card, +) +from app.remote.severity import derive_severity +``` + +```python +# app/remote/gates.py — replace the _PendingGate dataclass +@dataclass +class _PendingGate: + """Tracks one gate's outstanding capabilities and the command text (if + any) needed to render its resolved form later.""" + + request_id: str + session_id: str + gate_kind: GateKind + tokens: list[str] = field(default_factory=list) + command: str = "" +``` + +```python +# app/remote/gates.py — replace on_gate's body + def on_gate( + self, + session_id: str, + event_type: str, + data: dict, + connection_id: UUID, + destination_id: str, + ) -> None: + """Handle a gate event by creating opaque tokens and enqueuing a card. + + Called by the projection's ``_handle_gate``. + """ + request_id = data.get("request_id", "") + if not request_id: + return + + if event_type == "permission_asked": + self._on_permission_asked(session_id, data, connection_id, destination_id, request_id) + return + + gate_kind: GateKind + actions: list[tuple[str, str]] # (action_label, button_text) + + if event_type == "question_asked": + gate_kind = "question" + questions = data.get("questions", []) + if questions: + first_q = questions[0] + text = f"Question: {first_q.get('question', '')}" + options = first_q.get("options", []) + actions = [(opt, opt) for opt in options[:8]] # bound to 8 + else: + text = "Question asked." + actions = [] + elif event_type == "plan_approval_requested": + gate_kind = "plan" + plan_text = data.get("plan", "") + steps = data.get("steps", []) + text = f"Plan ready for review ({len(steps)} steps)." + if plan_text: + text = f"Plan: {plan_text[:200]}" + actions = [("approve", "Approve"), ("reject", "Reject")] + else: + return + + buttons: list[RemoteButton] = [] + gate = _PendingGate( + request_id=request_id, + session_id=session_id, + gate_kind=gate_kind, + ) + + for action_value, button_text in actions: + token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind=gate_kind, + action=action_value, + ) + buttons.append(RemoteButton(text=button_text, token=token)) + gate.tokens.append(token) + self._pending_by_token[token] = request_id + + self._pending_gates[request_id] = gate + + text = _redact_text(text) + msg = RemoteOutboundMessage( + connection_id=connection_id, + destination_id=destination_id, + text=text, + buttons=tuple(buttons), + priority=RemoteOutboundPriority.HIGH, + correlation_id=f"gate:{request_id}", + ) + self._enqueue_send(msg) + + def _on_permission_asked( + self, + session_id: str, + data: dict, + connection_id: UUID, + destination_id: str, + request_id: str, + ) -> None: + """Render and send a decidable permission card (AC-45/AC-46): the + real command and a derived, advisory-only severity, never a tool + name alone.""" + tool = data.get("tool", "unknown") + patterns = data.get("patterns") or [] + command = patterns[0] if patterns else tool + always_patterns = data.get("always_patterns") or [] + always_glob = always_patterns[0] if always_patterns else None + agent_name = data.get("metadata", {}).get("agent", "agent") + severity = derive_severity(tool=tool, command=command) + + gate = _PendingGate( + request_id=request_id, + session_id=session_id, + gate_kind="permission", + command=_redact_text(command), + ) + + once_token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind="permission", + action="once", + ) + gate.tokens.append(once_token) + self._pending_by_token[once_token] = request_id + + always_token: str | None = None + if always_glob: + always_token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind="permission", + action="always", + ) + gate.tokens.append(always_token) + self._pending_by_token[always_token] = request_id + + reject_token = self._issue_token( + connection_id=connection_id, + principal_id="", + destination_id=destination_id, + session_id=session_id, + request_id=request_id, + gate_kind="permission", + action="reject", + ) + gate.tokens.append(reject_token) + self._pending_by_token[reject_token] = request_id + + self._pending_gates[request_id] = gate + + text, buttons = render_permission_card( + tool=tool, + command=gate.command, + severity=severity, + agent=agent_name, + always_glob=_redact_text(always_glob) if always_glob else None, + always_token=always_token, + once_token=once_token, + reject_token=reject_token, + ) + msg = RemoteOutboundMessage( + connection_id=connection_id, + destination_id=destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + correlation_id=f"gate:{request_id}", + ) + self._enqueue_send(msg) +``` + +- [ ] **Step 4: Update the one pre-existing test this rename breaks** + +`test_permission_gate_creates_allow_and_reject_buttons` +(`TestGateRendering`) asserts the old button label verbatim: + +```python +assert msg.buttons[0].text == "Allow" +assert msg.buttons[1].text == "Reject" +``` + +`render_permission_card` labels the first button `"Allow once"`. Update +those two lines in place: + +```python + assert msg.buttons[0].text == "Allow once" + assert msg.buttons[1].text == "Reject" +``` + +- [ ] **Step 5: Run and confirm all three pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py -k "real_command or allow_for_session or creates_allow_and_reject" +``` + +Expected: PASS (3 tests: the 2 new ones plus the updated pre-existing one). + +- [ ] **Step 6: Write a failing test for `always` reply support** + +Mirrors the existing `test_callback_resolves_permission_once`/ +`test_callback_resolves_permission_reject` in `TestCallbackOrdering` +exactly — same `patch(...)` + `MagicMock` house style, going through the +public `handle_callback`, not the private resolver. + +```python +# tests/remote/test_gates.py (add to class TestCallbackOrdering) + @pytest.mark.asyncio + async def test_callback_resolves_permission_always( + self, bridge: RemoteGateBridge, adapter: FakeAdapter + ) -> None: + conn_id = uuid4() + bridge.on_gate( + session_id="sess-1", + event_type="permission_asked", + data={ + "request_id": "req-1", + "tool": "shell", + "patterns": ["git push origin main"], + "always_patterns": ["git push *"], + }, + connection_id=conn_id, + destination_id="chat-1", + ) + await asyncio.sleep(0.05) + + gate = bridge._pending_gates["req-1"] + # Token order from _on_permission_asked: once, always, reject. + always_token = gate.tokens[1] + + from unittest.mock import patch + + mock_svc = MagicMock() + mock_svc.reply.return_value = True + + with patch( + "app.agent.permission.get_service_for_session", return_value=mock_svc + ): + action = _make_action(callback_token=always_token, connection_id=conn_id) + await bridge.handle_callback(action) + + mock_svc.reply.assert_called_once_with("req-1", "always") +``` + +- [ ] **Step 7: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py -k resolves_permission_always +``` + +Expected: FAIL — `remote_gate_invalid_permission_action` branch returns +`False` for `action="always"`, so `mock_svc.reply` is never called. + +- [ ] **Step 8: Accept `always` in `_resolve_permission`** + +```python +# app/remote/gates.py — replace _resolve_permission's action check + async def _resolve_permission( + self, cap: GateCapability, action: RemoteInboundAction + ) -> bool: + """Resolve a permission gate. Remote accepts once/always/reject + (AC-28, revised) — "always" is session-scoped in PermissionService + (it appends a rule to session_ruleset, not a permanent grant), which + is exactly what the remote card's "Allow for session" label says.""" + from app.agent.permission import Reply, get_service_for_session + + if cap.action not in ("once", "always", "reject"): + logger.warning( + "remote_gate_invalid_permission_action action={}", + cap.action, + ) + return False + reply_value: Reply = cap.action # ty: ignore[invalid-assignment] + + svc = get_service_for_session(cap.session_id) + if svc is None: + logger.debug( + "remote_gate_permission_service_missing session_id={}", + cap.session_id, + ) + return False + + resolved = svc.reply(cap.request_id, reply_value) + if resolved: + logger.info( + "remote_gate_permission_resolved request_id={} reply={}", + cap.request_id, + reply_value, + ) + return resolved +``` + +- [ ] **Step 9: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py -k resolves_permission_always +``` + +Expected: PASS. + +- [ ] **Step 10: Write a failing test for visible resolution** + +`test_on_reply_cleans_up_capabilities` already exists in `TestReplyEvents` +and covers capability/token cleanup — add the one thing it doesn't cover: +that the card is actually edited into a resolved, decision-stating form. +This test must be `async` (unlike its sync sibling) because +`_enqueue_resolved_edit`'s fire-and-forget task needs a running event loop +to actually schedule, and a brief `asyncio.sleep` to let that task run +before asserting on `adapter.edited`. + +```python +# tests/remote/test_gates.py — add to class TestReplyEvents + @pytest.mark.asyncio + async def test_on_reply_edits_the_card_to_a_resolved_form( + self, bridge: RemoteGateBridge, adapter: FakeAdapter + ) -> None: + bridge.on_gate( + session_id="sess-1", + event_type="permission_asked", + data={ + "request_id": "req-1", + "tool": "shell", + "patterns": ["rm -rf build/"], + }, + connection_id=uuid4(), + destination_id="chat-1", + ) + + bridge.on_reply( + "sess-1", "permission_replied", {"request_id": "req-1", "reply": "once"} + ) + await asyncio.sleep(0.05) + + assert len(adapter.edited) == 1 + assert adapter.edited[0].correlation_id == "gate:req-1" + assert adapter.edited[0].buttons == () + assert "Allowed once" in adapter.edited[0].text + assert "rm -rf build/" in adapter.edited[0].text +``` + +- [ ] **Step 11: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py -k resolved_form +``` + +Expected: FAIL — `on_reply`'s `chat_id is not None and message_id is not +None` guard is always false, so `adapter.edited` stays empty. + +- [ ] **Step 12: Replace `on_reply` and the dead edit path** + +```python +# app/remote/gates.py — replace on_reply + def on_reply(self, session_id: str, event_type: str, data: dict) -> None: + """Handle a gate reply event by editing its card into a resolved, + button-free form (AC-47). + + Called by the projection when a ``permission_replied``, + ``question_replied``, or ``plan_approval_replied`` event fires. + """ + request_id = data.get("request_id", "") + if not request_id: + return + + gate = self._pending_gates.pop(request_id, None) + if gate is None: + return + + for token in gate.tokens: + self._capabilities.pop(token, None) + self._pending_by_token.pop(token, None) + + resolution_text = self._resolution_text(gate, data) + self._enqueue_resolved_edit(gate, resolution_text) + + def _resolution_text(self, gate: _PendingGate, data: dict) -> str: + """Build the resolved-form message text for one gate kind. Only + permission gates have a per-decision label defined by the spec + (AC-45's "Allowed once"/"Allowed for session"/"Rejected"); question + and plan gates get a generic resolved marker.""" + if gate.gate_kind == "permission": + reply = data.get("reply", "reject") + text, _buttons = render_permission_resolved_card( + command=gate.command or "(command unavailable)", resolution=reply + ) + return text + return "✅ Resolved" + + def _enqueue_resolved_edit(self, gate: _PendingGate, text: str) -> None: + """Edit this gate's card into its resolved form — fire-and-forget, + matching the delivery pattern already used by ``_enqueue_send``.""" + try: + loop = asyncio.get_running_loop() + loop.create_task(self._do_resolved_edit(gate, text)) + except RuntimeError: + pass + + async def _do_resolved_edit(self, gate: _PendingGate, text: str) -> None: + try: + msg = RemoteOutboundMessage( + connection_id=UUID(int=0), # not used for edit lookup + destination_id="", + text=text, + buttons=(), + correlation_id=f"gate:{gate.request_id}", + ) + await self._adapter.edit(msg) + except Exception as exc: + logger.debug( + "remote_gate_resolved_edit_failed request_id={} error={}", + gate.request_id, + exc, + ) +``` + +```python +# app/remote/gates.py — delete the now-unused _edit_remove_buttons and +# _do_edit_remove methods entirely (replaced by _enqueue_resolved_edit / +# _do_resolved_edit above). +``` + +- [ ] **Step 13: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_gates.py +``` + +Expected: PASS (every test in the file, including the pre-existing +`test_on_reply_cleans_up_capabilities` and the new resolved-form test). + +- [ ] **Step 14: Full remote suite and lint** + +```powershell +uv run pytest --no-cov -q tests/remote/ +uv run ruff check app/remote/gates.py tests/remote/test_gates.py +uv run ty check app/remote/gates.py +``` + +Expected: all green. If any other test in the suite referenced +`_edit_remove_buttons`, `_PendingGate.chat_id`, or `_PendingGate.message_id` +directly, update it to the new `_enqueue_resolved_edit` path — grep first: + +```powershell +grep -rn "chat_id\|message_id\|_edit_remove_buttons" tests/remote/test_gates.py +``` + +- [ ] **Step 15: Commit** + +```bash +git add app/remote/gates.py tests/remote/test_gates.py +git commit -m "fix(remote): permission cards show the real command and actually resolve" +``` + +--- + +## Self-review notes + +- **Spec coverage:** AC-45 (Task 2 + 3's card rendering), AC-46 (Task 1 + + Task 2's severity display), AC-47 (Task 3's correlation-id send + resolved + edit + dead-field removal), AC-28 revised (Task 3 Steps 5-8's `always` + support) are each covered by a task above. AC-48 through AC-58 (mode/model/ + agent control, health, changes, live mode, onboarding, command-set) are + explicitly out of scope for this plan — they are later phases of the same + spec, to be written as separate plan documents once this phase lands, per + the spec's own stated rollout order. +- **Type consistency:** `_PendingGate.command` (Task 3) matches the + `gate.command` reads in `_resolution_text`; `render_permission_card`'s + parameter names (Task 2) match every call site in `_on_permission_asked` + (Task 3) exactly; `_resolve_permission`'s `Reply` import and literal values + match `app/agent/permission.py:63`'s `Reply = Literal["once", "always", + "reject"]` verified during spec research. +- **No placeholders:** every step above contains complete, runnable code — + no "similar to Task N" references, no TODO markers. diff --git a/documents/plans/remote-telegram-control-implementation.md b/documents/plans/remote-telegram-control-implementation.md new file mode 100644 index 00000000..d0a38a67 --- /dev/null +++ b/documents/plans/remote-telegram-control-implementation.md @@ -0,0 +1,1186 @@ +# Remote Telegram: Settings Control (Mode, Model, Lead Agent) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a paired phone view and change a session's permission mode, model, and lead agent via `/settings` — Phase 2 of the control-surface spec, covering AC-48, AC-49, and AC-50. `bypass` permission mode is never offered or accepted remotely, under any code path. + +**Architecture:** One new module, `app/remote/control.py`, owns every write (`set_permission_mode`, `set_lead_agent`, `set_model`) and the two bounded read helpers (`list_lead_names`, `list_model_ids`) that back the settings card's button rows — each write function replicates the exact persistence sequence its HTTP-route sibling already uses (same service-layer calls: `team_manager`, `app.agent.permission`, `app.models.chat`), so remote and desktop stay behaviorally identical, but as plain async functions `app/remote/actions.py` can call directly without HTTP. `formatting.py` gains settings-card support for the three new button rows. `actions.py` gains the `/settings` command and capability dispatch for taps. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel, asyncio, pytest, pytest-asyncio. + +**Spec:** [`remote-telegram-control-surface.md`](remote-telegram-control-surface.md) (amends `remote-channel-telegram.md` and `remote-telegram-response-ui.md`) — this plan implements exactly AC-48, AC-49, and AC-50. AC-51 through AC-58 (health, changes, providers-read-only, onboarding, response modes, command-set) are later phases. + +## Global Constraints + +- `bypass` is never settable remotely, under any command, button, callback, or forged token — excluded by name in `control.py`, not merely omitted from a menu (AC-48). +- Severity/mode/model/agent changes never bypass the service layer's own validation (`resolve_configured_lead` raises `ValueError` on an unknown name; the model registry is the sole source of valid model ids) — `control.py` surfaces those as a bounded `ControlResult`, never swallows or reinterprets them. +- Every write function takes an already-open `AsyncSession` (the caller's, per the existing `dispatch_command(db, action)` pattern in `actions.py`) — it never opens its own `async_session_factory()`. +- `app/remote/` depends only on the service layer (`app.services.*`, `app.agent.*`, `app.models.*`) — never on `app/api/routes/*` — with exactly one documented, deliberate exception: `get_registry` (model catalog) has no service-layer equivalent, so `control.py` imports it directly from `app.api.routes.agents`, called as a plain function (its `Query(...)` parameters are just OpenAPI metadata on plain defaults; it takes no `Request`/`Depends`-injected state). +- Every task cites its spec ACs, writes a failing test before implementation, and leaves the test suite green at its checkpoint. +- Commit at the end of each task. + +--- + +## File and interface map + +New units: + +- `app/remote/control.py` — `ALLOWED_REMOTE_MODES`, `ControlResult`, `set_permission_mode`, `set_lead_agent`, `set_model`, `list_lead_names`, `list_model_ids`. +- `tests/remote/test_control.py`. + +Changed units: + +- `app/remote/formatting.py` — `render_settings_card` gains `mode_tokens`, `agent_name`/`agent_tokens`, `model_tokens` parameters; `redaction_policy`/`notify_scope`/`redaction_tokens`/`notify_scope_tokens` become optional (a later phase wires those; this phase's `/settings` doesn't supply them yet). +- `app/remote/actions.py` — `_SLASH_COMMANDS` gains `"settings"`; new `_cmd_settings`; new capability action kinds `set_mode`/`set_agent`/`set_model` in `_execute_action`. +- `tests/remote/test_formatting.py`, `tests/remote/test_actions.py` — updated/new focused evidence. + +--- + +### Task 1: `set_permission_mode` + +**ACs:** AC-48 + +**Files:** + +- Create: `app/remote/control.py` +- Create: `tests/remote/test_control.py` + +**Interfaces:** + +- Produces: `ALLOWED_REMOTE_MODES: tuple[str, ...]` (`"ask"`, `"accept-edits"`, `"plan"`, `"auto"` — no `"bypass"`), `ControlResult` (`status: Literal["ok","invalid","not_found"]`, `detail: str = ""`), `async def set_permission_mode(db: AsyncSession, session_id: str, mode: str) -> ControlResult`. +- Consumes: `app.models.chat.ChatSession`, `app.services.team_manager.current_team_for_session`/`current_coding_team_for_session`, `app.agent.permission.get_services_for_stream`/`Mode`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_control.py +from __future__ import annotations + +from uuid import UUID + +import pytest +import pytest_asyncio + +import app.core.db as db_module +from app.models.chat import ChatSession +from app.remote import control + + +@pytest_asyncio.fixture +async def chat_session() -> ChatSession: + async with db_module.async_session_factory() as db: + session = ChatSession(title="Control test", mode="work", session_type="main") + db.add(session) + await db.commit() + await db.refresh(session) + return session + + +@pytest.mark.asyncio +async def test_set_permission_mode_persists_a_valid_mode(chat_session: ChatSession) -> None: + async with db_module.async_session_factory() as db: + result = await control.set_permission_mode(db, str(chat_session.id), "ask") + + assert result.status == "ok" + async with db_module.async_session_factory() as db: + refreshed = await db.get(ChatSession, chat_session.id) + assert refreshed is not None + assert refreshed.permission_mode == "ask" + + +@pytest.mark.asyncio +async def test_set_permission_mode_rejects_bypass(chat_session: ChatSession) -> None: + async with db_module.async_session_factory() as db: + result = await control.set_permission_mode(db, str(chat_session.id), "bypass") + + assert result.status == "invalid" + async with db_module.async_session_factory() as db: + refreshed = await db.get(ChatSession, chat_session.id) + assert refreshed is not None + assert refreshed.permission_mode != "bypass" + + +@pytest.mark.asyncio +async def test_set_permission_mode_rejects_unknown_mode(chat_session: ChatSession) -> None: + async with db_module.async_session_factory() as db: + result = await control.set_permission_mode(db, str(chat_session.id), "nonsense") + + assert result.status == "invalid" + + +@pytest.mark.asyncio +async def test_set_permission_mode_not_found_for_unknown_session() -> None: + async with db_module.async_session_factory() as db: + result = await control.set_permission_mode(db, str(UUID(int=0)), "ask") + + assert result.status == "not_found" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.remote.control'`. + +- [ ] **Step 3: Implement `control.py` (mode-switching slice)** + +```python +# app/remote/control.py +"""Mode/model/lead-agent control for the phone's /settings command. + +Each write function replicates the exact persistence sequence its HTTP +route sibling already uses (app/api/routes/team/chat.py's +set_session_permission_mode/update_team_session_lead, +app/api/routes/team/webbridge.py's update_browser_session_model), as a +plain async function app/remote/actions.py can call directly — remote +and desktop must stay behaviorally identical, but app/remote/ never +depends on app/api/routes/* (see module docstring exception below for +the one deliberate departure: the model registry). + +bypass is deliberately excluded from ALLOWED_REMOTE_MODES, checked by +name (not by list length or position) before anything else runs — a +phone that could enable bypass could silently disable every approval +prompt an operator relies on (AC-48). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, cast +from uuid import UUID + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.chat import ChatSession + +__all__ = [ + "ALLOWED_REMOTE_MODES", + "ControlResult", + "set_permission_mode", +] + +#: Every permission mode this phone may set — bypass excluded on purpose. +ALLOWED_REMOTE_MODES: tuple[str, ...] = ("ask", "accept-edits", "plan", "auto") + +ControlStatus = Literal["ok", "invalid", "not_found", "conflict"] + + +@dataclass(frozen=True) +class ControlResult: + """A bounded, adapter-neutral outcome for one control write.""" + + status: ControlStatus + detail: str = "" + + +async def set_permission_mode( + db: AsyncSession, session_id: str, mode: str +) -> ControlResult: + if mode not in ALLOWED_REMOTE_MODES: + return ControlResult(status="invalid", detail=mode) + + try: + session_uuid = UUID(session_id) + except ValueError: + return ControlResult(status="not_found") + session = await db.get(ChatSession, session_uuid) + if session is None: + return ControlResult(status="not_found") + + session.permission_mode = mode + session_mode = session.mode + session_workspace = session.workspace + db.add(session) + await db.commit() + + from app.services import team_manager + + team_obj = team_manager.current_team_for_session(session_id) + if team_obj is None and session_mode == "coding" and session_workspace: + team_obj = team_manager.current_coding_team_for_session( + session_workspace, session_id + ) + if team_obj is not None: + team_obj.permission_mode = mode + + from app.agent.permission import Mode, get_services_for_stream + + for service in get_services_for_stream(session_id): + service.set_mode(cast(Mode, mode)) + + return ControlResult(status="ok") +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py +``` + +Expected: PASS (4 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/control.py tests/remote/test_control.py +uv run ty check app/remote/control.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/control.py tests/remote/test_control.py +git commit -m "feat(remote): add permission-mode control, bypass excluded" +``` + +--- + +### Task 2: `set_lead_agent` and `list_lead_names` + +**ACs:** AC-50 + +**Files:** + +- Modify: `app/remote/control.py` +- Modify: `tests/remote/test_control.py` + +**Interfaces:** + +- Produces: `async def set_lead_agent(db: AsyncSession, session_id: str, lead_name: str) -> ControlResult`, `async def list_lead_names(app_mode: str, *, limit: int = 5) -> list[str]`. +- Consumes: `app.models.chat.normalize_mode`, `app.services.team_manager.resolve_configured_lead`/`configured_lead_rosters`/`find_team_for_session`/`stop_sessions`, `app.services.memory_stream_store.running_session_ids`. + +- [ ] **Step 1: Write the failing tests** + +This worktree's isolated test config dirs (`pytest.ini`'s `EVOFLUX_CONFIG_DIR` +etc.) have no real agent configs seeded, so `configured_lead_rosters` +returns empty there — verified by running the naive version of these tests +first. Mock it with one controlled, fake roster instead of depending on +whatever happens to exist on disk in this environment; patched at the +module level so both `list_lead_names`'s direct call and +`resolve_configured_lead`'s own internal call see the same fake roster. + +```python +# tests/remote/test_control.py (add) +class _FakeLead: + """Stand-in for the roster's real lead-agent config object — only + ``.name`` is read by list_lead_names/resolve_configured_lead.""" + + def __init__(self, name: str) -> None: + self.name = name + + +def _mock_one_lead_roster(monkeypatch: pytest.MonkeyPatch, name: str = "evoflux") -> None: + monkeypatch.setattr( + "app.services.team_manager.configured_lead_rosters", + lambda mode: (name, [(_FakeLead(name), None, [])]), + ) + + +@pytest.mark.asyncio +async def test_list_lead_names_returns_configured_leads( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _mock_one_lead_roster(monkeypatch, "evoflux") + + names = await control.list_lead_names("work") + + assert names == ["evoflux"] + + +@pytest.mark.asyncio +async def test_list_lead_names_is_bounded_to_five(monkeypatch: pytest.MonkeyPatch) -> None: + fake_rosters = [(_FakeLead(f"lead-{i}"), None, []) for i in range(8)] + monkeypatch.setattr( + "app.services.team_manager.configured_lead_rosters", + lambda mode: ("lead-0", fake_rosters), + ) + + names = await control.list_lead_names("work") + + assert len(names) == 5 + + +@pytest.mark.asyncio +async def test_set_lead_agent_persists_a_valid_lead( + chat_session: ChatSession, monkeypatch: pytest.MonkeyPatch +) -> None: + _mock_one_lead_roster(monkeypatch, "evoflux") + + async with db_module.async_session_factory() as db: + result = await control.set_lead_agent(db, str(chat_session.id), "evoflux") + + assert result.status == "ok" + async with db_module.async_session_factory() as db: + refreshed = await db.get(ChatSession, chat_session.id) + assert refreshed is not None + assert refreshed.agent_name == "evoflux" + + +@pytest.mark.asyncio +async def test_set_lead_agent_rejects_unknown_name( + chat_session: ChatSession, monkeypatch: pytest.MonkeyPatch +) -> None: + _mock_one_lead_roster(monkeypatch, "evoflux") + + async with db_module.async_session_factory() as db: + result = await control.set_lead_agent( + db, str(chat_session.id), "not-a-real-configured-lead-name" + ) + + assert result.status == "invalid" + + +@pytest.mark.asyncio +async def test_set_lead_agent_conflicts_while_session_is_running( + chat_session: ChatSession, monkeypatch: pytest.MonkeyPatch +) -> None: + _mock_one_lead_roster(monkeypatch, "evoflux") + monkeypatch.setattr( + "app.services.memory_stream_store.running_session_ids", + lambda: {str(chat_session.id)}, + ) + + async with db_module.async_session_factory() as db: + result = await control.set_lead_agent(db, str(chat_session.id), "evoflux") + + assert result.status == "conflict" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py -k "lead" +``` + +Expected: FAIL with `AttributeError: module 'app.remote.control' has no attribute 'list_lead_names'`. + +- [ ] **Step 3: Implement the lead-switching slice** + +```python +# app/remote/control.py — add to __all__: "set_lead_agent", "list_lead_names" +``` + +```python +# app/remote/control.py — new functions +async def list_lead_names(app_mode: str, *, limit: int = 5) -> list[str]: + """A short, stable-order list of configured lead-agent names for + *app_mode* ("work"/"coding") — bounded for a phone's button row, same + pattern already used for workflow/project menu items in actions.py.""" + from app.services import team_manager + + try: + _default_lead, rosters = team_manager.configured_lead_rosters(app_mode) + except ValueError: + return [] + return [lead.name for lead, _path, _members in rosters][:limit] + + +async def set_lead_agent( + db: AsyncSession, session_id: str, lead_name: str +) -> ControlResult: + from app.models.chat import normalize_mode + from app.services import memory_stream_store as stream_store + from app.services import team_manager + + try: + session_uuid = UUID(session_id) + except ValueError: + return ControlResult(status="not_found") + session = await db.get(ChatSession, session_uuid) + if session is None: + return ControlResult(status="not_found") + if session.parent_session_id is not None: + return ControlResult(status="not_found") + + live_team = team_manager.find_team_for_session(session_id) + if session_id in stream_store.running_session_ids() or ( + live_team is not None + and any(member.state == "working" for member in live_team.all_members) + ): + return ControlResult( + status="conflict", + detail="Finish or stop the active task before changing lead.", + ) + + app_mode = normalize_mode(session.mode) + try: + selected = team_manager.resolve_configured_lead(app_mode, lead_name) + except ValueError as exc: + return ControlResult(status="invalid", detail=str(exc)) + + if session.agent_name != selected: + session.agent_name = selected + db.add(session) + await db.commit() + await db.refresh(session) + await team_manager.stop_sessions({session_id}) + + return ControlResult(status="ok") +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py +``` + +Expected: PASS (9 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/control.py tests/remote/test_control.py +uv run ty check app/remote/control.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/control.py tests/remote/test_control.py +git commit -m "feat(remote): add lead-agent control" +``` + +--- + +### Task 3: `set_model` and `list_model_ids` + +**ACs:** AC-49 + +**Files:** + +- Modify: `app/remote/control.py` +- Modify: `tests/remote/test_control.py` + +**Interfaces:** + +- Produces: `async def set_model(db: AsyncSession, session_id: str, model_id: str, *, thinking_level: str | None = None) -> ControlResult`, `async def list_model_ids(app_mode: str | None = None, *, limit: int = 5) -> list[str]`. +- Consumes: `app.api.routes.agents.get_registry` (the one documented exception to the service-layer-only rule — see module docstring), `app.agent.providers.thinking.accepts_thinking_level`. + +- [ ] **Step 1: Write the failing tests** + +Same lesson as Task 2's lead-agent tests: this environment happens to have +real models available via an ambiently-configured provider, but that isn't +guaranteed on every machine this suite runs on, so mock `get_registry` +with a small, controlled fake instead of depending on it. + +```python +# tests/remote/test_control.py (add) +class _FakeModelEntry: + """Stand-in for the real registry's ModelCatalogEntry — only ``.id`` + is read by list_model_ids/set_model.""" + + def __init__(self, id: str) -> None: + self.id = id + + +class _FakeRegistry: + def __init__(self, model_ids: list[str]) -> None: + self.models = [_FakeModelEntry(mid) for mid in model_ids] + + +def _mock_registry(monkeypatch: pytest.MonkeyPatch, model_ids: list[str]) -> None: + async def _fake_get_registry(*args: object, **kwargs: object) -> _FakeRegistry: + return _FakeRegistry(model_ids) + + monkeypatch.setattr("app.api.routes.agents.get_registry", _fake_get_registry) + + +@pytest.mark.asyncio +async def test_list_model_ids_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: + _mock_registry(monkeypatch, [f"provider:model-{i}" for i in range(8)]) + + model_ids = await control.list_model_ids(limit=5) + + assert len(model_ids) == 5 + + +@pytest.mark.asyncio +async def test_set_model_persists_a_valid_model( + chat_session: ChatSession, monkeypatch: pytest.MonkeyPatch +) -> None: + _mock_registry(monkeypatch, ["provider:model-a", "provider:model-b"]) + + async with db_module.async_session_factory() as db: + result = await control.set_model(db, str(chat_session.id), "provider:model-a") + + assert result.status == "ok" + async with db_module.async_session_factory() as db: + refreshed = await db.get(ChatSession, chat_session.id) + assert refreshed is not None + assert refreshed.model == "provider:model-a" + + +@pytest.mark.asyncio +async def test_set_model_rejects_unknown_model_id( + chat_session: ChatSession, monkeypatch: pytest.MonkeyPatch +) -> None: + _mock_registry(monkeypatch, ["provider:model-a"]) + + async with db_module.async_session_factory() as db: + result = await control.set_model( + db, str(chat_session.id), "not-a-real-provider:not-a-real-model" + ) + + assert result.status == "invalid" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py -k "model" +``` + +Expected: FAIL with `AttributeError: module 'app.remote.control' has no attribute 'list_model_ids'`. + +- [ ] **Step 3: Implement the model-switching slice** + +```python +# app/remote/control.py — add to __all__: "set_model", "list_model_ids" +``` + +```python +# app/remote/control.py — new functions +async def list_model_ids(app_mode: str | None = None, *, limit: int = 5) -> list[str]: + """A short, catalog-order list of registered model ids — bounded for a + phone's button row. There is no curated "recommended models" concept + in the registry today (every provider-visible model is returned + unbounded), so this is a simple positional cap, not a ranking. + Uses the module-level `cast` (imported at the top of the file, next + to Literal used for ControlStatus) — a local `from typing import + Literal, cast` here is flagged unused by ruff, since the Literal + reference below is inside a string forward-ref, not a live name.""" + from app.api.routes.agents import get_registry + + mode_arg = cast("Literal['work', 'coding'] | None", app_mode) + registry = await get_registry(mode=mode_arg) + return [entry.id for entry in registry.models][:limit] + + +async def set_model( + db: AsyncSession, + session_id: str, + model_id: str, + *, + thinking_level: str | None = None, +) -> ControlResult: + from app.api.routes.agents import get_registry + from app.agent.providers.thinking import accepts_thinking_level + + try: + session_uuid = UUID(session_id) + except ValueError: + return ControlResult(status="not_found") + session = await db.get(ChatSession, session_uuid) + if session is None: + return ControlResult(status="not_found") + + registry = await get_registry() + selected = next((entry for entry in registry.models if entry.id == model_id), None) + if selected is None: + return ControlResult(status="invalid", detail=model_id) + if thinking_level is not None and not accepts_thinking_level( + model_id, thinking_level + ): + return ControlResult(status="invalid", detail=thinking_level) + + session.model = model_id + session.thinking_level = thinking_level + db.add(session) + await db.commit() + + return ControlResult(status="ok") +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py +``` + +Expected: PASS (12 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/control.py tests/remote/test_control.py +uv run ty check app/remote/control.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/control.py tests/remote/test_control.py +git commit -m "feat(remote): add model control via the registry" +``` + +--- + +### Task 4: Settings-card rendering + +**ACs:** AC-48/49/50 (display half) + +**Files:** + +- Modify: `app/remote/formatting.py` +- Modify: `tests/remote/test_formatting.py` + +**Interfaces:** + +- Changes `render_settings_card`: adds `mode_tokens: Mapping[str, str]`, `agent_name: str`, `agent_tokens: Mapping[str, str]`, `model_tokens: Mapping[str, str]` (all required — every caller updates); `redaction_policy`, `notify_scope`, `redaction_tokens`, `notify_scope_tokens` become optional (`None`/empty default) since no caller supplies them yet. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_formatting.py (add) +def test_render_settings_card_shows_mode_model_and_agent_buttons(): + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="anthropic:claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + mode_tokens={"ask": "m1", "auto": "m2"}, + agent_tokens={"evoflux": "a1", "explorer": "a2"}, + model_tokens={"anthropic:claude-sonnet-5": "d1"}, + ) + assert "ask" in text + assert "anthropic:claude-sonnet-5" in text + assert "evoflux" in text + button_tokens = {b.token for b in buttons} + assert {"m1", "m2", "a1", "a2", "d1"} <= button_tokens + + +def test_render_settings_card_never_offers_a_bypass_button(): + _, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="anthropic:claude-sonnet-5", + permission_mode="auto", + agent_name="evoflux", + mode_tokens={"auto": "m1", "bypass": "should-never-appear"}, + agent_tokens={}, + model_tokens={}, + ) + assert "should-never-appear" not in {b.token for b in buttons} + assert not any("bypass" in b.text.lower() for b in buttons) + + +def test_render_settings_card_omits_redaction_section_when_not_supplied(): + text, _ = formatting.render_settings_card( + connection_label="evoflux-api", + model="anthropic:claude-sonnet-5", + permission_mode="auto", + agent_name="evoflux", + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + ) + assert "Outbound redaction" not in text + assert "Notifications" not in text +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py -k settings_card +``` + +Expected: FAIL — `render_settings_card()` missing required arguments `agent_name`, `mode_tokens`, `agent_tokens`, `model_tokens`. + +- [ ] **Step 3: Rewrite `render_settings_card`** + +```python +# app/remote/formatting.py — replace render_settings_card in full +def render_settings_card( + *, + connection_label: str, + model: str, + permission_mode: str, + agent_name: str, + mode_tokens: Mapping[str, str], + agent_tokens: Mapping[str, str], + model_tokens: Mapping[str, str], + redaction_policy: str | None = None, + notify_scope: str | None = None, + redaction_tokens: Mapping[str, str] = {}, + notify_scope_tokens: Mapping[str, str] = {}, +) -> tuple[str, tuple[RemoteButton, ...]]: + lines = [ + "⚙️ Settings", + "", + f"Connection\n{escape(connection_label)}", + "", + f"Mode\n{escape(permission_mode)}", + "", + f"Model\n{escape(model)}", + "", + f"Lead agent\n{escape(agent_name)}", + ] + if notify_scope is not None: + lines += ["", f"Notifications\n{escape(notify_scope)}"] + if redaction_policy is not None: + lines += [ + "", + f"Outbound redaction\n{escape(redaction_policy)}", + ] + text = "\n".join(lines) + + buttons: list[RemoteButton] = [ + RemoteButton(text=f"Mode: {escape(name)}", token=token) + for name, token in mode_tokens.items() + if name != "bypass" + ] + buttons += [ + RemoteButton(text=f"Agent: {escape(name)}", token=token) + for name, token in agent_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Model: {escape(name)}", token=token) + for name, token in model_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Redaction: {escape(name)}", token=token) + for name, token in redaction_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Notify: {escape(name)}", token=token) + for name, token in notify_scope_tokens.items() + ] + return text, tuple(buttons) +``` + +Note the `if name != "bypass"` filter on the mode-button comprehension: defense in depth alongside `control.ALLOWED_REMOTE_MODES` already excluding it at the source (AC-48's boundary is enforced at both the write path and the render path, so a bug in one is never the only thing standing between a phone and bypass). + +- [ ] **Step 3b: Update two pre-existing tests this rewrite breaks** + +`test_render_settings_card_never_emits_model_or_permission_buttons` and +`test_render_settings_card_escapes_toggle_names` (both already in +`tests/remote/test_formatting.py`, from the earlier response-ui work) call +`render_settings_card` without the new required `agent_name`/`mode_tokens`/ +`agent_tokens`/`model_tokens` — both will now raise `TypeError`. The first +one is more than a signature mismatch: it explicitly asserted the *old* +AC-41 boundary ("never emits model or permission buttons"), which this +phase's AC-48/49 deliberately supersede. Rename and invert it rather than +just patching its call: + +```python +# tests/remote/test_formatting.py — replace in place +def test_render_settings_card_now_emits_mode_and_model_buttons(): + """Supersedes the old AC-41 boundary (model/mode were never buttons, + "desktop only" read-only text) — AC-48/49 revise that: mode and model + are now remotely settable, so this card must offer buttons for both.""" + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + mode_tokens={"ask": "m1"}, + agent_tokens={}, + model_tokens={"claude-sonnet-5": "d1"}, + redaction_policy="standard", + notify_scope="all", + redaction_tokens={"strict": "r1", "off": "r2"}, + notify_scope_tokens={"all": "n1", "remote_only": "n2"}, + ) + button_texts = [b.text for b in buttons] + assert any("model" in t.lower() for t in button_texts) + assert any("mode" in t.lower() for t in button_texts) + assert any("strict" in t.lower() for t in button_texts) +``` + +```python +# tests/remote/test_formatting.py — test_render_settings_card_escapes_toggle_names, +# add the three new required kwargs to its existing render_settings_card call +# (agent_name="evoflux", mode_tokens={}, agent_tokens={}, model_tokens={}) — +# its actual assertions (about redaction/notify escaping) are unchanged. +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py +``` + +Expected: PASS (all tests in the file). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py +uv run ty check app/remote/formatting.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): settings card renders mode/model/agent as buttons, bypass excluded" +``` + +--- + +### Task 5: Wire `/settings` into `actions.py` + +**ACs:** AC-48, AC-49, AC-50, AC-58 (partial — adds `settings` to the command set) + +**Files:** + +- Modify: `app/remote/actions.py` +- Modify: `tests/remote/test_actions.py` + +**Interfaces:** + +- Changes `_SLASH_COMMANDS`: adds `"settings"`. +- Produces (private): `_cmd_settings`, `_exec_set_mode`, `_exec_set_agent`, `_exec_set_model`. +- Consumes: `app.remote.control` (all six public names), `app.remote.formatting.render_settings_card`, `app.models.chat.ChatSession`/`normalize_mode`. + +- [ ] **Step 1: Write the failing tests** + +This file's established pattern (see `TestStatus`) mocks +`service._pairing_service.authorize` directly rather than persisting a +real `RemotePairing` row — `mock_db = MagicMock()` is enough when the +command never actually touches the DB. `/settings` *does* need a real +`db.get(ChatSession, ...)` round-trip once `active_session_id` is set, so +these three tests use a real DB session (`app.core.db.async_session_factory`, +already used the same way in `tests/remote/test_control.py`) instead of +`MagicMock()`, while still mocking `authorize` for pairing/authorization +exactly like every other command test in this file. + +```python +# tests/remote/test_actions.py — add near the top, alongside the existing imports +import app.core.db as db_module +from app.models.chat import ChatSession +``` + +```python +# tests/remote/test_actions.py (add) +class TestSettings: + @pytest.mark.asyncio + async def test_settings_command_shows_current_mode_model_and_agent( + self, service: RemoteActionService + ) -> None: + async with db_module.async_session_factory() as db: + session = ChatSession( + title="Settings test", + mode="work", + session_type="main", + permission_mode="ask", + model="anthropic:claude-sonnet-5", + agent_name="evoflux", + ) + db.add(session) + await db.commit() + await db.refresh(session) + + mock_pairing = MagicMock() + mock_pairing.active_session_id = session.id + mock_pairing.label = "My Phone" + + with patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ): + action = _make_action(text="/settings") + result = await service.dispatch_command(db, action) + + assert result.status == "ok" + assert "ask" in result.text + assert "anthropic:claude-sonnet-5" in result.text + assert "evoflux" in result.text + + @pytest.mark.asyncio + async def test_settings_command_without_active_session_is_friendly( + self, service: RemoteActionService + ) -> None: + mock_db = MagicMock() + mock_pairing = MagicMock() + mock_pairing.active_session_id = None + + with patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ): + action = _make_action(text="/settings") + result = await service.dispatch_command(mock_db, action) + + assert result.status == "ok" + assert "no active task yet" in result.text.lower() + + @pytest.mark.asyncio + async def test_mode_callback_applies_the_selected_mode( + self, service: RemoteActionService, adapter: FakeAdapter + ) -> None: + conn_id = uuid4() + async with db_module.async_session_factory() as db: + session = ChatSession( + title="Settings test", + mode="work", + session_type="main", + permission_mode="auto", + ) + db.add(session) + await db.commit() + await db.refresh(session) + + mock_pairing = MagicMock() + mock_pairing.active_session_id = session.id + mock_pairing.label = "My Phone" + + with patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ): + settings_action = _make_action(text="/settings", connection_id=conn_id) + await service.dispatch_command(db, settings_action) + + sent_buttons = adapter.sent_messages[-1].buttons + ask_token = next(b.token for b in sent_buttons if b.text == "Mode: ask") + + callback_action = _make_action( + kind=RemoteInboundActionKind.CALLBACK, + callback_token=ask_token, + connection_id=conn_id, + ) + handled = await service.handle_action_callback(callback_action, db) + + assert handled is True + refreshed = await db.get(ChatSession, session.id) + assert refreshed is not None + assert refreshed.permission_mode == "ask" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py -k Settings +``` + +Expected: FAIL — `/settings` falls through to `_cmd_help` (unknown command), so none of the mode/model/agent assertions match. + +- [ ] **Step 3: Add `/settings` to the known commands and dispatch** + +```python +# app/remote/actions.py — _SLASH_COMMANDS +_SLASH_COMMANDS: frozenset[str] = frozenset( + {"start", "help", "status", "new", "stop", "unpair", "actions", "settings"} +) +``` + +```python +# app/remote/actions.py — inside dispatch_command, alongside the other elif branches + elif command == "settings": + return await self._cmd_settings(db, action) +``` + +- [ ] **Step 4: Implement `_cmd_settings`** + +```python +# app/remote/actions.py — new method, near _cmd_status + async def _cmd_settings( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.models.chat import ChatSession, normalize_mode + from app.remote import control + from app.remote.formatting import render_settings_card + + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is None: + return RemoteActionResult(status="unauthorized") + + # Both branches below call self._send explicitly, not just return + # text — runtime.py's guard (next step) skips its own fallback send + # for the WHOLE "settings" command unconditionally (matching how it + # already does for "actions"), so a branch that only returned text + # without sending it would silently never reach the phone at all. + no_active_session_text = ( + "No active task yet — send a message to start one, then " + "/settings shows its mode/model/agent." + ) + if pairing.active_session_id is None: + await self._send(action.principal.destination_id, no_active_session_text) + return RemoteActionResult(status="ok", text=no_active_session_text) + + session = await db.get(ChatSession, pairing.active_session_id) + if session is None: + await self._send(action.principal.destination_id, no_active_session_text) + return RemoteActionResult(status="ok", text=no_active_session_text) + + app_mode = normalize_mode(session.mode) + session_id = str(session.id) + + mode_tokens = { + mode: self._issue_settings_token(action, session_id, "set_mode", mode) + for mode in control.ALLOWED_REMOTE_MODES + } + agent_names = await control.list_lead_names(app_mode) + agent_tokens = { + name: self._issue_settings_token(action, session_id, "set_agent", name) + for name in agent_names + } + model_ids = await control.list_model_ids(app_mode) + model_tokens = { + model_id: self._issue_settings_token(action, session_id, "set_model", model_id) + for model_id in model_ids + } + + text, buttons = render_settings_card( + connection_label=pairing.label or "This phone", + model=session.model or "(default)", + permission_mode=session.permission_mode, + agent_name=session.agent_name or "(default)", + mode_tokens=mode_tokens, + agent_tokens=agent_tokens, + model_tokens=model_tokens, + ) + + if self._adapter is not None: + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok", text=text) + + def _issue_settings_token( + self, + action: RemoteInboundAction, + session_id: str, + action_kind: str, + action_target: str, + ) -> str: + return self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id=session_id, + action_kind=action_kind, + action_target=action_target, + ) +``` + +`/settings` already sends its own message with buttons (like `/actions` does) — add it to the existing "already sent its own message" guard in `runtime.py`'s `_handle_command` alongside `actions`, so the plain-text result isn't sent a second time: + +```python +# app/remote/runtime.py — inside _handle_command + command = (action.text or "").strip().split(maxsplit=1)[0][1:].lower() + if command in ("actions", "settings"): + return +``` + +- [ ] **Step 5: Run and confirm the settings-display tests pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py -k "settings_command" +``` + +Expected: PASS. + +- [ ] **Step 6: Write failing tests for the three new capability action kinds, then implement `_execute_action`'s new branches** + +`_execute_action` already receives `db: AsyncSession` as a parameter +(from Task 6 of the earlier response-ui plan, which added it so +`handle_action_callback`'s existing workflow/coding-project/schedule +branches could share one session) — use that directly rather than +opening a second, redundant session via `async_session_factory()`. + +```python +# app/remote/actions.py — inside _execute_action + elif cap.action_kind == "set_mode": + return await self._exec_set_mode(cap, action, db) + elif cap.action_kind == "set_agent": + return await self._exec_set_agent(cap, action, db) + elif cap.action_kind == "set_model": + return await self._exec_set_model(cap, action, db) +``` + +```python +# app/remote/actions.py — new methods, near _exec_workflow_start + async def _exec_set_mode( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_permission_mode(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Mode set to {cap.action_target}." + ) + return True + + async def _exec_set_agent( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_lead_agent(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Lead agent set to {cap.action_target}." + ) + return True + + async def _exec_set_model( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_model(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Model set to {cap.action_target}." + ) + return True + + async def _reply_control_result( + self, + action: RemoteInboundAction, + result: control.ControlResult, + success_text: str, + ) -> None: + if result.status == "ok": + text = success_text + elif result.status == "conflict": + text = result.detail or "That can't be changed right now." + elif result.status == "not_found": + text = "That task no longer exists." + elif result.detail: + text = f"That value isn't valid: {result.detail}" + else: + text = "That value isn't valid." + await self._reply_text(action.principal.destination_id, text) +``` + +`result: control.ControlResult`'s annotation resolves fine at type-check +time only because `from __future__ import annotations` is already at the +top of `actions.py` (deferring all annotations to strings) — add `from +app.remote import control` to the existing `if TYPE_CHECKING:` block near +the top of the file (alongside `RemoteAdapterStatus`/`RemoteProjection`) +so the type checker can still resolve the name, without a real module-level +import (this module never imports `app.remote.control` outside `TYPE_CHECKING`, +matching every other cross-module reference in this file). + +- [ ] **Step 7: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py +``` + +Expected: PASS (every test in the file, including the new mode-callback one). + +- [ ] **Step 8: Full remote suite, lint, type-check** + +```powershell +uv run pytest --no-cov -q tests/remote/ +uv run ruff check app/remote/actions.py app/remote/runtime.py tests/remote/test_actions.py +uv run ty check app/remote/ +``` + +- [ ] **Step 9: Commit** + +```bash +git add app/remote/actions.py app/remote/runtime.py tests/remote/test_actions.py +git commit -m "feat(remote): wire /settings command with mode/model/agent switching" +``` + +--- + +## Self-review notes + +- **Spec coverage:** AC-48 (Tasks 1, 4 filter, 5's `ALLOWED_REMOTE_MODES` usage), AC-49 (Task 3, 5), AC-50 (Task 2, 5) are each covered. AC-51–58 are explicitly out of scope, next phases. +- **The bypass boundary is enforced twice, independently:** `control.ALLOWED_REMOTE_MODES` (write path, Task 1) and `render_settings_card`'s `if name != "bypass"` filter (render path, Task 4) — a bug in either alone still can't produce a working bypass button, matching the spec's "excluded in the remote layer itself" requirement. +- **Type/name consistency:** `ControlResult.status` values (`ok`/`invalid`/`not_found`/`conflict`) are used identically in `_reply_control_result` (Task 5) and match every test assertion in Tasks 1-3. `render_settings_card`'s parameter names (Task 4) match every call site in `_cmd_settings` (Task 5) exactly. +- **Task 5's tests were verified against the real file, not guessed:** `tests/remote/test_actions.py`'s actual fixtures (`service`, `adapter`, `_make_action`) and established pattern (mock `service._pairing_service.authorize`, matching `TestStatus`'s existing tests) were read directly and matched exactly — `FakeAdapter.sent_messages` (not a guessed `.sent`) is this file's real attribute name for delivered `RemoteOutboundMessage`s, confirmed at `test_actions.py:34`. diff --git a/documents/plans/remote-telegram-control-surface.md b/documents/plans/remote-telegram-control-surface.md new file mode 100644 index 00000000..4be235fd --- /dev/null +++ b/documents/plans/remote-telegram-control-surface.md @@ -0,0 +1,467 @@ +# Remote Telegram: decidable approvals, remote control, and live activity + +Status: proposed + +**Amends `documents/plans/remote-channel-telegram.md` and +`documents/plans/remote-telegram-response-ui.md`.** Both remain normative +for everything not listed below. This document changes five accepted +contracts and adds new scope on top of the rest: + +- **AC-20 / AC-22** (event allowlist, quiet lifecycle) are revised to permit + tool, skill, and agent names in status text — **only** under an opt-in + `live` response mode that is off by default. +- **AC-28** (remote permission replies limited to `once`/`reject`) is revised + to admit `always`, which is session-scoped in the owning service and not + the permanent grant the original restriction assumed. +- **AC-32** (no remote settings writes, redaction excepted) is widened to + admit exactly three additional writes: permission mode, model, and lead + agent. Credentials, provider configuration, and sandbox policy remain + forbidden, unchanged. +- **AC-38** (bounded lifecycle liveliness) is revised: `live` mode edits the + status card on a throttled cadence rather than only on lifecycle + transitions. `summary` mode keeps AC-38 as written. +- **AC-41** (model and permission mode are read-only remotely) is superseded + by AC-48/AC-49, with `bypass` carved out as permanently desktop-only. + +## Problem and outcome + +The remote channel can start work and report completion, but it cannot be +*trusted with* work. Three concrete failures: + +**Approvals are undecidable.** `app/remote/gates.py:113` renders a permission +request as `f"Permission requested: {tool}"` — the tool name only. A request +to run `rm -rf build/ && git clean -fdx` reaches the phone as "Permission +requested: shell" with Allow/Reject buttons. The actual command is already in +the event payload (`patterns[0]`, `app/agent/hooks/stream_publisher.py:309`) +and is discarded. The operator is asked to authorize something they cannot +see. + +**Approvals never fire in the default configuration.** `ChatSession.permission_mode` +defaults to `auto` (`app/models/chat.py:153`), and `auto` never blocks +(`app/agent/permission.py:386`). The gate machinery is unreachable until the +mode changes — and AC-41 forbids changing it from the phone. The approval +feature is, in practice, structurally dead. + +**Turns are opaque while they run.** A phone-admitted turn shows one static +status card and a typing indicator for its whole duration. For a multi-minute +turn there is no signal about what the agent is doing, which tool it is +running, or which agent in the team is active. + +The outcome is a remote surface that behaves like a real coding client: an +approval card that names the command and its blast radius, the ability to put +the session into a mode where approvals actually happen, live narration of +tools and skills when asked for, read-only insight into system health and +code changes, and a first-run flow that points at all of it. + +## Goals + +- Render permission requests with the actual command, a derived severity, and + the exact glob a session-scoped allow would grant. +- Fix the gate card lifecycle so an answered card visibly resolves instead of + keeping dead buttons forever. +- Let the phone change permission mode, model, and lead agent — never + `bypass`, never credentials. +- Add read-only `/health` and `/changes` surfaces backed by existing + diagnostics and turn-change services. +- Add an opt-in `live` response mode that narrates tools, skills, and active + agent on a throttled cadence, and keep today's quiet behavior as the + default. +- Replace the dead-end pairing confirmation with a first-run card that offers + the next useful action. +- Keep the command surface short enough that every command means something + concrete. + +## Non-goals + +- **`bypass` permission mode is never settable remotely**, under any command, + button, callback, or natural-language shortcut. A phone that can silently + disable every approval prompt is a privilege-escalation path into the + host, not a feature. +- No credential entry and no provider configuration writes from the phone. + `PUT /api/settings/providers/{id}` writes API keys into `.env` + (`app/api/routes/settings.py:1340`) and stays desktop-only. +- No token-by-token streaming. Telegram flood-limits edits to roughly one per + second per chat and rejects unchanged-text edits outright; an agent turn + emits deltas far faster than that. `live` mode coalesces, it does not + stream. +- No `ToolOutputDeltaEvent` (raw stdout/stderr) in v1 — the least bounded + content in the system, deferred until the bounded cases are proven. +- No second permission policy in the remote layer. Derived severity is + **advisory display only** and never changes what is gated; gating stays + entirely `PermissionService`'s decision. +- No persistent "allow forever" grant. `always` is session-scoped in the + owning service (`app/agent/permission.py:372`) and the remote label says so. +- No change to the one-connection/one-pairing v1 limit, the pairing flow, or + callback-token ownership rules. + +## User flows and states + +### Approving a dangerous command + +When `PermissionService.ask` raises a request, the gate card renders the +command, not the tool name: + +``` +🔴 Dangerous command +
rm -rf build/ && git clean -fdx
+shell · evoflux · "Fix failing tests" + +[ Allow once ] [ Allow for session ] [ Reject ] +``` + +Severity is derived, displayed, and advisory (AC-46). "Allow for session" +shows the glob it would grant, because `always` broadens the request to a +pattern (`always_patterns[0]`, e.g. `git push *`) and the operator must see +that blast radius before granting it: + +``` +[ 🔒 Allow for session — git push * ] +``` + +On reply the card is edited in place to its resolved form, losing its +buttons and stating what was decided: + +``` +✅ Allowed once +
rm -rf build/ && git clean -fdx
+``` + +Today none of that final edit happens: `_PendingGate.chat_id`/`message_id` +(`app/remote/gates.py:72`) are never assigned, so the guard at `gates.py:192` +is permanently false; the card is sent without a `correlation_id`, so the +adapter has no record to edit (`app/remote/telegram/adapter.py:209`); and +`_do_edit_remove` edits with `text=""` (`gates.py:401`), which Telegram +rejects. All three layers are fixed by AC-47. + +### Changing how the agent works + +`/settings` opens one hub card showing current state, with a button per +changeable value: + +``` +⚙️ Settings + +Mode ask +Model mimo-v2.5-pro +Lead agent evoflux +Responses summary +Providers 3 configured + +[ Mode ] [ Model ] [ Agent ] [ Responses ] [ Providers ] +``` + +Each button opens a bounded picker; each pick calls one existing service +entry point and re-renders the hub. The mode picker lists `ask`, +`accept-edits`, `plan`, and `auto`, and displays `bypass` as present but +unavailable from the phone — shown rather than hidden, so the card never +misrepresents the session's actual state when `bypass` is set at the desktop. + +Lead-agent changes are refused while a turn is running (the owning endpoint +409s, `app/api/routes/team/chat.py:1720`); the card surfaces that as "finish +or /stop the current task first", never a raw error. + +### Watching a turn in live mode + +With `response_mode = live`, the turn's single status card is edited on a +throttled cadence with a rolling activity window: + +``` +🔧 Fix failing tests · 1m 12s + +explorer · 💭 thinking +📚 Skill: test-driven-development +🔧 grep "def test_auth" +🔧 read tests/test_auth.py +⏳ shell pytest tests/test_auth.py -q +``` + +Skills are not a distinct event; they are a tool call carrying `skill_name` +in its arguments (`app/agent/tools/builtin/skill.py:193`) and are +special-cased in rendering. The `agent` field present on every event +(`app/agent/schemas/events.py:31`) names which team member is active. + +The window holds the most recent entries only, so the card cannot grow toward +Telegram's 4096-character limit. When the turn ends this same message becomes +the done card — one message per turn, start to finish, in both modes. + +With `response_mode = summary` (default), behavior is exactly today's: one +status card, one final done/error card, typing indicator in between. + +### Checking health and changes + +`/health` renders `GET /api/health/diagnostics`, whose response is already +shaped for this (`{id, label, status: ok|warn|fail, detail, hint}`, +`app/api/routes/health.py:193`): + +``` +🩺 Health + +✅ Database ok +✅ Migrations at head +⚠️ Providers 1 of 3 unreachable +✅ MCP servers none configured +❌ Disk space 2.1 GB free +``` + +`/changes` renders the current session's turn changes (files plus line +counts, `app/api/routes/team/chat.py:1675`) with a drill-down button per file +that fetches real diff text through the existing capability-token and +chunking machinery. + +### First run + +The pairing confirmation becomes a starting point rather than a dead end: + +``` +✅ Paired! This phone is connected to EvoFlux. + +Mode is auto — the agent won't ask before running commands. + +[ ⚙️ Set up ] [ 🩺 Health check ] [ 💬 Just start working ] +``` + +The mode line is stated because `auto` means no approval prompts will ever +reach this phone, which is the single most consequential default the operator +should know about at pairing time. + +## Requirements and acceptance criteria + +IDs continue from AC-44. AC-20, AC-22, AC-28, AC-32, AC-38 and AC-41 are +revised as described above; all others are new. + +- **AC-45 — Decidable permission cards:** A permission card renders the + requested command from the event's `patterns[0]`, the tool name, the owning + agent, and the session title. Command text passes through outbound + redaction and then HTML escaping, in that order. A card offering a + session-scoped allow displays the glob that allow would grant + (`always_patterns[0]`). No card asks for a decision without showing what is + being decided. +- **AC-46 — Advisory severity only:** Severity is derived from the tool name + and command text: `high` for a destructive-pattern match, `elevated` for + `shell`/`python`/`process`/`rm`, `normal` otherwise. Severity changes only + the rendered icon and label. Tests prove that no severity value alters + which requests are gated, which replies are accepted, or how any reply + resolves — gating remains entirely `PermissionService`'s decision. +- **AC-47 — Gate cards resolve visibly:** A gate card is sent with a + `correlation_id`, and on reply the same message is edited to a resolved + form that states the decision and carries no buttons. The dead + `chat_id`/`message_id` fields are removed rather than populated, since the + adapter already maps correlation ids to sent messages. Tests cover reply, + expiry, and adapter-edit failure. +- **AC-48 — Remote permission mode, bypass excluded:** `/settings` can set + `ask`, `accept-edits`, `plan`, or `auto` through + `PATCH /api/team/sessions/{id}/permission-mode`. `bypass` is rejected by + the remote layer independently of what the endpoint accepts, and a test + asserts no remote code path can produce it. A session already in `bypass` + displays as such and may be changed *out of* bypass from the phone. +- **AC-49 — Remote model switching:** `/settings` can set the session model + from the registry-validated catalog. Requires a new + `PATCH /api/team/sessions/{id}/model` for team sessions, mirroring the + existing side-chat endpoint (`app/api/routes/team/webbridge.py:883`), + including its registry and `accepts_thinking_level` validation. +- **AC-50 — Remote lead agent switching:** `/settings` can change the lead + agent through `PATCH /api/team/sessions/{id}/lead`, and renders the + endpoint's running-session 409 as a bounded, actionable message. +- **AC-51 — Health surface:** `/health` renders every check from + `GET /api/health/diagnostics` with per-check status. No check value + containing a secret, key, path, or environment value is rendered beyond + what that endpoint already returns. +- **AC-52 — Changes surface:** `/changes` lists the current session's changed + files with line counts; per-file drill-down returns that file's diff text, + redacted and chunked like any other outbound content, under the existing + capability-token TTL. +- **AC-53 — Providers stay read-only:** The phone may list providers and + their configured state and usage. No remote path reaches + `PUT /api/settings/providers/{id}`, `POST /providers/{id}/test`, or any + other credential-accepting endpoint. Proven by an inspection test over the + remote dispatch surface. +- **AC-54 — First-run card:** A successful pairing sends a card naming the + current permission mode and offering setup, health check, and start-working + actions. A rejected pairing sends nothing, unchanged from AC-9. +- **AC-55 — Response mode preference:** `RemotePairing.response_mode` + (`summary` default, `live`) is set from `/settings`, persists across + restarts, and takes effect on the next turn with no restart. +- **AC-56 — Live mode content and bounds:** In `live` mode the status card + shows a rolling window of the **6 most recent** activity entries — tool + name plus an argument summary truncated to **80 characters**, skill name, + thinking state, and active agent — each redaction-processed then escaped. + Six entries at 80 characters bounds the activity block under ~600 + characters, well clear of Telegram's 4096-character limit even with a long + title and header. + `ToolOutputDeltaEvent` is never rendered. In `summary` mode no activity + entry is ever rendered, preserving AC-20 as originally written. +- **AC-57 — Throttled, budgeted edits:** Live-mode edits are coalesced to at + most one per `LIVE_EDIT_INTERVAL` (3s) per turn, skipped entirely when the + rendered text is unchanged, and drawn from one shared per-connection edit + budget across all concurrently live turns. On a Telegram `429` the budget + honors `retry_after` and the cadence degrades; updates are never dropped + silently in a way that loses the final card. +- **AC-58 — Bounded command surface:** The command set is `/help`, + `/status`, `/new`, `/stop`, `/settings`, `/health`, `/changes`, + `/actions`, `/unpair`. Configuration lives behind `/settings`; `/health` + and `/changes` stay top-level as actions. Telegram's native command menu + advertises exactly this set and never a command that is not implemented. + +## API, event, tool, and UI contracts + +### New modules + +- `app/remote/severity.py` — derives advisory severity from tool name and + command text. Pure, no I/O, no dependency on `app.remote` state. +- `app/remote/control.py` — the three write operations (mode, model, lead) + plus the read aggregation `/settings` renders. Owns the bypass refusal and + the 409 translation. Keeps `actions.py` from absorbing a second + responsibility. +- `app/remote/live_activity.py` — the rolling activity window and its + rendering. `app/remote/outbound.py` is already 889 lines and `actions.py` + 753; live-mode buffering and window management go here rather than growing + either further. +- `app/remote/edit_budget.py` — the shared per-connection edit budget and + `retry_after` backoff used by AC-57. + +### Changed modules + +- `app/remote/gates.py` — command-bearing permission cards, `always` reply, + correlation-id send, resolved-form edit, dead field removal. +- `app/remote/formatting.py` — builders for permission, settings, picker, + health, changes, onboarding, and live-activity cards. +- `app/remote/actions.py` — `/settings`, `/health`, `/changes` dispatch and + their capability action kinds. +- `app/remote/outbound.py` — live-mode observation and throttled edit path. +- `app/remote/runtime.py` — onboarding card, response-mode wiring. +- `app/remote/telegram/adapter.py` — command menu updated to AC-58's set. + +### Event observation + +`RemoteProjection.observe`'s allowlist becomes mode-dependent: `summary` keeps +today's set exactly; `live` additionally observes `tool_call`, `tool_start`, +`tool_end`, and `thinking`. `observe` stays synchronous, bounded, and +non-blocking — activity entries are buffered in memory and rendered on the +async delivery path, never inside `observe()`. + +### New endpoint + +`PATCH /api/team/sessions/{session_id}/model` — team-session model change, +mirroring `webbridge.py:883`'s validation. This is the one backend gap the +design requires; every other operation reuses an existing endpoint. + +## Data model, migration, and retention + +One migration (`00000066`) adds `remote_pairings.response_mode` (bounded +enum, default `summary`), revising `down_revision` from the current head +`00000065` and bumping `SCHEMA_HEAD` in `app/core/schema_version.py:16` +to match — the same two-step the `notify_scope` migration performed. +Permission mode, model, and lead agent already persist on `ChatSession` +(`permission_mode`, `model`, `agent_name`) and are not duplicated. + +Activity windows, severity values, and edit budgets are in-memory and +ephemeral, consistent with the accepted spec's treatment of progress-message +state. Diff and health content is read on demand and never cached beyond the +existing capability-token TTL. No new durable store. + +## Permissions, security, privacy, and trust + +The `bypass` exclusion (AC-48) is the load-bearing boundary of this document. +Everything else the phone gains is recoverable or observable; a remote +`bypass` toggle is neither, because it disables the very prompts that would +reveal its misuse. It is excluded in the remote layer itself, not merely +omitted from a menu, so a forged or replayed callback cannot reach it. + +Advisory-only severity (AC-46) is the second boundary. A remote layer that +derived its own notion of "dangerous" and acted on it would be a second +permission policy that can silently diverge from `PermissionService`. Severity +may change an icon; it may never change an outcome. + +Tool arguments are the highest-risk text this feature renders — they carry +file paths, command strings, and occasionally secrets. Every rendered field +passes through `protect_outbound_text(context=OutboundContext(channel="remote"))` +and then `html.escape`, redaction first, escaping last, matching AC-24 +(revised). `ToolOutputDeltaEvent` is excluded from v1 precisely because it is +the least bounded of these. + +Provider read-only (AC-53) is enforced by inspection test rather than by +convention, since the credential-writing endpoint sits one call away from the +provider-listing one the phone legitimately uses. + +## Concurrency, failure, recovery, and idempotency + +The shared edit budget (AC-57) is the new contention point: several turns can +be live at once when `notify_scope=all`, and each independently wants the +same per-chat edit allowance. The budget is owned per connection and consulted +by every live turn, so contention degrades cadence rather than producing 429s. +A turn's final done/error card is drawn from the same reserved allowance and +is never starved by activity updates — liveliness is best-effort, completion +is not. + +Live-mode buffering reuses the existing per-turn lifecycle: the activity +window is owned by the same `_TurnDeliveryState` that owns the status message +and typing task, and is discarded wherever that state is already torn down +(completion, error, interrupt, adapter shutdown, unpairing). No new teardown +path. + +A control operation that fails at its owning endpoint (validation, 409, +transport) re-renders the settings hub with the failure stated and the +unchanged current value, so the card never shows a value the backend did not +accept. + +## Observability and diagnostics + +New counters: severity distribution across rendered permission cards, remote +reply distribution (`once`/`always`/`reject`), `response_mode` distribution +across pairings, live-edit attempts versus budget-deferred edits, and +control-operation outcomes by kind. No label carries session, path, command, +or content data, consistent with the accepted spec's metric-label rules. + +The gate-card resolution fix (AC-47) is itself an observability improvement: +an unresolved card is currently indistinguishable from a resolved one. + +## Compatibility, rollout, and rollback + +Every behavior change is default-off or default-unchanged. `response_mode` +defaults to `summary`, which is exactly today's behavior. Permission mode is +untouched until explicitly changed, so no existing session's gating changes +as a result of this document. The permission-card and gate-lifecycle fixes +(AC-45/AC-47) change only what an already-sent card contains and whether it +resolves — both strictly more correct than today. + +The one behavior change visible without opting in is the pairing card +(AC-54), which replaces a one-line confirmation. + +Rollback is the accepted spec's disable/remove path. The migration is +additive with a default; no irreversible state is created. + +## Verification matrix + +| AC | Evidence | +|---|---| +| AC-45 | Gate-card golden-output tests including adversarial command text (`<`, `>`, `&`, tag-like strings); redaction-before-escaping order test; glob-display test | +| AC-46 | Severity unit tests per class; inspection test proving no severity value reaches any gating or reply path | +| AC-47 | Reply/expiry/edit-failure lifecycle tests asserting exactly one edit, resolved text, zero buttons; regression test that a card is sent with a correlation id | +| AC-48 | Mode-set round-trip tests per allowed mode; refusal test for `bypass` via command, callback, and forged token; display test for a desktop-set `bypass` session | +| AC-49 | Model-set round-trip against the registry; invalid-model and thinking-level rejection tests; new endpoint's own route tests | +| AC-50 | Lead-change round-trip; 409-while-running translation test | +| AC-51, AC-52 | Render tests against fixture diagnostics/changes payloads; drill-down scoping, redaction, chunking, and expiry tests | +| AC-53 | Inspection test over the full remote dispatch surface proving no path reaches a credential-accepting endpoint | +| AC-54 | Pairing-success card content test; pairing-rejection silence test (AC-9 regression) | +| AC-55 | Migration default test; `/settings` round-trip; next-turn effect without restart | +| AC-56 | Window-cap and 4096-boundary tests; skill-vs-tool rendering test; `summary`-mode assertion that no activity entry is ever rendered (AC-20 regression) | +| AC-57 | Throttle-interval test; unchanged-text skip test; multi-turn budget contention test; `429`/`retry_after` degradation test; final-card-not-starved test | +| AC-58 | Command-set test; native-menu registration test asserting no unimplemented command is advertised | + +## Ownership and source map + +- Severity derivation: `app/remote/severity.py` (new). +- Control operations and settings aggregation: `app/remote/control.py` (new). +- Live activity window: `app/remote/live_activity.py` (new). +- Edit budget: `app/remote/edit_budget.py` (new). +- Gate cards and lifecycle: `app/remote/gates.py`. +- Card rendering: `app/remote/formatting.py`. +- Command dispatch: `app/remote/actions.py`. +- Turn observation and delivery: `app/remote/outbound.py`. +- Onboarding and wiring: `app/remote/runtime.py`. +- Schema: one migration adding `remote_pairings.response_mode`. +- New endpoint: `PATCH /api/team/sessions/{id}/model`. + +This document remains proposed until implementation is verified and +reconciled into current documentation, per the accepted spec's lifecycle +rules. diff --git a/documents/plans/remote-telegram-copy-improvements.md b/documents/plans/remote-telegram-copy-improvements.md new file mode 100644 index 00000000..4b6f589f --- /dev/null +++ b/documents/plans/remote-telegram-copy-improvements.md @@ -0,0 +1,154 @@ +# Remote Telegram: command names and messages are unclear — proposed copy + +Status: proposed (feedback only — not implemented, not scheduled) + +## Problem + +None of the existing or planned specs ([`remote-channel-telegram.md`](remote-channel-telegram.md), +[`remote-telegram-response-ui.md`](remote-telegram-response-ui.md)) address command +naming or message wording — they cover formatting, live status, and new +surfaces (`/settings`, guided picker), not clarity of what's already there. +Direct user feedback on the current bot: + +- "What does 'type to chat with agent' mean? Give me an actual command." +- "`/new` — start a new task — doesn't mean anything to me." +- "`/stop` doesn't mean anything when I don't know what's running." +- "`/actions` also doesn't mean anything to me." +- "`Connected to EvoFlux on {label}.`" reads as a raw log line, not a + confirmation a person sent from their phone. + +The current copy assumes the reader already understands EvoFlux's +task/turn model (a "task" is a chat session; "stop" interrupts the agent's +current turn; "actions" is a menu, not an action). A first-time phone user +doesn't have that model yet — the copy needs to teach it, not just label it. + +## Scope + +Copy only. No command is renamed, added, or removed; no behavior changes. +Every string below is a literal replacement in the same two files: + +- `app/remote/actions.py` — `_HELP_TEXT` (`actions.py:87`) +- `app/remote/runtime.py` — pairing confirmation (`runtime.py:401`) +- `app/remote/telegram/adapter.py` — Telegram's native "/" command-menu + descriptions (`_register_commands`, `adapter.py:324-330`) + +## Proposed copy + +### 1. `/help` text + +**Current:** +``` +Available commands: + +/help — Show this help +/status — Show connection and current task status +/new — Start a new task (clears current task) +/stop — Stop the current running task +/unpair — Unpair this phone from EvoFlux +/actions — Show more actions (Workflows, Projects, Scheduler) + +Or just type a message to chat with your agent. +``` + +**Proposed:** +``` +👋 You're paired with EvoFlux. Just type a message to send it to your +agent — no command needed. It'll pick up your current task, or start a +new one if there isn't one yet. + +Commands: +/status — What's my agent doing right now? +/new — Set aside the current task and start a fresh one +/stop — Interrupt the agent while it's working on your last message +/actions — Run a saved workflow, open a coding project, or fire a + scheduled task +/settings — See the connected model, permissions, and notification prefs +/unpair — Disconnect this phone from EvoFlux + +Send /help any time to see this again. +``` + +Rationale per line: +- Leads with the free-text behavior first, since that's the primary way + to use the bot and the thing users were most confused about — it's not + buried as an afterthought at the bottom. +- `/status` reframed as a question a person would actually ask, not a + description of a feature. +- `/new`/`/stop` each state the before/after in plain terms instead of + repeating the command's own name back at the reader. +- `/actions` replaced with concrete examples of what's behind it, instead + of "more actions" (which is an empty label — more than what?). +- `/settings` line added pre-emptively for when Task 7 ships, so this + rewrite doesn't need to be revisited twice. Remove it now if the copy + change lands before `/settings` does. + +### 2. Pairing confirmation + +**Current:** +``` +Connected to EvoFlux on {label}. +``` + +**Proposed:** +``` +✅ Paired! This phone ("{label}") is now connected to EvoFlux. + +Type anything to start working with your agent, or send /help to see +what else you can do. +``` + +Rationale: the current line reads like a debug log (no punctuation +rhythm, no next step). The rewrite confirms success in plain language and +immediately tells the reader what to do next, since pairing is the very +first thing a new user sees. + +### 3. Telegram's native "/" menu descriptions + +These show up in Telegram's own command-picker UI (tap "/" in the message +box) — separate from `/help`'s text, and space-constrained (Telegram +truncates around 256 chars, but the picker UI itself is only comfortable +with much shorter one-liners). + +**Current** (`adapter.py:324-330`): +```python +("help", "Show available commands"), +("status", "Show connection and current task status"), +("new", "Start a new task"), +("stop", "Stop the current running task"), +("unpair", "Unpair this phone from EvoFlux"), +("actions", "Show more actions (Workflows, Projects, Scheduler)"), +``` + +**Proposed:** +```python +("help", "What can I do here?"), +("status", "What's my agent doing right now?"), +("new", "Set aside this task, start a new one"), +("stop", "Interrupt the agent mid-task"), +("actions", "Run a workflow, project, or schedule"), +("unpair", "Disconnect this phone"), +``` + +(`/settings` intentionally omitted from this list until Task 7 actually +ships it — Telegram's menu should never advertise a command that doesn't +work yet.) + +## Out of scope / open question for a follow-up + +The user also asked "what does type to chat with agent mean... give me an +actual command." The copy above answers this by explaining free-text +inline rather than introducing a command — free-text-first is the +existing, intentional design (confirmed by both accepted specs), and +adding e.g. `/chat ` as a required wrapper would be a behavior +change, not a copy fix, and would fight the "guided picker" work already +planned in Task 8. If clearer wording still isn't enough in practice, +that's worth its own brainstorming pass — flagging it here rather than +deciding it unilaterally. + +## Verification + +Since this is copy-only: no new tests needed beyond confirming +`_HELP_TEXT`'s existing byte-length assumptions still hold (Telegram +messages are HTML-escaped and length-limited; nothing here approaches +that limit). Manual read-through in a Telegram client to confirm line +wrapping looks right on a phone screen would be the only real check. diff --git a/documents/plans/remote-telegram-insight-implementation.md b/documents/plans/remote-telegram-insight-implementation.md new file mode 100644 index 00000000..658efcb0 --- /dev/null +++ b/documents/plans/remote-telegram-insight-implementation.md @@ -0,0 +1,829 @@ +# Remote Telegram: Health and Changes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two read-only commands — `/health` (system diagnostics) and +`/changes` (the active session's changed files, with per-file diff +drill-down) — Phase 3 of the control-surface spec, covering AC-51 and +AC-52. + +**Architecture:** `app/remote/control.py` gains two read functions. +`get_health_diagnostics` calls `app.api.routes.health.health_diagnostics` +directly — a second, documented exception to the "service layer only" +rule (like `get_registry` before it): all check logic lives inline in +that route with no service-layer equivalent, and duplicating ~250 lines +of db/provider/team/MCP/disk checks would be far worse than calling the +one already-correct function with an explicit session. `get_file_diff` +calls `app.api.routes.team.git.get_diff_view` directly for the same +reason — it already carries the security-sensitive path-traversal and +staged/unstaged/untracked detection logic; reimplementing that in +`app/remote/` risks subtly reintroducing a path-traversal bug. The +file-list side of `/changes` needs no such exception: +`app.services.turn_changes.get_latest` is a genuine, already-correct +service-layer function. `formatting.py` gains two card builders. +`actions.py` gains `/health` and `/changes`, plus a new `changes_diff` +capability action kind — the *first* drill-down capability in this +codebase that fetches its content fresh at tap time rather than reading +a pre-stored string, since a git diff must reflect the file's state now, +not its state when the turn finished. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel, asyncio, pytest, pytest-asyncio. + +**Spec:** [`remote-telegram-control-surface.md`](remote-telegram-control-surface.md) — this plan implements exactly AC-51 and AC-52. AC-53 (providers read-only), AC-54 (onboarding), AC-55–57 (live activity mode), and AC-58 (final command-set) are later phases. + +## Global Constraints + +- Every check/diff value rendered passes through the same redact-then-escape + pipeline as every other outbound card (`_redact_text` then HTML escaping) + — a diff can contain arbitrary file content, the least bounded text this + feature has rendered yet. +- The two route-module imports (`health_diagnostics`, `get_diff_view`) are + called with explicit arguments, never relying on their `Depends(...)` + defaults — those defaults are FastAPI dependency-injection sentinels, + not usable values, when called outside a request. +- `/changes`'s file-list button cap and drill-down TTL match existing + precedent elsewhere in this module (5-8 item menu caps, + `_CAPABILITY_TTL_SECONDS` for token expiry) — no new constants invented + where an existing one already fits. +- Every task cites its spec ACs, writes a failing test before implementation, + and leaves the test suite green at its checkpoint. +- Commit at the end of each task. + +--- + +## File and interface map + +Changed units: + +- `app/remote/control.py` — adds `get_health_diagnostics`, `get_file_diff`. +- `app/remote/formatting.py` — adds `render_health_card`, `render_changes_card`. +- `app/remote/actions.py` — `_SLASH_COMMANDS` gains `"health"`, `"changes"`; + new `_cmd_health`, `_cmd_changes`, `_exec_changes_diff`; `_execute_action` + gains a `changes_diff` branch. +- `tests/remote/test_control.py`, `tests/remote/test_formatting.py`, + `tests/remote/test_actions.py` — new focused evidence. + +--- + +### Task 1: `get_health_diagnostics` and `get_file_diff` + +**ACs:** AC-51, AC-52 (data half) + +**Files:** + +- Modify: `app/remote/control.py` +- Modify: `tests/remote/test_control.py` + +**Interfaces:** + +- Produces: `async def get_health_diagnostics() -> dict`, + `async def get_file_diff(workspace: str, path: str) -> str`. +- Consumes: `app.api.routes.health.health_diagnostics`, + `app.api.routes.team.git.get_diff_view`, `app.core.db.async_session_factory`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_control.py (add) +@pytest.mark.asyncio +async def test_get_health_diagnostics_returns_checks_and_summary() -> None: + result = await control.get_health_diagnostics() + + assert "checks" in result + assert "summary" in result + assert result["summary"] in ("ok", "warn", "fail") + assert isinstance(result["checks"], list) + if result["checks"]: + first = result["checks"][0] + assert set(first.keys()) >= {"id", "label", "status", "detail"} + + +@pytest.mark.asyncio +async def test_get_file_diff_returns_empty_for_a_non_git_workspace( + tmp_path: "Path", +) -> None: + diff = await control.get_file_diff(str(tmp_path), "nonexistent.py") + + assert diff == "" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py -k "health_diagnostics or file_diff" +``` + +Expected: FAIL with `AttributeError: module 'app.remote.control' has no attribute 'get_health_diagnostics'`. + +- [ ] **Step 3: Implement both functions** + +```python +# app/remote/control.py — add to __all__: "get_health_diagnostics", "get_file_diff" +``` + +```python +# app/remote/control.py — new functions +async def get_health_diagnostics() -> dict: + """The same active health check the desktop UI's Diagnostics screen + uses, called directly rather than duplicated — see this module's + docstring for why app/remote/ makes an exception to "service layer + only" for this one function. Uses read_session_factory (not + async_session_factory) since this mirrors a GET route — app.core.db's + own get_session dependency picks the same read lane for GET requests.""" + from app.api.routes.health import health_diagnostics + from app.core.db import read_session_factory + + async with read_session_factory() as db: + return await health_diagnostics(session=db) + + +async def get_file_diff(workspace: str, path: str) -> str: + """One file's unified diff (staged, unstaged, or untracked-as-additions) + — delegates to the same route function the desktop UI's diff viewer + uses, which already carries the path-traversal and staged/unstaged/ + untracked detection logic; reimplementing that here would risk + subtly reintroducing a path-traversal bug.""" + from app.api.routes.team.git import get_diff_view + + result = await get_diff_view(workspace=workspace, path=path) + return result.get("diff", "") +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py +``` + +Expected: PASS (14 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/control.py tests/remote/test_control.py +uv run ty check app/remote/control.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/control.py tests/remote/test_control.py +git commit -m "feat(remote): add health diagnostics and file-diff reads" +``` + +--- + +### Task 2: Health and changes card rendering + +**ACs:** AC-51, AC-52 (display half) + +**Files:** + +- Modify: `app/remote/formatting.py` +- Modify: `tests/remote/test_formatting.py` + +**Interfaces:** + +- Produces: `render_health_card(checks: Sequence[Mapping[str, object]]) -> str`, + `render_changes_card(*, title: str, files: Sequence[tuple[str, str, int | None, int | None]], additions: int, deletions: int, file_tokens: Mapping[str, str]) -> tuple[str, tuple[RemoteButton, ...]]`. + +`render_health_card` returns a bare `str` (no buttons — health is purely +informational, nothing on it is tappable). `files` entries are +`(path, status, additions, deletions)` tuples, matching +`turn_changes.ChangedFile`'s fields in call order without importing that +dataclass into `formatting.py` (this module renders plain values, never +domain objects, matching every other builder here). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_formatting.py (add) +_HEALTH_ICON = {"ok": "✅", "warn": "⚠️", "fail": "❌"} + + +def test_render_health_card_shows_each_check_with_its_status_icon(): + text = formatting.render_health_card( + [ + {"id": "db", "label": "Database", "status": "ok", "detail": "connected"}, + { + "id": "disk", + "label": "Disk space", + "status": "fail", + "detail": "2.1 GB free", + }, + ] + ) + assert "Database" in text + assert "Disk space" in text + assert "✅" in text # ok icon + assert "❌" in text # fail icon + + +def test_render_health_card_escapes_detail_text(): + text = formatting.render_health_card( + [ + { + "id": "x", + "label": "X", + "status": "warn", + "detail": "", + } + ] + ) + assert "" not in text + assert "<script>" in text + + +def test_render_changes_card_lists_files_with_line_counts_and_buttons(): + text, buttons = formatting.render_changes_card( + title="Fix auth tests", + files=[ + ("app/auth.py", "modified", 10, 2), + ("tests/test_auth.py", "added", 5, 0), + ], + additions=15, + deletions=2, + file_tokens={"app/auth.py": "tok-1", "tests/test_auth.py": "tok-2"}, + ) + assert "app/auth.py" in text + assert "+15" in text + assert "-2" in text + assert {b.token for b in buttons} == {"tok-1", "tok-2"} + + +def test_render_changes_card_escapes_file_paths(): + text, _ = formatting.render_changes_card( + title="Task", + files=[("", elapsed_seconds=1.0, activity_lines=[] + ) + assert "" not in text + assert "<script>" in text + + +def test_render_live_status_card_with_no_activity_yet_still_renders() -> None: + text, buttons = formatting.render_live_status_card( + title="New task", elapsed_seconds=0.5, activity_lines=[] + ) + assert "New task" in text + assert buttons == () +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest --no-cov -q tests/remote/test_formatting.py -k live_status_card -v` +Expected: FAIL with `AttributeError: module 'app.remote.formatting' has no attribute 'render_live_status_card'`. + +- [ ] **Step 3: Write the implementation** + +Add to `app/remote/formatting.py`, near `render_status_card`, and add `"render_live_status_card"` to `__all__`: + +```python +def render_live_status_card( + *, title: str, elapsed_seconds: float, activity_lines: Sequence[str] +) -> tuple[str, tuple[RemoteButton, ...]]: + """The single status card a live-mode turn edits in place (AC-56). No + buttons — like ``render_status_card``, this card is never actionable; + when the turn ends this same message becomes the done/error card.""" + header = f"\U0001f527 {escape(title)} · {format_elapsed(elapsed_seconds)}" + if not activity_lines: + return header, () + return header + "\n\n" + "\n".join(activity_lines), () +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest --no-cov -q tests/remote/test_formatting.py -v` +Expected: PASS, all tests in the file green (no regressions). + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py && uv run ty check app/remote/formatting.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): add render_live_status_card for live-mode turns (AC-56)" +``` + +--- + +### Task 4: Thread `response_mode` from the pairing into `begin_phone_turn` + +**Files:** +- Modify: `app/remote/inbound.py` +- Modify: `app/remote/runtime.py` +- Test: `tests/remote/test_inbound.py` + +**Interfaces:** +- Consumes: `RemotePairing.response_mode` (already persisted, `app/models/remote.py`). +- Produces: `RemoteInboundResult.response_mode: str = "summary"` (new field, default preserves every existing caller); `runtime.py`'s `_handle_text` passes `response_mode=result.response_mode` into `begin_phone_turn` (Task 5 adds that parameter). + +- [ ] **Step 1: Write the failing tests** + +`tests/remote/test_inbound.py` already has a `_paired_text_action(db, text=...)` module-level helper (creates a `RemoteConnection` + `RemotePairing` + a matching `RemoteInboundAction`, returns `(pairing, action)`) and an established monkeypatch pattern for stubbing `resolve_team_for_session`/`submit_persisted_interactive_message` — see `test_handle_text_creates_and_selects_a_top_level_work_task` near the top of the file. Add these two tests reusing that exact pattern: + +```python +@pytest.mark.asyncio +async def test_handle_text_result_defaults_to_summary_response_mode(monkeypatch): + from app.remote.inbound import RemoteInboundService + import app.remote.inbound as inbound + + async with db_module.async_session_factory() as db: + pairing, action = await _paired_text_action(db) + team = SimpleNamespace() + + async def resolve(db, session_id: str, *, require_existing: bool): + session = await db.get(ChatSession, UUID(session_id)) + return session, team + + submit = AsyncMock( + side_effect=lambda db, *, session, **_kwargs: InteractiveMessageResult( + status="accepted", session_id=str(session.id), message_id=None + ) + ) + monkeypatch.setattr(inbound, "resolve_team_for_session", resolve) + monkeypatch.setattr(inbound, "submit_persisted_interactive_message", submit) + + result = await RemoteInboundService().handle_text(db, action) + + assert result.response_mode == "summary" + + +@pytest.mark.asyncio +async def test_handle_text_result_carries_a_live_response_mode(monkeypatch): + from app.remote.inbound import RemoteInboundService + import app.remote.inbound as inbound + + async with db_module.async_session_factory() as db: + pairing, action = await _paired_text_action(db) + pairing.response_mode = "live" + db.add(pairing) + await db.commit() + team = SimpleNamespace() + + async def resolve(db, session_id: str, *, require_existing: bool): + session = await db.get(ChatSession, UUID(session_id)) + return session, team + + submit = AsyncMock( + side_effect=lambda db, *, session, **_kwargs: InteractiveMessageResult( + status="accepted", session_id=str(session.id), message_id=None + ) + ) + monkeypatch.setattr(inbound, "resolve_team_for_session", resolve) + monkeypatch.setattr(inbound, "submit_persisted_interactive_message", submit) + + result = await RemoteInboundService().handle_text(db, action) + + assert result.response_mode == "live" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest --no-cov -q tests/remote/test_inbound.py -k response_mode -v` +Expected: FAIL with `AttributeError: 'RemoteInboundResult' object has no attribute 'response_mode'`. + +- [ ] **Step 3: Implement** + +In `app/remote/inbound.py`, extend the dataclass and its one success-path construction: + +```python +@dataclass(frozen=True) +class RemoteInboundResult: + """A bounded, adapter-neutral outcome for one remote action.""" + + status: str + session_id: UUID | None = None + message_id: UUID | None = None + response_mode: str = "summary" +``` + +and in `handle_text`'s final `return`: + +```python + return RemoteInboundResult( + status=result.status, + session_id=UUID(result.session_id), + message_id=result.message_id, + response_mode=pairing.response_mode, + ) +``` + +(`pairing` is already in scope — it was re-fetched at the top of the `async with await self._lock_for(...)` block.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest --no-cov -q tests/remote/test_inbound.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Wire runtime.py (no new test — covered end-to-end by Task 5's outbound tests)** + +In `app/remote/runtime.py`'s `_handle_text`, extend the `begin_phone_turn` call: + +```python + self._projection.begin_phone_turn( + str(result.session_id), + connection_id=str(action.connection_id), + destination_id=action.principal.destination_id, + principal_id=action.principal.principal_id, + title=( + session_row.title + if session_row and session_row.title + else "New task" + ), + status=result.status, + response_mode=result.response_mode, + ) +``` + +- [ ] **Step 6: Lint and type-check** + +Run: `uv run ruff check app/remote/inbound.py app/remote/runtime.py tests/remote/test_inbound.py && uv run ty check app/remote/inbound.py app/remote/runtime.py` +Expected: both clean. (`ty check` on `runtime.py` will fail until Task 5 adds `begin_phone_turn`'s `response_mode` parameter — if this task is executed before Task 5, run this step again after Task 5 instead of failing the branch here.) + +- [ ] **Step 7: Commit** + +```bash +git add app/remote/inbound.py app/remote/runtime.py tests/remote/test_inbound.py +git commit -m "feat(remote): thread response_mode from the pairing into each new turn (AC-55/56)" +``` + +--- + +### Task 5: Wire live-mode observation and throttled edits into `app/remote/outbound.py` + +This is the integration task — the largest in this plan. It depends on Tasks 1, 2, 3, and 4. + +**Files:** +- Modify: `app/remote/outbound.py` +- Test: `tests/remote/test_outbound.py` + +**Interfaces:** +- Consumes: `LiveActivityWindow` (Task 1), `EditBudget` (Task 2), `render_live_status_card` (Task 3), `begin_phone_turn(..., response_mode: str = "summary")` (Task 4's call site). +- Produces: `_TurnDeliveryState.activity: LiveActivityWindow`, `_TurnDeliveryState.response_mode: str`; `RemoteProjection._edit_budget: EditBudget`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/remote/test_outbound.py` (reusing this file's existing `FakeAdapter`, `_envelope` helper, and `begin_phone_turn`-based turn setup patterns — read the file's existing live-turn-adjacent tests first, e.g. around `_handle_gate`/`_finalize_turn`, before writing these): + +```python +class TestLiveMode: + @pytest.mark.asyncio + async def test_summary_mode_never_renders_activity_even_when_tool_events_flow( + self, + ) -> None: + """AC-20 regression guard: response_mode="summary" (the default) + must behave exactly as it did before this feature existed.""" + adapter = FakeAdapter() + projection = RemoteProjection() + projection.set_adapter(adapter) + session_id = str(uuid4()) + connection_id = str(uuid4()) # must be a real UUID string — _enqueue_edit/ + # _enqueue_send both do UUID(connection_id) before handing a message to the + # adapter, matching every other test in this file (e.g. test_done_edits_... + # above uses connection_id = str(uuid4()) for the same reason). + projection.register_session( + session_id, connection_id=connection_id, destination_id="chat-1" + ) + projection.begin_phone_turn( + session_id, + connection_id=connection_id, + destination_id="chat-1", + principal_id="user-1", + title="Task", + status="accepted", + ) + await projection.drain_pending() + edits_before = len(adapter.edited) + + projection.observe( + session_id, + _envelope( + "tool_start", + agent="explorer", + tool_call_id="call-1", + name="grep", + arguments="{}", + ), + ) + await projection.drain_pending() + + assert len(adapter.edited) == edits_before # no live edit was sent + + @pytest.mark.asyncio + async def test_live_mode_edits_the_status_card_with_activity(self) -> None: + adapter = FakeAdapter() + projection = RemoteProjection() + projection.set_adapter(adapter) + session_id = str(uuid4()) + connection_id = str(uuid4()) + projection.register_session( + session_id, connection_id=connection_id, destination_id="chat-1" + ) + projection.begin_phone_turn( + session_id, + connection_id=connection_id, + destination_id="chat-1", + principal_id="user-1", + title="Task", + status="accepted", + response_mode="live", + ) + await projection.drain_pending() + + projection.observe( + session_id, + _envelope( + "tool_start", + agent="explorer", + tool_call_id="call-1", + name="grep", + arguments='{"pattern": "def test_auth"}', + ), + ) + await projection.drain_pending() + + assert len(adapter.edited) == 1 + assert "grep" in adapter.edited[-1].text + assert "def test_auth" in adapter.edited[-1].text + + @pytest.mark.asyncio + async def test_live_mode_throttles_a_second_edit_within_the_interval(self) -> None: + adapter = FakeAdapter() + projection = RemoteProjection() + projection.set_adapter(adapter) + session_id = str(uuid4()) + connection_id = str(uuid4()) + projection.register_session( + session_id, connection_id=connection_id, destination_id="chat-1" + ) + projection.begin_phone_turn( + session_id, + connection_id=connection_id, + destination_id="chat-1", + principal_id="user-1", + title="Task", + status="accepted", + response_mode="live", + ) + await projection.drain_pending() + + projection.observe( + session_id, + _envelope( + "tool_start", agent="explorer", tool_call_id="call-1", name="grep", + arguments="{}", + ), + ) + await projection.drain_pending() + projection.observe( + session_id, + _envelope( + "tool_start", agent="explorer", tool_call_id="call-2", name="read", + arguments="{}", + ), + ) + await projection.drain_pending() + + # Both tool_start events fire well within LIVE_EDIT_INTERVAL of each + # other in real wall-clock terms (this test runs in milliseconds), + # so only the first produced an edit. + assert len(adapter.edited) == 1 + + @pytest.mark.asyncio + async def test_final_card_is_not_starved_by_the_live_edit_budget(self) -> None: + """A turn's final done card must always be delivered even if the + edit budget is currently exhausted from live-activity updates.""" + adapter = FakeAdapter() + projection = RemoteProjection() + projection.set_adapter(adapter) + session_id = str(uuid4()) + connection_id = str(uuid4()) + projection.register_session( + session_id, connection_id=connection_id, destination_id="chat-1" + ) + projection.begin_phone_turn( + session_id, + connection_id=connection_id, + destination_id="chat-1", + principal_id="user-1", + title="Task", + status="accepted", + response_mode="live", + ) + await projection.drain_pending() + + projection.observe( + session_id, + _envelope( + "tool_start", agent="explorer", tool_call_id="call-1", name="grep", + arguments="{}", + ), + ) + await projection.drain_pending() + edits_after_activity = len(adapter.edited) + + projection.observe(session_id, _envelope("done")) + await projection.drain_pending() + + assert len(adapter.edited) == edits_after_activity + 1 # the done card landed +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest --no-cov -q tests/remote/test_outbound.py -k TestLiveMode -v` +Expected: FAIL — `begin_phone_turn() got an unexpected keyword argument 'response_mode'`. + +- [ ] **Step 3: Implement** + +In `app/remote/outbound.py`: + +1. Add imports: + +```python +from app.remote.edit_budget import EditBudget +from app.remote.formatting import ( + render_done_card, + render_error_card, + render_live_status_card, + render_status_card, +) +from app.remote.live_activity import LiveActivityWindow +``` + +2. Add the activity event set and extend the observed set: + +```python +#: Live-mode-only events (AC-56) — only handled for a turn whose cached +#: response_mode is "live"; the check happens in ``observe`` itself +#: (in-memory, synchronous — no I/O), never before it, since ``observe`` +#: has no other reason to look at a turn's mode. +_ACTIVITY_EVENT_TYPES = frozenset({"tool_call", "tool_start", "tool_end", "thinking"}) + +_OBSERVED_EVENT_TYPES = frozenset( + { + "done", + "error", + "permission_asked", + "question_asked", + "plan_approval_requested", + "permission_replied", + "question_replied", + "plan_approval_replied", + } +) | _ACTIVITY_EVENT_TYPES +``` + +3. Extend `_TurnDeliveryState`: + +```python + #: "summary" (default) or "live" — read fresh from the pairing at the + #: start of every phone-admitted turn (never cached across turns), so + #: AC-55's "no restart needed" holds. Desktop-started turns (no + #: begin_phone_turn call) stay at the default; only a phone-admitted + #: turn can be live, since only it owns an editable status card. + response_mode: str = "summary" + #: The rolling activity window this turn renders into while live — + #: unused and empty in summary mode. + activity: LiveActivityWindow = field(default_factory=LiveActivityWindow) +``` + +4. Add `_edit_budget` to `RemoteProjection`: + +```python + #: Shared across every live turn on this connection (AC-57) — see + #: app/remote/edit_budget.py's own docstring for why the throttle is + #: connection-scoped rather than per-turn. + _edit_budget: EditBudget = field(default_factory=EditBudget, repr=False) +``` + +5. Extend `begin_phone_turn`'s signature and construction (add the parameter, thread it into `_TurnDeliveryState(...)`): + +```python + def begin_phone_turn( + self, + session_id: str, + *, + connection_id: str, + destination_id: str, + principal_id: str, + title: str, + status: str, + response_mode: str = "summary", + ) -> None: +``` + +```python + turn = _TurnDeliveryState( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + lifecycle_correlation_id=correlation_id, + phone_admitted=True, + title=title, + response_mode=response_mode, + ) +``` + +6. In `observe`, after the existing `turn = self._turns.get(session_id)` / fallback-turn block and before the `if event_type == "done":` chain, add the activity branch: + +```python + if event_type in _ACTIVITY_EVENT_TYPES: + self._handle_activity(turn, event_type, envelope) + return +``` + +7. Add the two new methods (near `_handle_gate`): + +```python + def _handle_activity( + self, turn: _TurnDeliveryState, event_type: str, envelope + ) -> None: + """Feed one tool/thinking event into *turn*'s activity window and, + if this is a live phone-admitted turn, enqueue a throttled edit. + + A turn whose final card has already been queued + (``completion_sent``) ignores further activity — the card is + about to be overwritten by the done/error card regardless.""" + if ( + not turn.phone_admitted + or turn.response_mode != "live" + or turn.completion_sent + ): + return + + data = envelope.data + name = data.get("name", "") + tool_call_id = data.get("tool_call_id") + if event_type == "tool_call": + turn.activity.observe_tool_call(tool_call_id=tool_call_id, name=name) + elif event_type == "tool_start": + turn.activity.observe_tool_start( + tool_call_id=tool_call_id, name=name, arguments=data.get("arguments") + ) + elif event_type == "tool_end": + turn.activity.observe_tool_end(tool_call_id=tool_call_id, name=name) + elif event_type == "thinking": + turn.activity.observe_thinking(agent=data.get("agent", "")) + + self._maybe_schedule_live_edit(turn) + + def _maybe_schedule_live_edit(self, turn: _TurnDeliveryState) -> None: + correlation_id = turn.lifecycle_correlation_id + if correlation_id is None: + return + text, buttons = render_live_status_card( + title=turn.title, + elapsed_seconds=time.monotonic() - turn.started_at, + activity_lines=turn.activity.lines(), + ) + if not self._edit_budget.should_edit( + connection_id=turn.connection_id, key=correlation_id, text=text + ): + return + self._edit_budget.record_edit( + connection_id=turn.connection_id, key=correlation_id, text=text + ) + self._enqueue_edit( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + correlation_id=correlation_id, + ) +``` + +8. Discard the budget's per-turn bookkeeping wherever a turn's state is torn down — `clear_turn` and `_finalize_turn`: + +```python + def clear_turn(self, session_id: str) -> None: + """Clear delivery state for a completed turn.""" + turn = self._turns.pop(session_id, None) + if turn is not None: + self._stop_typing(turn) + if turn.lifecycle_correlation_id is not None: + self._edit_budget.discard(turn.lifecycle_correlation_id) +``` + +In `_finalize_turn`, right after `self._stop_typing(turn)`: + +```python + self._stop_typing(turn) + if turn.lifecycle_correlation_id is not None: + self._edit_budget.discard(turn.lifecycle_correlation_id) +``` + +9. Honor 429s in the edit-drain loop inside `drain_pending` — replace: + +```python + while self._pending_edits: + msg = self._pending_edits.pop(0) + try: + await adapter.edit(msg) + except Exception as exc: + logger.warning( + "remote_outbound_edit_failed destination_id={} error={}", + msg.destination_id, + exc, + ) +``` + +with: + +```python + while self._pending_edits: + msg = self._pending_edits.pop(0) + try: + await adapter.edit(msg) + except Exception as exc: + # A 429 carries retry_after on Telegram's own exception + # type; duck-typed here rather than importing + # TelegramApiError, which would tie this + # adapter-neutral module to one adapter's transport. + error_code = getattr(exc, "error_code", None) + retry_after = getattr(exc, "retry_after", None) + if error_code == 429 and retry_after is not None: + self._edit_budget.note_rate_limited( + connection_id=str(msg.connection_id), + retry_after=float(retry_after), + ) + logger.warning( + "remote_outbound_edit_failed destination_id={} error={}", + msg.destination_id, + exc, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest --no-cov -q tests/remote/test_outbound.py -v` +Expected: PASS, all tests in the file green (no regressions in the ~50+ pre-existing tests). + +- [ ] **Step 5: Run the full remote suite, lint, and type-check** + +Run: `uv run pytest --no-cov -q tests/remote/ && uv run ruff check app/remote/ tests/remote/ && uv run ty check app/remote/` +Expected: all clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/outbound.py tests/remote/test_outbound.py +git commit -m "feat(remote): wire live-mode activity observation and throttled edits (AC-56/57)" +``` + +--- + +### Task 6: AC-58 — close the command-menu gap (`/settings`, `/health`, `/changes`) + +`_register_commands` in `app/remote/telegram/adapter.py` currently advertises `help`, `status`, `new`, `stop`, `actions`, `unpair` — missing three commands `actions.py` has dispatched since Phases 2 and 3 of the control-surface work. This is the one remaining gap in AC-58's bounded command surface. + +**Files:** +- Modify: `app/remote/telegram/adapter.py` +- Test: `tests/remote/telegram/test_adapter.py` + +**Interfaces:** +- Consumes: nothing new — `_register_commands` already calls `self._client.set_commands(commands)`. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/remote/telegram/test_adapter.py`, inside (or alongside) `class TestStartupSequence`: + +```python + @pytest.mark.asyncio + async def test_registers_exactly_the_ac58_bounded_command_set(self): + transport = ScriptedTransport() + adapter = _make_adapter(transport) + await _run_briefly(adapter) + + _, payload = next(c for c in transport.calls if c[0] == "setMyCommands") + registered = [cmd["command"] for cmd in payload["commands"]] + + assert registered == [ + "help", + "status", + "new", + "stop", + "settings", + "health", + "changes", + "actions", + "unpair", + ] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest --no-cov -q tests/remote/telegram/test_adapter.py -k ac58 -v` +Expected: FAIL — `registered` is missing `"settings"`, `"health"`, `"changes"`. + +- [ ] **Step 3: Implement** + +In `app/remote/telegram/adapter.py`'s `_register_commands`: + +```python + commands = [ + ("help", "What can I do here?"), + ("status", "What's my agent doing right now?"), + ("new", "Set aside this task, start a new one"), + ("stop", "Interrupt the agent mid-task"), + ("settings", "Change mode, model, agent, or response style"), + ("health", "Check system health"), + ("changes", "See this task's file changes"), + ("actions", "Run a workflow, project, or schedule"), + ("unpair", "Disconnect this phone"), + ] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest --no-cov -q tests/remote/telegram/test_adapter.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/telegram/adapter.py tests/remote/telegram/test_adapter.py && uv run ty check app/remote/telegram/adapter.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/telegram/adapter.py tests/remote/telegram/test_adapter.py +git commit -m "feat(remote): advertise settings/health/changes in the command menu (AC-58)" +``` + +--- + +## Final verification (after all six tasks) + +- [ ] Run the full test suite: `uv run pytest --no-cov -q` +- [ ] Run `uv run ruff check .` +- [ ] Run `uv run ty check app/` +- [ ] Merge to local `main` following this session's established pattern: check `git status --short` on the main checkout first (never overwrite unreviewed WIP), fast-forward merge, re-run the full suite on the merged result, no push. + +## Out of scope for this plan + +- **AC-53** (providers read-only listing) — this is a new, unbuilt capability (there is no provider-listing action or `/settings` "Providers" row yet), not an audit of existing code. Left for a dedicated follow-up. +- **Phase 6 — onboarding** (AC-54's richer first-run card with Set up/Health check/Just start working buttons) — unrelated to live mode; left for its own plan. diff --git a/documents/plans/remote-telegram-onboarding-implementation.md b/documents/plans/remote-telegram-onboarding-implementation.md new file mode 100644 index 00000000..9544df72 --- /dev/null +++ b/documents/plans/remote-telegram-onboarding-implementation.md @@ -0,0 +1,469 @@ +# Remote Telegram Onboarding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement AC-54 — a successful pairing sends a card naming the current permission mode and offering setup, health check, and start-working actions, instead of today's plain confirmation text with no way forward. + +**Architecture:** A new `render_onboarding_card` builder in `formatting.py`, three new capability action kinds in `actions.py` (`onboarding_setup`, `onboarding_health`, `onboarding_start`) that delegate to the existing `_cmd_settings`/`_cmd_health` implementations (no new business logic — onboarding just gives the phone a shortcut into commands it already has), and `runtime.py`'s `_handle_pairing` sends the new card instead of the old plain-text confirmation. A rejected pairing still sends nothing (AC-9, unchanged). + +**Tech Stack:** Python 3.12, pytest + pytest-asyncio, `ruff`, `ty`. + +**Spec:** `documents/plans/remote-telegram-control-surface.md`, AC-54. + +## Global Constraints + +- The mode line always reflects `ChatSession.model_fields["permission_mode"].default` (currently `"auto"`) — read programmatically, never hardcoded, so it can't silently drift from the model's real default. +- A rejected pairing attempt sends nothing at all (AC-9) — this plan touches only the success path. +- "Set up" and "Health check" reuse `_cmd_settings`/`_cmd_health` exactly as `/settings`/`/health` already do — no new session/db logic. At pairing time there is no active session yet, so tapping "Set up" shows the existing "no active task yet" message, same as typing `/settings` would right after pairing. + +--- + +### Task 1: `render_onboarding_card` in `app/remote/formatting.py` + +**Files:** +- Modify: `app/remote/formatting.py` +- Test: `tests/remote/test_formatting.py` + +**Interfaces:** +- Produces: `render_onboarding_card(*, label: str, default_permission_mode: str, setup_token: str, health_token: str, start_token: str) -> tuple[str, tuple[RemoteButton, ...]]`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/remote/test_formatting.py`: + +```python +def test_render_onboarding_card_names_the_phone_and_states_auto_mode() -> None: + text, buttons = formatting.render_onboarding_card( + label="My Phone", + default_permission_mode="auto", + setup_token="setup-tok", + health_token="health-tok", + start_token="start-tok", + ) + assert "My Phone" in text + assert "connected to EvoFlux" in text + assert "auto" in text + assert "won't ask" in text + assert len(buttons) == 3 + assert {b.token for b in buttons} == {"setup-tok", "health-tok", "start-tok"} + + +def test_render_onboarding_card_escapes_label() -> None: + text, _ = formatting.render_onboarding_card( + label="", + default_permission_mode="auto", + setup_token="s", + health_token="h", + start_token="w", + ) + assert "" not in text + assert "<script>" in text + + +def test_render_onboarding_card_states_non_auto_mode_without_the_auto_warning() -> None: + text, _ = formatting.render_onboarding_card( + label="My Phone", + default_permission_mode="ask", + setup_token="s", + health_token="h", + start_token="w", + ) + assert "ask" in text + assert "won't ask" not in text +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest --no-cov -q tests/remote/test_formatting.py -k onboarding -v` +Expected: FAIL with `AttributeError: module 'app.remote.formatting' has no attribute 'render_onboarding_card'`. + +- [ ] **Step 3: Implement** + +Add to `app/remote/formatting.py`'s `__all__` and body (near `render_settings_card`): + +```python +def render_onboarding_card( + *, + label: str, + default_permission_mode: str, + setup_token: str, + health_token: str, + start_token: str, +) -> tuple[str, tuple[RemoteButton, ...]]: + """The first-run card sent right after a successful pairing (AC-54) — + a starting point rather than a dead end. The mode line is stated + because it is the single most consequential default the operator + should know about at pairing time: "auto" means no approval prompts + will ever reach this phone.""" + mode_line = f"Mode is {escape(default_permission_mode)}" + if default_permission_mode == "auto": + mode_line += " — the agent won't ask before running commands." + else: + mode_line += "." + text = ( + f'✅ Paired! This phone ("{escape(label)}") is now connected ' + f"to EvoFlux.\n\n{mode_line}" + ) + buttons = ( + RemoteButton(text="⚙️ Set up", token=setup_token), + RemoteButton(text="\U0001fa7a Health check", token=health_token), + RemoteButton(text="\U0001f4ac Just start working", token=start_token), + ) + return text, buttons +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest --no-cov -q tests/remote/test_formatting.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py && uv run ty check app/remote/formatting.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): add render_onboarding_card for the first-run pairing message (AC-54)" +``` + +--- + +### Task 2: Onboarding capability tokens in `app/remote/actions.py` + +**Files:** +- Modify: `app/remote/actions.py` +- Test: `tests/remote/test_actions.py` + +**Interfaces:** +- Produces: `RemoteActionService.build_onboarding_card(action: RemoteInboundAction, *, label: str) -> tuple[str, tuple[RemoteButton, ...]]` (public — `runtime.py` calls this directly, Task 3). Three new `_execute_action` action kinds: `onboarding_setup`, `onboarding_health`, `onboarding_start`. +- Consumes: `render_onboarding_card` (Task 1), the existing `_cmd_settings`/`_cmd_health`/`_issue_token`/`_reply_text`. + +- [ ] **Step 1: Write the failing tests** + +Add a new class to `tests/remote/test_actions.py`, after `TestSettings` (or anywhere top-level — read the file's existing `_make_action`/fixture names first, matching `TestSettings`'s own patterns for a paired action): + +```python +class TestOnboarding: + @pytest.mark.asyncio + async def test_build_onboarding_card_mints_three_distinct_tokens( + self, service: RemoteActionService + ) -> None: + action = _make_action(text="/start") + + text, buttons = service.build_onboarding_card(action, label="My Phone") + + assert "My Phone" in text + assert len(buttons) == 3 + tokens = {b.token for b in buttons} + assert len(tokens) == 3 # all distinct + + @pytest.mark.asyncio + async def test_onboarding_setup_button_behaves_like_settings( + self, service: RemoteActionService, adapter: FakeAdapter + ) -> None: + conn_id = uuid4() + onboarding_action = _make_action(text="/start", connection_id=conn_id) + _text, buttons = service.build_onboarding_card(onboarding_action, label="My Phone") + setup_token = next(b.token for b in buttons if "Set up" in b.text) + + mock_pairing = MagicMock() + mock_pairing.active_session_id = None + mock_pairing.label = "My Phone" + + async with db_module.async_session_factory() as db: + with patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ): + callback_action = _make_action( + kind=RemoteInboundActionKind.CALLBACK, + callback_token=setup_token, + connection_id=conn_id, + ) + handled = await service.handle_action_callback(callback_action, db) + + assert handled is True + assert "No active task yet" in adapter.sent_messages[-1].text + + @pytest.mark.asyncio + async def test_onboarding_health_button_sends_health_diagnostics( + self, service: RemoteActionService, adapter: FakeAdapter + ) -> None: + conn_id = uuid4() + onboarding_action = _make_action(text="/start", connection_id=conn_id) + _text, buttons = service.build_onboarding_card(onboarding_action, label="My Phone") + health_token = next(b.token for b in buttons if "Health" in b.text) + + mock_pairing = MagicMock() + + async with db_module.async_session_factory() as db: + with ( + patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ), + patch( + "app.remote.control.get_health_diagnostics", + new=AsyncMock(return_value={"checks": []}), + ), + ): + callback_action = _make_action( + kind=RemoteInboundActionKind.CALLBACK, + callback_token=health_token, + connection_id=conn_id, + ) + handled = await service.handle_action_callback(callback_action, db) + + assert handled is True + assert "Health" in adapter.sent_messages[-1].text + + @pytest.mark.asyncio + async def test_onboarding_start_button_sends_a_friendly_prompt( + self, service: RemoteActionService, adapter: FakeAdapter + ) -> None: + conn_id = uuid4() + onboarding_action = _make_action(text="/start", connection_id=conn_id) + _text, buttons = service.build_onboarding_card(onboarding_action, label="My Phone") + start_token = next(b.token for b in buttons if "start" in b.text.lower()) + + async with db_module.async_session_factory() as db: + callback_action = _make_action( + kind=RemoteInboundActionKind.CALLBACK, + callback_token=start_token, + connection_id=conn_id, + ) + handled = await service.handle_action_callback(callback_action, db) + + assert handled is True + assert adapter.sent_messages # something was sent +``` + +This needs `from uuid import uuid4` (already imported at the top of the file per earlier tasks in this session) and `AsyncMock` (already added to the `unittest.mock` import line by the providers plan). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest --no-cov -q tests/remote/test_actions.py -k TestOnboarding -v` +Expected: FAIL — `AttributeError: 'RemoteActionService' object has no attribute 'build_onboarding_card'`. + +- [ ] **Step 3: Implement** + +In `app/remote/actions.py`, add a public method (near `_cmd_settings`, since it shares the same token-issuing shape): + +```python + def build_onboarding_card( + self, action: RemoteInboundAction, *, label: str + ) -> tuple[str, tuple[RemoteButton, ...]]: + """The first-run card sent right after a successful pairing + (AC-54). Unlike every other card this module builds, no session + or turn exists yet — these three tokens' session_id/action_target + are unused placeholders, the same repurposed-field pattern already + used for the response-mode tokens' pairing-id reuse.""" + from app.models.chat import ChatSession + from app.remote.formatting import render_onboarding_card + + setup_token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="onboarding_setup", + action_target="", + ) + health_token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="onboarding_health", + action_target="", + ) + start_token = self._issue_token( + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, + session_id="", + action_kind="onboarding_start", + action_target="", + ) + default_mode = ChatSession.model_fields["permission_mode"].default + return render_onboarding_card( + label=label, + default_permission_mode=default_mode, + setup_token=setup_token, + health_token=health_token, + start_token=start_token, + ) +``` + +Add the three branches to `_execute_action`'s elif chain (after `set_response_mode`, before `changes_diff`): + +```python + elif cap.action_kind == "onboarding_setup": + return await self._exec_onboarding_setup(cap, action, db) + elif cap.action_kind == "onboarding_health": + return await self._exec_onboarding_health(cap, action, db) + elif cap.action_kind == "onboarding_start": + return await self._exec_onboarding_start(cap, action, db) +``` + +Add the three handlers near `_exec_set_response_mode`: + +```python + async def _exec_onboarding_setup( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """/settings already self-sends its reply — nothing further to do + here, matching how /settings itself works when typed directly.""" + await self._cmd_settings(db, action) + return True + + async def _exec_onboarding_health( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Unlike /settings, _cmd_health does not self-send (its text is + normally sent by runtime.py's /health fallback path) — send it + explicitly here.""" + result = await self._cmd_health(db, action) + if result.text: + await self._reply_text(action.principal.destination_id, result.text) + return True + + async def _exec_onboarding_start( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + await self._reply_text( + action.principal.destination_id, + "Great — type your first message whenever you're ready.", + ) + return True +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest --no-cov -q tests/remote/test_actions.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/actions.py tests/remote/test_actions.py && uv run ty check app/remote/actions.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/actions.py tests/remote/test_actions.py +git commit -m "feat(remote): add onboarding capability tokens (setup/health/start) (AC-54)" +``` + +--- + +### Task 3: Wire the onboarding card into `runtime.py`'s pairing success path + +**Files:** +- Modify: `app/remote/runtime.py` +- Test: `tests/remote/test_runtime.py` + +**Interfaces:** +- Consumes: `RemoteActionService.build_onboarding_card` (Task 2). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/remote/test_runtime.py`, near `test_handle_pairing_consumes_a_token_minted_via_the_shared_service`: + +```python +@pytest.mark.asyncio +async def test_handle_pairing_success_offers_onboarding_buttons( + session, fake_stores, fake_adapters +) -> None: + connection = await _make_connection(session, enabled=True) + fake_stores[connection.id] = FakeCredentialStore("secret-token") + await remote_runtime.start() + link = pairing_service.issue_link(connection) + token = link.url.rsplit("start=", 1)[-1] + + action = RemoteInboundAction( + connection_id=connection.id, + kind=RemoteInboundActionKind.PAIRING_START, + principal=RemotePrincipal( + connection_id=connection.id, + principal_id="12345", + destination_id="12345", + display="Test User", + ), + source_key=f"telegram:{connection.id}:1", + pairing_token=token, + ) + + await remote_runtime._handle_pairing(action) + + assert len(fake_adapters[0].sent) == 1 + confirmation = fake_adapters[0].sent[0] + assert "auto" in confirmation.text + assert len(confirmation.buttons) == 3 + button_texts = {b.text for b in confirmation.buttons} + assert any("Set up" in t for t in button_texts) + assert any("Health" in t for t in button_texts) + assert any("start working" in t.lower() for t in button_texts) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest --no-cov -q tests/remote/test_runtime.py -k onboarding_buttons -v` +Expected: FAIL — `assert 0 == 3` (today's confirmation message carries no buttons at all). + +- [ ] **Step 3: Implement** + +In `app/remote/runtime.py`'s `_handle_pairing`, replace the plain-text confirmation send with: + +```python + # A silently-persisted pairing is indistinguishable from a + # failed one from the phone's side — confirm it with a + # starting point rather than a dead end (AC-54). Never sent on + # rejection (AC-9: a refusal reveals no connection state). + if self._adapter is not None and self._actions is not None: + text, buttons = self._actions.build_onboarding_card( + action, label=result.label + ) + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + ) + ) +``` + +This replaces the entire previous `if self._adapter is not None:` block (the one building the old plain `f'✅ Paired! ...'` string) — same guard condition plus the new `self._actions is not None` check (defensive: `_actions` is always set by the time a real `/start` deep link can be consumed, since the adapter's poll loop that delivers it only starts after `_start_locked` finishes constructing `_actions`, but the guard costs nothing and avoids a crash if that invariant is ever violated by a future refactor). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest --no-cov -q tests/remote/test_runtime.py -v` +Expected: PASS, all tests in the file green — including the pre-existing `test_handle_pairing_consumes_a_token_minted_via_the_shared_service`, which only asserts `"connected" in confirmation.text.lower()` and is unaffected by the added buttons. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/runtime.py tests/remote/test_runtime.py && uv run ty check app/remote/runtime.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/runtime.py tests/remote/test_runtime.py +git commit -m "feat(remote): send the onboarding card on a successful pairing (AC-54)" +``` + +--- + +## Final verification (after all three tasks) + +- [ ] Run the full test suite: `uv run pytest --no-cov -q` +- [ ] Run `uv run ruff check .` +- [ ] Run `uv run ty check app/` +- [ ] Merge to local `main` following this session's established pattern: check `git status --short` on the main checkout first, fast-forward merge, re-run tests on the merged result, no push. + +## Out of scope for this plan + +This closes AC-54, the last item in the original control-surface spec (AC-45 through AC-58). Any further Telegram remote-control work (e.g. a `/providers` drill-down, live-mode UI polish) is a new request, not part of that spec. diff --git a/documents/plans/remote-telegram-providers-implementation.md b/documents/plans/remote-telegram-providers-implementation.md new file mode 100644 index 00000000..e088a7b0 --- /dev/null +++ b/documents/plans/remote-telegram-providers-implementation.md @@ -0,0 +1,430 @@ +# Remote Telegram Providers (Read-Only) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement AC-53 — `/settings` shows how many providers are configured, and no remote code path can ever reach a credential-accepting provider endpoint. + +**Architecture:** One new read-only aggregation in `control.py` (`count_configured_providers`) calling the existing `GET /api/settings/providers` route function directly — the third documented, deliberate exception to "service layer only" in this module (after the model catalog and health/diff). `render_settings_card` grows an optional `configured_provider_count` line. The AC's real weight is the inspection test: a static scan proving `app/remote/` never references any of the five write-capable provider functions (`save_provider`, `save_provider_visible_models`, `test_provider`, `list_provider_models`, `delete_provider`). + +**Scope note:** The control-surface spec's mockup shows a `[ Providers ]` button opening a picker. Providers are read-only in this feature (no pick to make), so this plan implements the count line only — the load-bearing part of AC-53 per the spec's own Permissions section ("Provider read-only is enforced by inspection test rather than by convention") is the inspection test, not a richer display. A drill-down listing configured providers by name, or per-provider usage (AC references "usage"), is left for a follow-up if wanted. + +**Tech Stack:** Python 3.12, pytest + pytest-asyncio, `ruff`, `ty`. + +**Spec:** `documents/plans/remote-telegram-control-surface.md`, AC-53. + +## Global Constraints + +- No remote code path may import or call `save_provider`, `save_provider_visible_models`, `test_provider`, `list_provider_models`, or `delete_provider` (all in `app/api/routes/settings.py`) — proven by a static inspection test, not by convention. +- `count_configured_providers()` calls `app.api.routes.settings.list_providers()` directly (no `Depends`-injected state, matching the two existing departures already documented in `control.py`'s module docstring) and counts `is_configured` — it must never trigger a second call per `/settings` render beyond what that route already does internally (result caching is that route's own concern, already in place for daemon/model-discovery probes). + +--- + +### Task 1: `control.py` — `count_configured_providers` + +**Files:** +- Modify: `app/remote/control.py` +- Test: `tests/remote/test_control.py` + +**Interfaces:** +- Produces: `async def count_configured_providers() -> int`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/remote/test_control.py`: + +```python +@pytest.mark.asyncio +async def test_count_configured_providers_counts_only_configured_ones( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.api.schemas.settings import ProviderInfo, ProvidersListBody + + def _entry(id_: str, *, is_configured: bool) -> ProviderInfo: + return ProviderInfo( + id=id_, + label=id_, + description="", + kind="api_key", + is_configured=is_configured, + ) + + async def _fake_list_providers() -> ProvidersListBody: + return ProvidersListBody( + providers=[ + _entry("openai", is_configured=True), + _entry("anthropic", is_configured=True), + _entry("mistral", is_configured=False), + ], + has_any_configured=True, + ) + + monkeypatch.setattr( + "app.api.routes.settings.list_providers", _fake_list_providers + ) + + count = await control.count_configured_providers() + + assert count == 2 + + +@pytest.mark.asyncio +async def test_count_configured_providers_is_zero_with_none_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.api.schemas.settings import ProvidersListBody + + async def _fake_list_providers() -> ProvidersListBody: + return ProvidersListBody(providers=[], has_any_configured=False) + + monkeypatch.setattr( + "app.api.routes.settings.list_providers", _fake_list_providers + ) + + count = await control.count_configured_providers() + + assert count == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest --no-cov -q tests/remote/test_control.py -k configured_providers -v` +Expected: FAIL with `AttributeError: module 'app.remote.control' has no attribute 'count_configured_providers'`. + +- [ ] **Step 3: Implement** + +Add to `control.py`'s `__all__` and body: + +```python +async def count_configured_providers() -> int: + """How many catalog providers currently have usable credentials — + the one number `/settings` shows for AC-53. Calls the same route + function the desktop Settings screen uses (see this module's + docstring for why app/remote/ makes this exception) rather than + reimplementing the static-credential/daemon-reachability checks + GET /api/settings/providers already performs.""" + from app.api.routes.settings import list_providers + + result = await list_providers() + return sum(1 for provider in result.providers if provider.is_configured) +``` + +And extend the module docstring's exception list with this third departure, and add `"count_configured_providers"` to `__all__`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest --no-cov -q tests/remote/test_control.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/control.py tests/remote/test_control.py && uv run ty check app/remote/control.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/control.py tests/remote/test_control.py +git commit -m "feat(remote): count configured providers for /settings (AC-53)" +``` + +--- + +### Task 2: `render_settings_card` gains the Providers line + +**Files:** +- Modify: `app/remote/formatting.py` +- Test: `tests/remote/test_formatting.py` + +**Interfaces:** +- Produces: `render_settings_card(..., configured_provider_count: int | None = None)` — when given, adds a `"Providers"` line right after `"Responses"`; `None` omits the line entirely (same optional-section pattern already used for `redaction_policy`/`notify_scope`). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/remote/test_formatting.py`: + +```python +def test_render_settings_card_shows_configured_provider_count() -> None: + text, _ = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + configured_provider_count=3, + ) + assert "Providers" in text + assert "3" in text + + +def test_render_settings_card_omits_providers_line_when_not_supplied() -> None: + text, _ = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + ) + assert "Providers" not in text +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest --no-cov -q tests/remote/test_formatting.py -k configured_provider -v` +Expected: FAIL — `test_render_settings_card_shows_configured_provider_count` fails with `TypeError: render_settings_card() got an unexpected keyword argument 'configured_provider_count'`. + +- [ ] **Step 3: Implement** + +In `app/remote/formatting.py`'s `render_settings_card` signature, add (after `model_tokens`): + +```python + configured_provider_count: int | None = None, +``` + +and after the existing `"Responses"` line in `lines`: + +```python + if configured_provider_count is not None: + lines += [ + "", + f"Providers\n{configured_provider_count} configured", + ] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest --no-cov -q tests/remote/test_formatting.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py && uv run ty check app/remote/formatting.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): show configured provider count on the settings card (AC-53)" +``` + +--- + +### Task 3: Wire the count into `/settings` + +**Files:** +- Modify: `app/remote/actions.py` +- Test: `tests/remote/test_actions.py` + +**Interfaces:** +- Consumes: `control.count_configured_providers()` (Task 1), `render_settings_card(..., configured_provider_count=...)` (Task 2). + +- [ ] **Step 1: Write the failing test** + +Add to `TestSettings` in `tests/remote/test_actions.py`: + +```python + @pytest.mark.asyncio + async def test_settings_command_shows_configured_provider_count( + self, service: RemoteActionService, adapter: FakeAdapter + ) -> None: + async with db_module.async_session_factory() as db: + session = ChatSession( + title="Settings test", + mode="work", + session_type="main", + permission_mode="ask", + ) + db.add(session) + await db.commit() + await db.refresh(session) + + mock_pairing = MagicMock() + mock_pairing.id = uuid4() + mock_pairing.active_session_id = session.id + mock_pairing.label = "My Phone" + mock_pairing.response_mode = "summary" + + with ( + patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ), + patch( + "app.remote.control.count_configured_providers", + new=AsyncMock(return_value=2), + ), + ): + settings_action = _make_action(text="/settings") + await service.dispatch_command(db, settings_action) + + sent_text = adapter.sent_messages[-1].text + assert "2 configured" in sent_text +``` + +This needs `from unittest.mock import AsyncMock` — check the top of `tests/remote/test_actions.py`; it already imports `MagicMock, patch` from `unittest.mock`, so add `AsyncMock` to that same import line rather than a second import statement. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest --no-cov -q tests/remote/test_actions.py -k configured_provider_count -v` +Expected: FAIL — `"2 configured" not in sent_text` (the settings card is sent without a Providers line at all). + +- [ ] **Step 3: Implement** + +In `app/remote/actions.py`'s `_cmd_settings`, after the existing `model_tokens = {...}` block and before the `render_settings_card(...)` call: + +```python + configured_provider_count = await control.count_configured_providers() +``` + +and add `configured_provider_count=configured_provider_count,` as an argument to the `render_settings_card(...)` call (after `model_tokens=model_tokens,`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest --no-cov -q tests/remote/test_actions.py -v` +Expected: PASS, all tests in the file green. + +- [ ] **Step 5: Lint and type-check** + +Run: `uv run ruff check app/remote/actions.py tests/remote/test_actions.py && uv run ty check app/remote/actions.py` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/actions.py tests/remote/test_actions.py +git commit -m "feat(remote): wire the configured-provider count into /settings (AC-53)" +``` + +--- + +### Task 4: AC-53 inspection test — providers stay read-only + +**Files:** +- Create: `tests/remote/test_ac53_providers_read_only.py` + +**Interfaces:** +- Consumes: nothing new — pure static source inspection over the `app/remote/` package. + +- [ ] **Step 1: Write the failing test** + +```python +"""AC-53 inspection test: no remote code path may reach a credential- +accepting provider endpoint. + +A behavioral test could only prove the paths it thinks to exercise; a +static scan proves the *absence* of a reference anywhere in the package, +which is what AC-53 actually promises ("proven by an inspection test +over the remote dispatch surface", not by convention or by enumerating +every possible callback). + +Scans the AST rather than raw text so a docstring or comment that merely +*names* one of these functions (e.g. control.py's own module docstring, +documenting that it does NOT call them) never counts as a reference — +only an actual import, call, or attribute access does. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_REMOTE_PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "app" / "remote" + +#: Every provider endpoint in app/api/routes/settings.py that accepts or +#: mutates credentials, visibility, or existence — AC-53's "no remote +#: path reaches PUT .../providers/{id}, POST .../test, or any other +#: credential-accepting endpoint". +_WRITE_CAPABLE_PROVIDER_FUNCTIONS = ( + "save_provider", + "save_provider_visible_models", + "test_provider", + "list_provider_models", + "delete_provider", +) + + +def _remote_source_files() -> list[Path]: + assert _REMOTE_PACKAGE_ROOT.is_dir(), _REMOTE_PACKAGE_ROOT + return list(_REMOTE_PACKAGE_ROOT.rglob("*.py")) + + +def _referenced_identifiers(path: Path) -> set[str]: + """Every real Python identifier a file's code actually binds to or + reads — import targets, attribute accesses, bare names — never string + literals, docstrings, or comments (comments never reach the AST at + all; docstrings are Constant nodes, not Name/Attribute/alias).""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + identifiers: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name): + identifiers.add(node.id) + elif isinstance(node, ast.Attribute): + identifiers.add(node.attr) + elif isinstance(node, ast.alias): + identifiers.add(node.name) + return identifiers + + +def test_no_remote_source_file_references_a_write_capable_provider_function() -> None: + files = _remote_source_files() + assert len(files) > 10 # sanity: the glob actually found the package + + offenders: list[str] = [] + for path in files: + identifiers = _referenced_identifiers(path) + for name in _WRITE_CAPABLE_PROVIDER_FUNCTIONS: + if name in identifiers: + offenders.append(f"{path.relative_to(_REMOTE_PACKAGE_ROOT)}: {name}") + + assert offenders == [] + + +def test_slash_commands_do_not_include_a_provider_write_command() -> None: + from app.remote.actions import _SLASH_COMMANDS + + # A future "/providers" command must stay a read-only view — nothing in + # today's bounded command set (AC-58) implies a write, and this guards + # against one being added under a name that sounds like a listing. + assert "provider" not in {cmd.lower() for cmd in _SLASH_COMMANDS} + assert "providers" not in {cmd.lower() for cmd in _SLASH_COMMANDS} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest --no-cov -q tests/remote/test_ac53_providers_read_only.py -v` +Expected: at this point in the plan (after Tasks 1-3 land) both tests should already PASS, since nothing in this plan ever imports a write-capable function. Run it anyway to confirm — this task exists to make that guarantee explicit and permanent, not to fix a violation. If it unexpectedly fails, that is a real finding: something written in Tasks 1-3 imported a write path and must be fixed before proceeding. + +- [ ] **Step 3: No implementation step — the test is the deliverable.** + +- [ ] **Step 4: Lint and type-check** + +Run: `uv run ruff check tests/remote/test_ac53_providers_read_only.py && uv run ty check tests/remote/test_ac53_providers_read_only.py` +Expected: both clean. + +- [ ] **Step 5: Commit** + +```bash +git add tests/remote/test_ac53_providers_read_only.py +git commit -m "test(remote): add the AC-53 provider-read-only inspection test" +``` + +--- + +## Final verification (after all four tasks) + +- [ ] Run the full test suite: `uv run pytest --no-cov -q` +- [ ] Run `uv run ruff check .` +- [ ] Run `uv run ty check app/` +- [ ] Merge to local `main` following this session's established pattern: check `git status --short` on the main checkout first, fast-forward merge, re-run tests on the merged result, no push. + +## Out of scope for this plan + +- A `/providers` drill-down listing configured provider names, or per-provider usage (tokens/cost) — the spec's "usage" wording is not built here; see this plan's Scope note. +- Phase 6 — onboarding (AC-54's richer first-run card). diff --git a/documents/plans/remote-telegram-response-mode-implementation.md b/documents/plans/remote-telegram-response-mode-implementation.md new file mode 100644 index 00000000..b46dc931 --- /dev/null +++ b/documents/plans/remote-telegram-response-mode-implementation.md @@ -0,0 +1,689 @@ +# Remote Telegram: Response Mode Preference Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `RemotePairing.response_mode` (`summary` default, `live`) as a +settable preference, surfaced and toggleable from `/settings` — AC-55 of +the control-surface spec, and only AC-55. This phase adds the *preference* +with zero behavior change (default `summary` is exactly today's behavior); +it does not touch turn observation, activity rendering, or edit +throttling — that is AC-56/57, a separate, larger phase, since this +preference must exist and be persistable before anything can read it. + +**Architecture:** One migration adds the column, following the exact +two-step precedent `notify_scope` already established (add the column, +bump `SCHEMA_HEAD`). `control.py` gains one write function, +`set_response_mode`, following the exact shape of `set_permission_mode` +but operating on `RemotePairing` (looked up by pairing id, not +`ChatSession` by session id) rather than a chat session — the first +control.py write that isn't chat-session-scoped. `formatting.py`'s +`render_settings_card` gains a `response_mode`/`response_mode_tokens` row, +mirroring the existing `mode`/`mode_tokens` row exactly. `actions.py` +wires it into the existing `/settings` card and adds one new capability +action kind, `set_response_mode`, whose capability's `session_id` field +is repurposed to hold the *pairing* id rather than a chat session id — +documented explicitly, since every other action kind uses that field for +an actual chat session. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel, Alembic, asyncio, pytest, pytest-asyncio. + +**Spec:** [`remote-telegram-control-surface.md`](remote-telegram-control-surface.md) — this plan implements exactly AC-55. AC-53 (providers read-only), AC-54 (onboarding), AC-56/57 (live activity content and throttled edits — depends on this phase's `response_mode` column existing), and AC-58 (final command-set) are later phases. + +## Global Constraints + +- This phase changes no observable bot behavior for any pairing that + never touches the new toggle — `response_mode` defaults to `"summary"`, + identical to today's only behavior. +- `set_response_mode` follows `set_permission_mode`'s exact validate-then- + persist shape (an `ALLOWED_RESPONSE_MODES` tuple checked by name, a + `db.get` + mutate + commit, wrapped in `ControlResult`) for consistency + with every other control.py write, even though this one operates on + `RemotePairing` instead of `ChatSession`. +- Every task cites its spec ACs, writes a failing test before implementation, + and leaves the test suite green at its checkpoint. +- Commit at the end of each task. + +--- + +## File and interface map + +New units: + +- `app/migrations/versions/00000066_add_remote_pairing_response_mode.py`. + +Changed units: + +- `app/core/schema_version.py` — `SCHEMA_HEAD` bumped to `"00000066"`. +- `app/models/remote.py` — `RemotePairing.response_mode`. +- `app/remote/control.py` — adds `ALLOWED_RESPONSE_MODES`, `set_response_mode`. +- `app/remote/formatting.py` — `render_settings_card` gains `response_mode`, `response_mode_tokens`. +- `app/remote/actions.py` — `_cmd_settings` renders the new row/buttons; + new `_exec_set_response_mode`; `_execute_action` gains a + `set_response_mode` branch. +- `tests/models/test_remote_models.py`, `tests/remote/test_control.py`, + `tests/remote/test_formatting.py`, `tests/remote/test_actions.py` — + new focused evidence. + +--- + +### Task 1: Migration and model field + +**ACs:** AC-55 (schema half) + +**Files:** + +- Create: `app/migrations/versions/00000066_add_remote_pairing_response_mode.py` +- Modify: `app/core/schema_version.py` +- Modify: `app/models/remote.py` +- Modify: `tests/models/test_remote_models.py` + +**Interfaces:** + +- Produces: `RemotePairing.response_mode: str` (default `"summary"`). + +- [ ] **Step 1: Write the migration** + +```python +# app/migrations/versions/00000066_add_remote_pairing_response_mode.py +"""Add remote_pairings.response_mode + +Revision ID: 00000066 +Revises: 00000065 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000066" +down_revision: str | Sequence[str] | None = "00000065" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "remote_pairings", + sa.Column( + "response_mode", + sa.String(20), + nullable=False, + server_default="summary", + ), + ) + + +def downgrade() -> None: + op.drop_column("remote_pairings", "response_mode") +``` + +- [ ] **Step 2: Update the schema-head marker** + +```python +# app/core/schema_version.py — line 16 +SCHEMA_HEAD = "00000066" +``` + +- [ ] **Step 3: Write a failing model/migration test** + +This file's real fixtures are `session` and `remote_connection` (not +`db_session` — verified directly against the file, which already defines +both, following `tests/remote/test_pairing.py`'s convention since no +shared `db_session` fixture exists in this codebase). Reuse the existing +`remote_connection` fixture already in this file rather than redefining +it, and match the existing `test_notify_scope_round_trips_a_non_default_value` +test's shape for the second test below. + +```python +# tests/models/test_remote_models.py (add) +@pytest.mark.asyncio +async def test_new_pairing_defaults_response_mode_to_summary( + session, remote_connection +): + pairing = RemotePairing( + connection_id=remote_connection.id, + principal_id="user-1", + destination_id="chat-1", + label="My phone", + ) + session.add(pairing) + await session.commit() + await session.refresh(pairing) + assert pairing.response_mode == "summary" + + +@pytest.mark.asyncio +async def test_response_mode_round_trips_a_non_default_value( + session, remote_connection +): + pairing = RemotePairing( + connection_id=remote_connection.id, + principal_id="user-1", + destination_id="chat-1", + label="My phone", + response_mode="live", + ) + session.add(pairing) + await session.commit() + await session.refresh(pairing) + + from sqlmodel import select + + reloaded = ( + await session.exec(select(RemotePairing).where(RemotePairing.id == pairing.id)) + ).one() + assert reloaded.response_mode == "live" +``` + +- [ ] **Step 4: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/models/test_remote_models.py -k response_mode +``` + +Expected: FAIL — `response_mode` is not a field on `RemotePairing`. + +- [ ] **Step 5: Add the column to the model** + +```python +# app/models/remote.py — inside class RemotePairing, after notify_scope + response_mode: str = Field( + default="summary", + sa_column=Column(sa.String(20), nullable=False, server_default="summary"), + ) +``` + +- [ ] **Step 6: Run migration head and the model test** + +```powershell +uv run alembic -c app/alembic.ini upgrade head +uv run pytest --no-cov -q tests/models/test_remote_models.py +``` + +- [ ] **Step 7: Lint** + +```powershell +uv run ruff check app/models/remote.py tests/models/test_remote_models.py app/migrations/versions/00000066_add_remote_pairing_response_mode.py +uv run ty check app/models/remote.py +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/migrations/versions/00000066_add_remote_pairing_response_mode.py app/core/schema_version.py app/models/remote.py tests/models/test_remote_models.py +git commit -m "feat(remote): add remote_pairings.response_mode column" +``` + +--- + +### Task 2: `set_response_mode` + +**ACs:** AC-55 (write half) + +**Files:** + +- Modify: `app/remote/control.py` +- Modify: `tests/remote/test_control.py` + +**Interfaces:** + +- Produces: `ALLOWED_RESPONSE_MODES: tuple[str, ...]` (`"summary"`, `"live"`), + `async def set_response_mode(db: AsyncSession, pairing_id: str, mode: str) -> ControlResult`. +- Consumes: `app.models.remote.RemotePairing`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_control.py (add) +from app.models.remote import RemotePairing + + +@pytest_asyncio.fixture +async def remote_pairing() -> RemotePairing: + """A minimal, standalone pairing row — this fixture creates its own + RemoteConnection first since RemotePairing.connection_id is a + non-nullable foreign key. Required fields verified directly against + tests/models/test_remote_models.py's own remote_connection fixture.""" + async with db_module.async_session_factory() as db: + from app.models.remote import RemoteConnection + + connection = RemoteConnection( + adapter="telegram", + label="My phone", + enabled=True, + adapter_principal_id="bot-1", + adapter_username="my_evoflux_bot", + ) + db.add(connection) + await db.commit() + await db.refresh(connection) + + pairing = RemotePairing( + connection_id=connection.id, + principal_id="user-1", + destination_id="chat-1", + label="My phone", + ) + db.add(pairing) + await db.commit() + await db.refresh(pairing) + return pairing + + +@pytest.mark.asyncio +async def test_set_response_mode_persists_a_valid_mode( + remote_pairing: RemotePairing, +) -> None: + async with db_module.async_session_factory() as db: + result = await control.set_response_mode(db, str(remote_pairing.id), "live") + + assert result.status == "ok" + async with db_module.async_session_factory() as db: + refreshed = await db.get(RemotePairing, remote_pairing.id) + assert refreshed is not None + assert refreshed.response_mode == "live" + + +@pytest.mark.asyncio +async def test_set_response_mode_rejects_unknown_mode( + remote_pairing: RemotePairing, +) -> None: + async with db_module.async_session_factory() as db: + result = await control.set_response_mode( + db, str(remote_pairing.id), "verbose" + ) + + assert result.status == "invalid" + + +@pytest.mark.asyncio +async def test_set_response_mode_not_found_for_unknown_pairing() -> None: + async with db_module.async_session_factory() as db: + result = await control.set_response_mode(db, str(UUID(int=0)), "live") + + assert result.status == "not_found" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py -k response_mode +``` + +Expected: FAIL with `AttributeError: module 'app.remote.control' has no attribute 'set_response_mode'`. + +- [ ] **Step 3: Implement `set_response_mode`** + +```python +# app/remote/control.py — add to __all__: "ALLOWED_RESPONSE_MODES", "set_response_mode" +``` + +```python +# app/remote/control.py — new constant and function +#: The two response modes a phone may choose between (AC-55). Unlike +#: ALLOWED_REMOTE_MODES (permission modes, chat-session-scoped), this +#: preference lives on RemotePairing — one phone, one pairing, one +#: notion of how chatty its own turns should be. +ALLOWED_RESPONSE_MODES: tuple[str, ...] = ("summary", "live") + + +async def set_response_mode( + db: AsyncSession, pairing_id: str, mode: str +) -> ControlResult: + if mode not in ALLOWED_RESPONSE_MODES: + return ControlResult(status="invalid", detail=mode) + + from app.models.remote import RemotePairing + + try: + pairing_uuid = UUID(pairing_id) + except ValueError: + return ControlResult(status="not_found") + pairing = await db.get(RemotePairing, pairing_uuid) + if pairing is None: + return ControlResult(status="not_found") + + pairing.response_mode = mode + db.add(pairing) + await db.commit() + + return ControlResult(status="ok") +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_control.py +``` + +Expected: PASS (17 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/control.py tests/remote/test_control.py +uv run ty check app/remote/control.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/control.py tests/remote/test_control.py +git commit -m "feat(remote): add response-mode control" +``` + +--- + +### Task 3: Settings-card display and toggle + +**ACs:** AC-55 (display half) + +**Files:** + +- Modify: `app/remote/formatting.py` +- Modify: `tests/remote/test_formatting.py` + +**Interfaces:** + +- Changes `render_settings_card`: adds required `response_mode: str` and + `response_mode_tokens: Mapping[str, str]` parameters, rendered as a + `Responses` row with buttons, in the same position as the existing + `Mode` row. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/remote/test_formatting.py (add) +def test_render_settings_card_shows_response_mode_and_its_toggle(): + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + response_mode_tokens={"summary": "r1", "live": "r2"}, + ) + assert "summary" in text + button_tokens = {b.token for b in buttons} + assert {"r1", "r2"} <= button_tokens +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py -k response_mode_and_its_toggle +``` + +Expected: FAIL — `render_settings_card()` missing required argument `response_mode`. + +- [ ] **Step 3: Add the new row and buttons** + +```python +# app/remote/formatting.py — render_settings_card's signature gains, +# right after agent_name: + response_mode: str, + response_mode_tokens: Mapping[str, str], +``` + +```python +# app/remote/formatting.py — inside render_settings_card, in the `lines` +# list, right after the "Lead agent" entry: + "", + f"Responses\n{escape(response_mode)}", +``` + +```python +# app/remote/formatting.py — inside render_settings_card, in the buttons +# list, right after the mode_tokens comprehension: + buttons += [ + RemoteButton(text=f"Responses: {escape(name)}", token=token) + for name, token in response_mode_tokens.items() + ] +``` + +Every other existing test that calls `render_settings_card` (from Task 4 +of the previous plan) now breaks — it's missing the two new required +arguments. Update each existing call site in +`tests/remote/test_formatting.py` to add +`response_mode="ask"` (or any placeholder string) and +`response_mode_tokens={}`, matching how those same tests already supply +empty dicts for `agent_tokens`/`model_tokens` when a test isn't about +those buttons specifically. + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py +``` + +Expected: PASS (all tests in the file). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py +uv run ty check app/remote/formatting.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): settings card shows and toggles response mode" +``` + +--- + +### Task 4: Wire the toggle into `/settings` + +**ACs:** AC-55 (final wiring) + +**Files:** + +- Modify: `app/remote/actions.py` +- Modify: `tests/remote/test_actions.py` + +**Interfaces:** + +- Changes `_cmd_settings`: also renders the response-mode row/buttons. +- Produces (private): `_exec_set_response_mode`. +- Changes `_execute_action`: gains a `set_response_mode` branch. + +`_ActionCapability.session_id` is repurposed for this one action kind to +hold the **pairing id**, not a chat session id — every other action kind +uses it for a real `ChatSession`. This is documented at both the issuing +and consuming ends so a future reader isn't confused by the field name. + +- [ ] **Step 1: Write the failing test** + +`mock_pairing` stands in for the return of `authorize()`, so its +`active_session_id`/`label`/`response_mode` can be anything controllable — +but its `.id` must resolve against a REAL `RemotePairing` row at callback +time (`control.set_response_mode` does a real `db.get(RemotePairing, +pairing_uuid)`), so this test creates a real pairing first (same shape as +Task 2's `remote_pairing` fixture, inlined here since it needs the same +`db` session the rest of the test already uses) and points the mock's +`.id` at that real row's id rather than a bare `uuid4()`. + +```python +# tests/remote/test_actions.py — add to class TestSettings + @pytest.mark.asyncio + async def test_response_mode_callback_applies_the_selected_mode( + self, service: RemoteActionService, adapter: FakeAdapter + ) -> None: + from app.models.remote import RemoteConnection, RemotePairing + + conn_id = uuid4() + async with db_module.async_session_factory() as db: + session = ChatSession( + title="Settings test", mode="work", session_type="main", + permission_mode="auto", + ) + db.add(session) + await db.commit() + await db.refresh(session) + + connection = RemoteConnection( + adapter="telegram", + label="My phone", + enabled=True, + adapter_principal_id="bot-1", + adapter_username="my_evoflux_bot", + ) + db.add(connection) + await db.commit() + await db.refresh(connection) + + real_pairing = RemotePairing( + connection_id=connection.id, + principal_id="user-1", + destination_id="chat-1", + label="My Phone", + ) + db.add(real_pairing) + await db.commit() + await db.refresh(real_pairing) + + mock_pairing = MagicMock() + mock_pairing.id = real_pairing.id + mock_pairing.active_session_id = session.id + mock_pairing.label = "My Phone" + mock_pairing.response_mode = "summary" + + with patch.object( + service._pairing_service, "authorize", return_value=mock_pairing + ): + settings_action = _make_action(text="/settings", connection_id=conn_id) + await service.dispatch_command(db, settings_action) + + sent_buttons = adapter.sent_messages[-1].buttons + live_token = next( + b.token for b in sent_buttons if b.text == "Responses: live" + ) + + callback_action = _make_action( + kind=RemoteInboundActionKind.CALLBACK, + callback_token=live_token, + connection_id=conn_id, + ) + handled = await service.handle_action_callback(callback_action, db) + + assert handled is True + refreshed = await db.get(RemotePairing, real_pairing.id) + assert refreshed is not None + assert refreshed.response_mode == "live" +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py -k response_mode_callback +``` + +Expected: FAIL — no `"Responses: live"` button exists yet in the sent card. + +- [ ] **Step 3: Extend `_cmd_settings`** + +```python +# app/remote/actions.py — inside _cmd_settings, alongside the existing +# mode_tokens/agent_tokens/model_tokens construction. pairing.id is the +# PAIRING's own id — repurposing the capability's session_id field to +# carry it (documented in _exec_set_response_mode below) since this +# preference lives on RemotePairing, not the chat session. + response_mode_tokens = { + mode: self._issue_settings_token( + action, str(pairing.id), "set_response_mode", mode + ) + for mode in control.ALLOWED_RESPONSE_MODES + } +``` + +```python +# app/remote/actions.py — inside _cmd_settings, the render_settings_card +# call gains: + response_mode=pairing.response_mode, + response_mode_tokens=response_mode_tokens, +``` + +- [ ] **Step 4: Implement `_exec_set_response_mode` and wire the branch** + +```python +# app/remote/actions.py — inside _execute_action, alongside the other +# set_* branches + elif cap.action_kind == "set_response_mode": + return await self._exec_set_response_mode(cap, action, db) +``` + +```python +# app/remote/actions.py — new method, near _exec_set_model + async def _exec_set_response_mode( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """cap.session_id here holds a PAIRING id, not a chat session id — + this preference lives on RemotePairing, the one action kind in + this module that isn't chat-session-scoped.""" + from app.remote import control + + result = await control.set_response_mode(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Responses set to {cap.action_target}." + ) + return True +``` + +- [ ] **Step 5: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py +``` + +Expected: PASS (every test in the file, including the new response-mode-callback one). + +- [ ] **Step 6: Full remote suite, lint, type-check** + +```powershell +uv run pytest --no-cov -q tests/remote/ +uv run ruff check app/remote/ tests/remote/ +uv run ty check app/remote/ +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/remote/actions.py tests/remote/test_actions.py +git commit -m "feat(remote): wire response-mode toggle into /settings" +``` + +--- + +## Self-review notes + +- **Spec coverage:** AC-55 is fully covered across all 4 tasks (schema → + write → display → wiring). AC-56/57 (the actual live-content rendering + this preference will eventually gate) are explicitly out of scope — this + phase only adds a switch that, today, nothing reads. +- **Zero behavior change confirmed:** no code in `outbound.py`'s + `observe()` or turn lifecycle is touched by this plan at all — the + column exists and is settable, but nothing consumes it yet. A pairing + that flips to `"live"` today gets no different behavior until AC-56/57 + ships and starts reading `RemotePairing.response_mode`. +- **The `session_id`-holds-a-pairing-id repurposing is flagged in three + places** (module-level capability field, the issuing call site in + `_cmd_settings`, and the consuming method's docstring) rather than + once, since it's the one place this plan bends an existing field's + meaning and a future reader debugging a capability token needs to find + that explanation from any of the three places they'd naturally look. +- **Both fixture uncertainties flagged during drafting were resolved + before finalizing this plan, not left for the executor:** + `tests/models/test_remote_models.py`'s real fixtures are `session` and + `remote_connection` (not `db_session`), and `RemoteConnection`'s real + required constructor fields are `adapter`, `label`, `enabled`, + `adapter_principal_id`, `adapter_username` — both confirmed by reading + the file directly, and both plan code blocks above already reflect the + verified versions. diff --git a/documents/plans/remote-telegram-response-ui-implementation.md b/documents/plans/remote-telegram-response-ui-implementation.md new file mode 100644 index 00000000..26bbaa8a --- /dev/null +++ b/documents/plans/remote-telegram-response-ui-implementation.md @@ -0,0 +1,1973 @@ +# Remote Telegram Response UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the Telegram remote-access channel HTML-formatted, alive-feeling responses; complete its existing "notify me about desktop-started work too" Goal; and add a read-mostly `/settings` view plus a guided project/prompt picker — all within Tasks 7-9 of the in-progress Telegram feature, which this plan extends rather than reopens. + +**Architecture:** One new rendering module (`app/remote/formatting.py`) becomes the single place literal HTML is written; `RemoteProjection` (outbound.py) gains a per-turn status-message lifecycle (create-then-edit, using the adapter's existing correlation-id mechanism) plus a cached "active pairing" view so it can route completions for sessions it never explicitly registered; `RemoteActionService` (actions.py) gains new capability-token action kinds for `/settings`, the guided picker, and on-demand drill-down content. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel/Alembic, asyncio, httpx, pytest. + +**Spec:** [`remote-telegram-response-ui.md`](remote-telegram-response-ui.md) (amends [`remote-channel-telegram.md`](remote-channel-telegram.md)) + +## Global Constraints + +- Every outbound message uses Telegram HTML parse mode; every value not authored as a static string inside `formatting.py` passes through `html.escape` before interpolation, applied after outbound redaction, never before. +- A phone-admitted turn's status message is created once and edited at most once more (to its final done/error form); no other edit, no periodic progress text. The native Telegram typing indicator, not message edits, carries the "still working" feeling. +- Running-state/status text contains only the task title and admission status; never a tool name, file path, or content fragment (preserves AC-20's event allowlist). +- `/settings` may change outbound redaction policy and `notify_scope` only. Model and permission mode render as plain text with no button, ever. +- Drill-down content ("Full diff", "Tool log") is the current turn's own already-persisted output only, fetched on demand and never cached beyond the existing 10-minute capability-token TTL — no new durable store. +- `RemoteProjection.observe` stays synchronous, bounded, and non-blocking; any database read needed for a newly-widened case happens in the async delivery path, never inside `observe()` itself. +- Every task cites its spec ACs, writes a failing test before implementation, and leaves the test suite green at its checkpoint. +- Commit at the end of each task. + +--- + +## File and interface map + +New units: + +- `app/remote/formatting.py` — HTML escaping helper and one builder per card type. +- `app/remote/turn_activity.py` — queries a turn's persisted tool-call messages; shared by the done-card summary and the drill-down buttons. +- `app/migrations/versions/00000065_add_remote_pairing_notify_scope.py` — adds `remote_pairings.notify_scope`. +- `tests/remote/test_formatting.py`, `tests/remote/test_turn_activity.py` — focused new-module evidence. + +Changed units: + +- `app/remote/contracts.py` — `RemoteAdapter` Protocol gains `indicate_typing`. +- `app/remote/telegram/client.py` — `send_text`/`edit_text` gain `parse_mode`; new `send_chat_action`. +- `app/remote/telegram/adapter.py` — passes `parse_mode="HTML"`; implements `indicate_typing`. +- `app/models/remote.py` — `RemotePairing.notify_scope`. +- `app/remote/outbound.py` — status-message lifecycle, active-pairing cache, widened `observe()`, formatting.py adoption. +- `app/remote/runtime.py` — wires the active-pairing cache and cross-references `actions`/`projection`. +- `app/remote/actions.py` — `/settings`, guided picker, drill-down capability handling, formatting.py adoption. +- `app/remote/gates.py` — formatting.py adoption. +- `tests/remote/telegram/test_client.py`, `tests/remote/telegram/test_adapter.py`, `tests/remote/test_outbound.py`, `tests/remote/test_actions.py`, `tests/remote/test_gates.py`, `tests/remote/test_runtime.py`, `tests/models/test_remote_models.py` — updated/new focused evidence. + +--- + +### Task 1: HTML rendering layer + +**ACs:** AC-24 (revised) + +**Files:** + +- Create: `app/remote/formatting.py` +- Create: `tests/remote/test_formatting.py` + +**Interfaces:** + +- Produces `escape(value: str) -> str`, `render_status_card`, `render_done_card`, `render_error_card`, `render_gate_card`, `render_settings_card`, `render_project_picker`, `render_prompt_suggestions` — every builder returns `tuple[str, tuple[RemoteButton, ...]]`. +- Consumes `app.remote.contracts.RemoteButton`. + +- [ ] **Step 1: Write escaping and golden-output tests** + +```python +# tests/remote/test_formatting.py +import html as html_lib + +from app.remote import formatting +from app.remote.contracts import RemoteButton + + +def test_escape_neutralizes_tag_characters(): + assert formatting.escape("", "tests/conftest.py"], + tool_call_count=4, + diff_token="diff-tok", + toollog_token="log-tok", + ) + assert "" not in text + assert "<script>" in text + assert "1m 12s" in text + assert "4 tool calls" in text + assert buttons == ( + RemoteButton(text="\U0001f4c4 Full diff", token="diff-tok"), + RemoteButton(text="\U0001f9fe Tool log", token="log-tok"), + ) + + +def test_render_done_card_omits_buttons_when_no_tokens(): + _, buttons = formatting.render_done_card( + title="No-op turn", + elapsed_seconds=1.0, + summary_lines=[], + tool_call_count=0, + diff_token=None, + toollog_token=None, + ) + assert buttons == () + + +def test_render_error_card_escapes_message(): + text, buttons = formatting.render_error_card( + title="Add rate limiter", + message="ModuleNotFoundError: ", + toollog_token="log-tok", + ) + assert "<redis>" in text + assert len(buttons) == 1 + + +def test_render_settings_card_never_emits_model_or_permission_buttons(): + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask each time", + redaction_policy="standard", + notify_scope="all", + redaction_tokens={"strict": "r1", "off": "r2"}, + notify_scope_tokens={"all": "n1", "remote_only": "n2"}, + ) + button_texts = [b.text for b in buttons] + assert not any("model" in t.lower() for t in button_texts) + assert not any("permission" in t.lower() for t in button_texts) + assert any("strict" in t.lower() for t in button_texts) +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.remote.formatting'`. + +- [ ] **Step 3: Implement the escaping helper and card builders** + +```python +# app/remote/formatting.py +from __future__ import annotations + +import html +from collections.abc import Mapping, Sequence + +from app.remote.contracts import RemoteButton + +__all__ = [ + "escape", + "format_elapsed", + "render_status_card", + "render_done_card", + "render_error_card", + "render_gate_card", + "render_settings_card", + "render_project_picker", + "render_prompt_suggestions", +] + +_STATUS_ICON = {"accepted": "\U0001f527", "queued": "⏳", "pending": "⏳"} + + +def escape(value: str) -> str: + """The only place raw text becomes safe to place inside an HTML tag.""" + return html.escape(value, quote=False) + + +def format_elapsed(seconds: float) -> str: + total = max(0, int(seconds)) + minutes, secs = divmod(total, 60) + return f"{minutes}m {secs}s" if minutes else f"{secs}s" + + +def render_status_card(*, title: str, status: str) -> tuple[str, tuple[RemoteButton, ...]]: + icon = _STATUS_ICON.get(status, "\U0001f527") + text = f"{icon} {escape(title)}\n{escape(status)}" + return text, () + + +def render_done_card( + *, + title: str, + elapsed_seconds: float, + summary_lines: Sequence[str], + tool_call_count: int, + diff_token: str | None, + toollog_token: str | None, +) -> tuple[str, tuple[RemoteButton, ...]]: + header = f"✅ {escape(title)} · {format_elapsed(elapsed_seconds)}" + lines = [escape(line) for line in summary_lines] + parts = [header] + if lines: + parts.append("\n".join(lines)) + parts.append(f"{tool_call_count} tool calls") + text = "\n\n".join(parts) + buttons: list[RemoteButton] = [] + if diff_token: + buttons.append(RemoteButton(text="\U0001f4c4 Full diff", token=diff_token)) + if toollog_token: + buttons.append(RemoteButton(text="\U0001f9fe Tool log", token=toollog_token)) + return text, tuple(buttons) + + +def render_error_card( + *, title: str, message: str, toollog_token: str | None +) -> tuple[str, tuple[RemoteButton, ...]]: + text = f"❌ {escape(title)}\n\nError: {escape(message)}" + buttons = ( + (RemoteButton(text="\U0001f9fe Tool log", token=toollog_token),) + if toollog_token + else () + ) + return text, buttons + + +def render_gate_card( + *, title: str, body: str, actions: Sequence[tuple[str, str]] +) -> tuple[str, tuple[RemoteButton, ...]]: + text = f"\U0001f510 {escape(title)}\n{escape(body)}" + buttons = tuple(RemoteButton(text=label, token=token) for token, label in actions) + return text, buttons + + +def render_settings_card( + *, + connection_label: str, + model: str, + permission_mode: str, + redaction_policy: str, + notify_scope: str, + redaction_tokens: Mapping[str, str], + notify_scope_tokens: Mapping[str, str], +) -> tuple[str, tuple[RemoteButton, ...]]: + text = ( + "⚙️ Settings\n\n" + f"Connection\n{escape(connection_label)}\n\n" + f"Model\n{escape(model)} (desktop only)\n\n" + f"Permission mode\n{escape(permission_mode)} (desktop only)\n\n" + f"Notifications\n{escape(notify_scope)}\n\n" + f"Outbound redaction\n{escape(redaction_policy)}" + ) + buttons = [ + RemoteButton(text=f"Redaction: {name}", token=token) + for name, token in redaction_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Notify: {name}", token=token) + for name, token in notify_scope_tokens.items() + ] + return text, tuple(buttons) + + +def render_project_picker( + *, projects: Sequence[tuple[str, str]] +) -> tuple[str, tuple[RemoteButton, ...]]: + text = "Which project?" + buttons = tuple( + RemoteButton(text=f"\U0001f4c1 {escape(name)}", token=token) + for token, name in projects + ) + return text, buttons + + +def render_prompt_suggestions( + *, + project_name: str, + context_line: str, + suggestions: Sequence[tuple[str, str]], + continue_token: str | None, +) -> tuple[str, tuple[RemoteButton, ...]]: + text = ( + f"\U0001f4c1 {escape(project_name)}\n" + f"{escape(context_line)}\n\n" + "Try one, or just type your own:" + ) + buttons: list[RemoteButton] = [] + if continue_token: + buttons.append(RemoteButton(text="▶ Continue last session", token=continue_token)) + buttons += [RemoteButton(text=label, token=token) for token, label in suggestions] + return text, tuple(buttons) +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_formatting.py +``` + +Expected: PASS (7 tests). + +- [ ] **Step 5: Lint** + +```powershell +uv run ruff check app/remote/formatting.py tests/remote/test_formatting.py +uv run ty check app/remote/formatting.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/formatting.py tests/remote/test_formatting.py +git commit -m "feat(remote): add HTML rendering layer for Telegram cards" +``` + +--- + +### Task 2: HTML parse mode and native typing indicator + +**ACs:** AC-24 (revised), AC-38 (transport half) + +**Files:** + +- Modify: `app/remote/contracts.py` +- Modify: `app/remote/telegram/client.py` +- Modify: `app/remote/telegram/adapter.py` +- Modify: `tests/remote/telegram/test_client.py` +- Modify: `tests/remote/telegram/test_adapter.py` + +**Interfaces:** + +- Produces `TelegramClient.send_chat_action(*, chat_id: str | int, action: str = "typing") -> None`. +- Produces `RemoteAdapter.indicate_typing(destination_id: str) -> None` (Protocol) and `TelegramAdapter.indicate_typing`. +- Changes `TelegramClient.send_text`/`edit_text` to always send `parse_mode="HTML"`. + +- [ ] **Step 1: Write failing client tests** + +```python +# tests/remote/telegram/test_client.py (add) +@pytest.mark.asyncio +async def test_send_text_sends_html_parse_mode(client, transport): + await client.send_text(chat_id="1", text="hi") + request = transport.requests[-1] + assert request.json()["parse_mode"] == "HTML" + + +@pytest.mark.asyncio +async def test_send_chat_action_calls_send_chat_action_endpoint(client, transport): + await client.send_chat_action(chat_id="1") + request = transport.requests[-1] + assert request.url.path.endswith("/sendChatAction") + assert request.json() == {"chat_id": "1", "action": "typing"} +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/telegram/test_client.py -k "parse_mode or chat_action" +``` + +- [ ] **Step 3: Implement in `TelegramClient`** + +```python +# app/remote/telegram/client.py — inside send_text/edit_text payload construction, +# add "parse_mode": "HTML" to the request body dict alongside existing chat_id/text/ +# reply_markup keys. Then add: + +async def send_chat_action( + self, *, chat_id: str | int, action: str = "typing" +) -> None: + await self._post("sendChatAction", {"chat_id": chat_id, "action": action}) +``` + +Use whichever existing private request helper `send_text` already calls (e.g. `self._post`/`self._request`) so error handling matches the rest of the class. + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/telegram/test_client.py +``` + +- [ ] **Step 5: Write failing adapter test for `indicate_typing`** + +```python +# tests/remote/telegram/test_adapter.py (add) +@pytest.mark.asyncio +async def test_indicate_typing_calls_client(adapter, fake_client): + await adapter.indicate_typing("chat-1") + assert fake_client.chat_actions == [("chat-1", "typing")] +``` + +- [ ] **Step 6: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/telegram/test_adapter.py -k indicate_typing +``` + +- [ ] **Step 7: Add `indicate_typing` to the Protocol and implement it** + +```python +# app/remote/contracts.py — inside class RemoteAdapter(Protocol): + async def indicate_typing(self, destination_id: str) -> None: ... +``` + +```python +# app/remote/telegram/adapter.py — new method on TelegramAdapter +async def indicate_typing(self, destination_id: str) -> None: + try: + await self._client.send_chat_action(chat_id=destination_id) + except TelegramApiError: + # Best-effort liveliness signal; never fail the turn over it. + pass +``` + +- [ ] **Step 8: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/telegram/test_client.py tests/remote/telegram/test_adapter.py +uv run ruff check app/remote/contracts.py app/remote/telegram/client.py app/remote/telegram/adapter.py +uv run ty check app/remote/ +``` + +- [ ] **Step 9: Commit** + +```bash +git add app/remote/contracts.py app/remote/telegram/client.py app/remote/telegram/adapter.py tests/remote/telegram/test_client.py tests/remote/telegram/test_adapter.py +git commit -m "feat(remote): send HTML parse mode and native typing indicator" +``` + +--- + +### Task 3: `notify_scope` schema and active-pairing cache + +**ACs:** AC-42 (schema half) + +**Files:** + +- Create: `app/migrations/versions/00000065_add_remote_pairing_notify_scope.py` +- Modify: `app/models/remote.py` +- Modify: `app/remote/outbound.py` +- Modify: `app/remote/runtime.py` +- Test: `tests/models/test_remote_models.py` (create if absent) +- Modify: `tests/remote/test_outbound.py` +- Modify: `tests/remote/test_runtime.py` + +**Interfaces:** + +- Produces `RemotePairing.notify_scope: str` (default `"all"`). +- Produces `RemoteProjection.set_active_pairing(*, connection_id: str, destination_id: str, notify_scope: str, principal_id: str) -> None` and `RemoteProjection.clear_active_pairing() -> None`. +- Consumes `RemotePairing` via direct `select()`, matching the pattern already used in `app/remote/inbound.py`. + +- [ ] **Step 1: Write the migration** + +```python +# app/migrations/versions/00000065_add_remote_pairing_notify_scope.py +"""Add remote_pairings.notify_scope + +Revision ID: 00000065 +Revises: 00000064 +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000065" +down_revision: str | Sequence[str] | None = "00000064" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "remote_pairings", + sa.Column( + "notify_scope", + sa.String(20), + nullable=False, + server_default="all", + ), + ) + + +def downgrade() -> None: + op.drop_column("remote_pairings", "notify_scope") +``` + +- [ ] **Step 2: Update the schema-head marker** + +Open `app/core/schema_version.py` and update the expected head revision constant to `"00000065"`, matching how `00000064` was registered. + +- [ ] **Step 3: Write a failing model/migration test** + +```python +# tests/models/test_remote_models.py +import pytest +from sqlmodel import select + +from app.models.remote import RemotePairing + + +@pytest.mark.asyncio +async def test_new_pairing_defaults_notify_scope_to_all(db_session, remote_connection): + pairing = RemotePairing( + connection_id=remote_connection.id, + principal_id="user-1", + destination_id="chat-1", + label="My phone", + ) + db_session.add(pairing) + await db_session.commit() + await db_session.refresh(pairing) + assert pairing.notify_scope == "all" +``` + +- [ ] **Step 4: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/models/test_remote_models.py +``` + +Expected: FAIL — `notify_scope` is not a field on `RemotePairing`. + +- [ ] **Step 5: Add the column to the model** + +```python +# app/models/remote.py — inside class RemotePairing, after `display`: +notify_scope: str = Field( + default="all", + sa_column=Column(sa.String(20), nullable=False, server_default="all"), +) +``` + +- [ ] **Step 6: Run migration head and model test** + +```powershell +uv run alembic upgrade head +uv run pytest --no-cov -q tests/models/test_remote_models.py +``` + +- [ ] **Step 7: Write failing active-pairing cache tests** + +```python +# tests/remote/test_outbound.py (add) +def test_set_active_pairing_then_clear(projection): + projection.set_active_pairing( + connection_id="conn-1", destination_id="chat-1", notify_scope="all", principal_id="user-1", + ) + assert projection.active_pairing() == ("conn-1", "chat-1", "all", "user-1") + projection.clear_active_pairing() + assert projection.active_pairing() is None +``` + +- [ ] **Step 8: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_outbound.py -k active_pairing +``` + +- [ ] **Step 9: Implement the cache on `RemoteProjection`** + +```python +# app/remote/outbound.py — new fields on RemoteProjection and two methods. +# principal_id travels alongside connection/destination so completions and +# drill-down tokens for a session the projection never explicitly +# register_session-ed (Task 5) can still be minted with a real, non-empty +# owner instead of "" — v1 has exactly one pairing per connection, so the +# active pairing's principal is the only valid actor for the whole +# connection regardless of which surface (phone or desktop) started the work. +_active_pairing: tuple[str, str, str, str] | None = field(default=None, repr=False) + +def set_active_pairing( + self, *, connection_id: str, destination_id: str, notify_scope: str, principal_id: str, +) -> None: + """Cache the single v1 pairing's routing info so ``observe`` can reach + sessions it was never explicitly ``register_session``-ed for.""" + self._active_pairing = (connection_id, destination_id, notify_scope, principal_id) + +def clear_active_pairing(self) -> None: + self._active_pairing = None + +def active_pairing(self) -> tuple[str, str, str, str] | None: + return self._active_pairing +``` + +- [ ] **Step 10: Wire it from `runtime.py`** + +```python +# app/remote/runtime.py — inside _start_locked, after `projection.set_adapter(adapter)` +from app.models.remote import RemotePairing +from sqlmodel import select + +async with read_session_factory() as pairing_session: + pairing = ( + await pairing_session.exec( + select(RemotePairing).where(RemotePairing.connection_id == connection.id) + ) + ).first() +if pairing is not None: + projection.set_active_pairing( + connection_id=str(pairing.connection_id), + destination_id=pairing.destination_id, + notify_scope=pairing.notify_scope, + principal_id=pairing.principal_id, + ) +``` + +```python +# app/remote/runtime.py — inside _handle_pairing, in the `if result is not None:` +# branch that already sends "Connected to EvoFlux on {result.label}." — +# `result` is the persisted RemotePairing row itself (consume()'s return type) +if self._projection is not None: + self._projection.set_active_pairing( + connection_id=str(action.connection_id), + destination_id=result.destination_id, + notify_scope=result.notify_scope, + principal_id=result.principal_id, + ) +``` + +```python +# app/remote/runtime.py — inside _stop_locked, before projection is discarded +if self._projection is not None: + self._projection.clear_active_pairing() +``` + +- [ ] **Step 11: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_outbound.py tests/remote/test_runtime.py +uv run ruff check app/remote/outbound.py app/remote/runtime.py app/models/remote.py tests/models/test_remote_models.py tests/remote/test_outbound.py tests/remote/test_runtime.py +uv run ty check app/remote/ app/models/remote.py +``` + +- [ ] **Step 12: Commit** + +```bash +git add app/migrations/versions/00000065_add_remote_pairing_notify_scope.py app/core/schema_version.py app/models/remote.py app/remote/outbound.py app/remote/runtime.py tests/models/test_remote_models.py tests/remote/test_outbound.py tests/remote/test_runtime.py +git commit -m "feat(remote): add notify_scope preference and active-pairing cache" +``` + +--- + +### Task 4: Turn-activity summary and phone-admitted status lifecycle + +**ACs:** AC-38, AC-24 (application half) + +**Files:** + +- Create: `app/remote/turn_activity.py` +- Create: `tests/remote/test_turn_activity.py` +- Modify: `app/remote/outbound.py` +- Modify: `app/remote/runtime.py` +- Modify: `tests/remote/test_outbound.py` + +**Interfaces:** + +- Produces `app.remote.turn_activity.load_turn_activity(db: AsyncSession, session_id: str, *, since: datetime) -> TurnActivity` where `TurnActivity` has `tool_call_count: int`, `summary_lines: list[str]`, `tool_log_text: str`, `diff_text: str`. +- Produces `RemoteProjection.begin_phone_turn(session_id: str, *, connection_id: str, destination_id: str, principal_id: str, title: str, status: str) -> None`. +- Consumes `app.remote.formatting.render_status_card/render_done_card/render_error_card`, `app.models.chat.ChatMessage`. + +- [ ] **Step 1: Write failing turn-activity tests** + +```python +# tests/remote/test_turn_activity.py +import pytest +from datetime import UTC, datetime, timedelta + +from app.models.chat import ChatMessage +from app.remote.turn_activity import load_turn_activity + + +@pytest.mark.asyncio +async def test_load_turn_activity_counts_tool_calls_and_builds_diff(db_session, chat_session): + since = datetime.now(UTC) - timedelta(seconds=1) + db_session.add_all( + [ + ChatMessage( + session_id=chat_session.id, + role="assistant", + tool_calls=[{"name": "write", "arguments": {"path": "a.py"}}], + created_at=since + timedelta(milliseconds=10), + ), + ChatMessage( + session_id=chat_session.id, + role="tool", + tool_call_id="1", + content="wrote a.py (+5 -1)", + created_at=since + timedelta(milliseconds=20), + ), + ChatMessage( + session_id=chat_session.id, + role="assistant", + tool_calls=[{"name": "read", "arguments": {"path": "b.py"}}], + created_at=since + timedelta(milliseconds=30), + ), + ] + ) + await db_session.commit() + + activity = await load_turn_activity(db_session, str(chat_session.id), since=since) + + assert activity.tool_call_count == 2 + assert "write" in activity.diff_text + assert "read" not in activity.diff_text + assert "read" in activity.tool_log_text +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_turn_activity.py +``` + +- [ ] **Step 3: Implement `turn_activity.py`** + +```python +# app/remote/turn_activity.py +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.chat import ChatMessage + +_DIFF_TOOLS = frozenset({"write", "edit", "patch"}) + +__all__ = ["TurnActivity", "load_turn_activity"] + + +@dataclass(frozen=True) +class TurnActivity: + tool_call_count: int + summary_lines: list[str] = field(default_factory=list) + tool_log_text: str = "" + diff_text: str = "" + + +async def load_turn_activity( + db: AsyncSession, session_id: str, *, since: datetime +) -> TurnActivity: + rows = ( + await db.exec( + select(ChatMessage) + .where(ChatMessage.session_id == session_id) + .where(ChatMessage.created_at >= since) + .order_by(ChatMessage.created_at) + ) + ).all() + + tool_calls: list[tuple[str, str]] = [] + for message in rows: + for call in message.tool_calls or []: + name = call.get("name", "unknown") + args = call.get("arguments", {}) + tool_calls.append((name, str(args))) + + tool_log_lines = [f"{name}: {args}" for name, args in tool_calls] + diff_lines = [f"{name}: {args}" for name, args in tool_calls if name in _DIFF_TOOLS] + diff_paths = sorted({args for name, args in tool_calls if name in _DIFF_TOOLS}) + + return TurnActivity( + tool_call_count=len(tool_calls), + summary_lines=diff_paths[:10], + tool_log_text="\n".join(tool_log_lines) or "No tool calls.", + diff_text="\n".join(diff_lines) or "No file changes.", + ) +``` + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_turn_activity.py +``` + +- [ ] **Step 5: Write failing phone-admitted lifecycle tests** + +```python +# tests/remote/test_outbound.py (add) +@pytest.mark.asyncio +async def test_begin_phone_turn_sends_status_card_then_done_edits_it( + projection, adapter +): + projection.register_session( + "sess-1", connection_id="conn-1", destination_id="chat-1", + tags=frozenset({"remote_origin"}), + ) + projection.begin_phone_turn( + "sess-1", connection_id="conn-1", destination_id="chat-1", principal_id="user-1", + title="Fix tests", status="accepted", + ) + await asyncio.sleep(0.05) + assert adapter.calls[0] == "send" + first_correlation = adapter.sent[0].correlation_id + + projection.observe("sess-1", _envelope("done", text="Done.")) + await projection.drain_pending() + + assert adapter.calls[1] == "edit" + assert adapter.edited[0].correlation_id == first_correlation + assert "Fix tests" in adapter.edited[0].text + assert projection.typing_task_for("sess-1") is None +``` + +- [ ] **Step 6: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_outbound.py -k begin_phone_turn +``` + +- [ ] **Step 7: Implement the lifecycle on `RemoteProjection`** + +```python +# app/remote/outbound.py — extend _TurnDeliveryState +@dataclass +class _TurnDeliveryState: + session_id: str + connection_id: str + destination_id: str + lifecycle_correlation_id: str | None = None + completion_sent: bool = False + phone_admitted: bool = False + started_at: float = field(default_factory=time.monotonic) + turn_started_wall_clock: datetime = field(default_factory=lambda: datetime.now(UTC)) + typing_task: "asyncio.Task[None] | None" = field(default=None, repr=False) + title: str = "" + principal_id: str = "" +``` + +```python +# app/remote/outbound.py — new public method +def begin_phone_turn( + self, + session_id: str, + *, + connection_id: str, + destination_id: str, + principal_id: str, + title: str, + status: str, +) -> None: + """Create the one status message a phone-admitted turn owns, and start + the native typing indicator alongside it. Called by runtime.py right + after register_session for a text-triggered admission.""" + correlation_id = f"status:{session_id}:{uuid.uuid4().hex[:8]}" + turn = _TurnDeliveryState( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + lifecycle_correlation_id=correlation_id, + phone_admitted=True, + title=title, + ) + self._turns[session_id] = turn + text, buttons = render_status_card(title=title, status=status) + self._enqueue_send( + destination_id=destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + correlation_id=correlation_id, + ) + if self._adapter is not None: + turn.typing_task = asyncio.create_task(self._run_typing_loop(turn)) + + +async def _run_typing_loop(self, turn: _TurnDeliveryState) -> None: + adapter = self._adapter + if adapter is None: + return + try: + while True: + await adapter.indicate_typing(turn.destination_id) + await asyncio.sleep(4.0) + except asyncio.CancelledError: + pass + + +def typing_task_for(self, session_id: str) -> "asyncio.Task[None] | None": + turn = self._turns.get(session_id) + return turn.typing_task if turn is not None else None + + +def _stop_typing(self, turn: _TurnDeliveryState) -> None: + if turn.typing_task is not None and not turn.typing_task.done(): + turn.typing_task.cancel() + turn.typing_task = None +``` + +Replace `_handle_done`/`_handle_error` to branch on `turn.phone_admitted`, using `formatting.py` builders and `turn_activity.load_turn_activity`: + +```python +async def _finalize_turn( + self, turn: _TurnDeliveryState, *, error_message: str | None +) -> None: + from app.core.db import async_session_factory + + self._stop_typing(turn) + elapsed = time.monotonic() - turn.started_at + async with async_session_factory() as db: + activity = await load_turn_activity( + db, turn.session_id, since=turn.turn_started_wall_clock + ) + diff_token = toollog_token = None + if self._actions is not None: + if activity.diff_text.strip() and activity.diff_text != "No file changes.": + diff_token = self._actions.register_capability( + connection_id=turn.connection_id, + principal_id=turn.principal_id, + destination_id=turn.destination_id, + session_id=turn.session_id, + action_kind="diff", + action_target=activity.diff_text, + ) + toollog_token = self._actions.register_capability( + connection_id=turn.connection_id, + principal_id=turn.principal_id, + destination_id=turn.destination_id, + session_id=turn.session_id, + action_kind="toollog", + action_target=activity.tool_log_text, + ) + if error_message is not None: + text, buttons = render_error_card( + title=turn.title, message=_redact_text(error_message), toollog_token=toollog_token + ) + else: + text, buttons = render_done_card( + title=turn.title, + elapsed_seconds=elapsed, + summary_lines=[_redact_text(line) for line in activity.summary_lines], + tool_call_count=activity.tool_call_count, + diff_token=diff_token, + toollog_token=toollog_token, + ) + if turn.phone_admitted and turn.lifecycle_correlation_id is not None: + self._enqueue_edit( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + correlation_id=turn.lifecycle_correlation_id, + ) + else: + self._enqueue_send( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + ) +``` + +`_handle_done`/`_handle_error` become thin wrappers: guard on `turn.completion_sent`, set it, then `await self._finalize_turn(turn, error_message=None)` / `await self._finalize_turn(turn, error_message=message)`. + +The real current `_enqueue_send` (`app/remote/outbound.py`) already accepts `buttons` but not `correlation_id`, builds one `RemoteOutboundMessage` per chunk, appends each to `self._pending: list[RemoteOutboundMessage]`, and calls `self._schedule_drain()` — a fire-and-forget `asyncio.create_task(self.drain_pending())` when a loop is running, there is no persistent background worker. `drain_pending()` pops everything off `self._pending` and calls `await adapter.send(msg)` for each; there is currently no edit path at all. Extend it exactly like this: + +```python +# app/remote/outbound.py — add a correlation_id parameter to the existing +# _enqueue_send, and a new _enqueue_edit + a second pending list +def _enqueue_send( + self, + *, + destination_id: str, + text: str, + buttons: tuple[RemoteButton, ...] = (), + priority: RemoteOutboundPriority = RemoteOutboundPriority.INFORMATIONAL, + correlation_id: str | None = None, +) -> None: + adapter = self._adapter + if adapter is None: + logger.debug("remote_outbound_no_adapter destination_id={}", destination_id) + return + connection_id = "" + for cid in self._session_connection_ids.values(): + connection_id = cid + break + chunks = _split_text(text) + for i, chunk in enumerate(chunks): + msg = RemoteOutboundMessage( + connection_id=UUID(connection_id) if connection_id else UUID(int=0), + destination_id=destination_id, + text=chunk, + buttons=buttons if i == len(chunks) - 1 else (), + priority=priority, + correlation_id=correlation_id if i == len(chunks) - 1 else None, + ) + self._pending.append(msg) + self._schedule_drain() + + +def _enqueue_edit( + self, *, destination_id: str, text: str, buttons: tuple[RemoteButton, ...], correlation_id: str, +) -> None: + """Edit-flagged delivery — a status card's final transition. Unlike + _enqueue_send, never splits (a status/done card is always short and + already bounded by formatting.py's builders), so it is always exactly + one queued item.""" + adapter = self._adapter + if adapter is None: + return + connection_id = "" + for cid in self._session_connection_ids.values(): + connection_id = cid + break + msg = RemoteOutboundMessage( + connection_id=UUID(connection_id) if connection_id else UUID(int=0), + destination_id=destination_id, + text=text, + buttons=buttons, + correlation_id=correlation_id, + ) + self._pending_edits.append(msg) + self._schedule_drain() +``` + +```python +# app/remote/outbound.py — extend drain_pending to also drain edits, and +# add the new field next to the existing _pending: list[RemoteOutboundMessage] +_pending_edits: list[RemoteOutboundMessage] = field(default_factory=list, repr=False) + +async def drain_pending(self) -> None: + """Send/edit all pending messages through the adapter.""" + adapter = self._adapter + if adapter is None: + self._pending.clear() + self._pending_edits.clear() + self._unaddressed_pending.clear() + return + while self._pending: + msg = self._pending.pop(0) + try: + await adapter.send(msg) + except Exception as exc: + logger.warning("remote_outbound_send_failed destination_id={} error={}", msg.destination_id, exc) + while self._pending_edits: + msg = self._pending_edits.pop(0) + try: + await adapter.edit(msg) + except Exception as exc: + logger.warning("remote_outbound_edit_failed destination_id={} error={}", msg.destination_id, exc) + await self._drain_unaddressed() +``` + +`register_capability`'s real signature is defined in Task 6 — until that task lands, treat `self._actions` as an optional `"RemoteActionService | None" = None` attribute set by the `set_actions` setter Task 6 also adds, and treat a `None` diff/toollog token as "no button" (Task 1's builders already handle that). `_drain_unaddressed` (Task 5) is a no-op empty method until Task 5 implements it — add it as a stub (`async def _drain_unaddressed(self) -> None: return`) in this task so `drain_pending` has something to call, and Task 5 replaces the stub body. + +- [ ] **Step 8: Wire `begin_phone_turn` from `runtime.py`** + +```python +# app/remote/runtime.py — inside _handle_text, right after the existing +# register_session(...) call +if result.session_id is not None and self._projection is not None: + from app.core.db import async_session_factory as _sf + + async with _sf() as title_session: + session_row = await title_session.get(ChatSession, result.session_id) + self._projection.begin_phone_turn( + str(result.session_id), + connection_id=str(action.connection_id), + destination_id=action.principal.destination_id, + principal_id=action.principal.principal_id, + title=(session_row.title if session_row and session_row.title else "New task"), + status=result.status, + ) +``` + +- [ ] **Step 9: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_outbound.py tests/remote/test_runtime.py tests/remote/test_turn_activity.py +uv run ruff check app/remote/outbound.py app/remote/runtime.py app/remote/turn_activity.py tests/remote/test_outbound.py tests/remote/test_turn_activity.py +uv run ty check app/remote/ +``` + +- [ ] **Step 10: Commit** + +```bash +git add app/remote/turn_activity.py app/remote/outbound.py app/remote/runtime.py tests/remote/test_turn_activity.py tests/remote/test_outbound.py +git commit -m "feat(remote): live status card, typing indicator, and done/error cards" +``` + +--- + +### Task 5: Cross-origin and workflow/scheduler completion delivery + +**ACs:** AC-42, AC-43 + +**Files:** + +- Modify: `app/remote/outbound.py` +- Modify: `tests/remote/test_outbound.py` + +**Interfaces:** + +- Changes `RemoteProjection.observe` session-resolution: falls back to `active_pairing()` when the session was never `register_session`-ed and the cached `notify_scope == "all"`, carrying the cached `principal_id` through so completion/drill-down capability tokens for these sessions are minted with a real owner instead of an empty string. +- Consumes `app.remote.inbound._is_addressable_session` (import it into `outbound.py` rather than duplicating the predicate) and `app.models.chat.ChatSession`. + +- [ ] **Step 1: Write failing cross-origin tests** + +```python +# tests/remote/test_outbound.py (add) +@pytest.mark.asyncio +async def test_unregistered_addressable_session_notifies_when_scope_is_all( + projection, adapter, db_session_factory, addressable_session +): + projection.set_active_pairing( + connection_id="conn-1", destination_id="chat-1", notify_scope="all", principal_id="user-1", + ) + projection.observe(str(addressable_session.id), _envelope("done", text="Done.")) + await projection.drain_pending() + assert adapter.calls == ["send"] + assert adapter.sent[0].destination_id == "chat-1" + + +@pytest.mark.asyncio +async def test_unregistered_session_silent_when_scope_is_remote_only( + projection, adapter, addressable_session +): + projection.set_active_pairing( + connection_id="conn-1", destination_id="chat-1", notify_scope="remote_only", principal_id="user-1", + ) + projection.observe(str(addressable_session.id), _envelope("done", text="Done.")) + await projection.drain_pending() + assert adapter.calls == [] + + +@pytest.mark.asyncio +async def test_non_addressable_session_never_notifies_even_with_scope_all( + projection, adapter, side_chat_session +): + projection.set_active_pairing( + connection_id="conn-1", destination_id="chat-1", notify_scope="all", principal_id="user-1", + ) + projection.observe(str(side_chat_session.id), _envelope("done", text="Done.")) + await projection.drain_pending() + assert adapter.calls == [] + + +@pytest.mark.asyncio +async def test_unregistered_session_gets_no_status_card_or_typing( + projection, adapter, addressable_session +): + projection.set_active_pairing( + connection_id="conn-1", destination_id="chat-1", notify_scope="all", principal_id="user-1", + ) + projection.observe(str(addressable_session.id), _envelope("done", text="Done.")) + await projection.drain_pending() + assert projection.typing_task_for(str(addressable_session.id)) is None +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_outbound.py -k "cross_origin or unregistered or non_addressable" +``` + +- [ ] **Step 3: Implement the widened resolution in `observe`** + +```python +# app/remote/outbound.py — replace the early-return session resolution at +# the top of observe() with: +def observe(self, session_id: str, envelope) -> None: + event_type = envelope.event + if event_type not in _OBSERVED_EVENT_TYPES: + return + + tags = self._session_tags.get(session_id) + if tags is not None: + connection_id = self._session_connection_ids.get(session_id, "") + destination_id = self._session_destination_ids.get(session_id, "") + if not connection_id or not destination_id: + return + else: + active = self._active_pairing + if active is None or active[2] != "all": + return + # Addressability for a session we never registered can only be + # confirmed with a database read, which observe() must never do + # (AC-19). Defer that check to the async delivery path by enqueuing + # a guarded lookup instead of calling send/edit directly. + if event_type not in ("done", "error"): + return + connection_id, destination_id, _, principal_id = active + self._enqueue_unregistered_completion( + session_id=session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + event_type=event_type, + envelope_data=dict(envelope.data), + ) + return + + turn = self._turns.get(session_id) + if turn is None: + turn = _TurnDeliveryState( + session_id=session_id, connection_id=connection_id, destination_id=destination_id, + ) + self._turns[session_id] = turn + + if event_type == "done": + self._handle_done(turn, envelope) + elif event_type == "error": + self._handle_error(turn, envelope) +``` + +```python +# app/remote/outbound.py — new queue item; _schedule_drain is the existing +# fire-and-forget wake used by _enqueue_send (there is no persistent +# background worker task to notify) +def _enqueue_unregistered_completion( + self, *, session_id: str, connection_id: str, destination_id: str, + principal_id: str, event_type: str, envelope_data: dict, +) -> None: + self._unaddressed_pending.append( + (session_id, connection_id, destination_id, principal_id, event_type, envelope_data) + ) + self._schedule_drain() +``` + +```python +# app/remote/outbound.py — inside the existing async delivery-worker loop, +# before/after the regular queue drain, drain _unaddressed_pending too: +async def _drain_unaddressed(self) -> None: + from app.core.db import async_session_factory + from app.models.chat import ChatSession + from app.remote.inbound import _is_addressable_session + + pending, self._unaddressed_pending = self._unaddressed_pending, [] + for session_id, connection_id, destination_id, principal_id, event_type, data in pending: + async with async_session_factory() as db: + session_row = await db.get(ChatSession, session_id) + if not _is_addressable_session(session_row): + continue + turn = self._turns.get(session_id) + if turn is None: + turn = _TurnDeliveryState( + session_id=session_id, connection_id=connection_id, + destination_id=destination_id, principal_id=principal_id, + title=session_row.title or "Task", + ) + self._turns[session_id] = turn + if event_type == "done": + if turn.completion_sent: + continue + turn.completion_sent = True + await self._finalize_turn(turn, error_message=None) + else: + await self._finalize_turn(turn, error_message=data.get("message", "An error occurred.")) +``` + +This replaces the empty `_drain_unaddressed` stub Task 4 added (that task's extended `drain_pending` already calls `await self._drain_unaddressed()` at the end, so existing tests that call `await projection.drain_pending()` exercise this path with no further wiring needed). Add `_unaddressed_pending: list[tuple] = field(default_factory=list, repr=False)` to `RemoteProjection`'s dataclass fields. + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_outbound.py +uv run ruff check app/remote/outbound.py tests/remote/test_outbound.py +uv run ty check app/remote/outbound.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/remote/outbound.py tests/remote/test_outbound.py +git commit -m "feat(remote): deliver completions for desktop-started and workflow sessions" +``` + +--- + +### Task 6: On-demand drill-down (Full diff, Tool log) + +**ACs:** AC-39 + +**Files:** + +- Modify: `app/remote/actions.py` +- Modify: `app/remote/outbound.py` +- Modify: `app/remote/runtime.py` +- Modify: `tests/remote/test_actions.py` +- Modify: `tests/remote/test_outbound.py` + +**Interfaces:** + +- Produces `RemoteActionService.register_capability(*, connection_id: UUID | str, principal_id: str, destination_id: str, session_id: str, action_kind: str, action_target: str) -> str`. +- Changes `RemoteActionService.handle_action_callback(self, action: RemoteInboundAction, db: AsyncSession) -> bool` — adds a required `db` parameter (needed by later tasks' branches, e.g. Task 7's settings writes and Task 8's session creation) and `"diff"`/`"toollog"` branches. Every existing caller and test call site for this method is updated in this task, not deferred. +- Produces `RemoteProjection.set_actions(actions: "RemoteActionService | None") -> None` and `RemoteActionService.set_projection(projection: "RemoteProjection | None") -> None` (the two services need each other: outbound needs actions to mint drill-down tokens; actions needs outbound to update the active-pairing cache and to give guided-flow-started tasks a live status card). + +- [ ] **Step 1: Write failing capability-registration and dispatch tests** + +```python +# tests/remote/test_actions.py (add) +def test_register_capability_returns_usable_token(service): + token = service.register_capability( + connection_id=uuid4(), principal_id="", destination_id="chat-1", + session_id="sess-1", action_kind="toollog", action_target="log text", + ) + assert isinstance(token, str) and len(token) > 0 + + +@pytest.mark.asyncio +async def test_toollog_callback_sends_stored_content(service, adapter, db_session): + token = service.register_capability( + connection_id=uuid4(), principal_id="", destination_id="chat-1", + session_id="sess-1", action_kind="toollog", action_target="write: {'path': 'a.py'}", + ) + action = _make_action(callback_token=token) + handled = await service.handle_action_callback(action, db_session) + assert handled is True + assert any("write" in msg.text for msg in adapter.sent) + + +@pytest.mark.asyncio +async def test_expired_capability_replies_friendly_message(service, adapter, db_session, monkeypatch): + token = service.register_capability( + connection_id=uuid4(), principal_id="", destination_id="chat-1", + session_id="sess-1", action_kind="diff", action_target="diff text", + ) + monkeypatch.setattr(time, "monotonic", lambda: time.monotonic() + 700) + action = _make_action(callback_token=token) + handled = await service.handle_action_callback(action, db_session) + assert handled is True + assert "expired" in adapter.sent[0].text.lower() +``` + +- [ ] **Step 1b: Update the method signature and every existing call site** + +Add `db: AsyncSession` as `handle_action_callback`'s second positional parameter. Update its four existing internal branches (workflow/coding-project/EASD/schedule dispatch) — they currently open their own `async with async_session_factory() as session:` block per branch; replace each with the passed-in `db` so the whole method shares one session, matching how `dispatch_command` already receives `db` from its caller. Update every existing test in `tests/remote/test_actions.py` that calls `service.handle_action_callback(action)` to `service.handle_action_callback(action, db_session)`. + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py -k "capability or toollog or expired" +``` + +- [ ] **Step 3: Implement `register_capability` and dispatch branches** + +```python +# app/remote/actions.py — new public method on RemoteActionService +def register_capability( + self, + *, + connection_id: UUID | str, + principal_id: str, + destination_id: str, + session_id: str, + action_kind: str, + action_target: str, +) -> str: + token = secrets.token_urlsafe(16)[:_MAX_CALLBACK_TOKEN_BYTES] + self._capabilities[token] = _ActionCapability( + token=token, + connection_id=UUID(str(connection_id)), + principal_id=principal_id, + destination_id=destination_id, + session_id=session_id, + action_kind=action_kind, + action_target=action_target, + ) + return token +``` + +```python +# app/remote/actions.py — inside handle_action_callback, add before the +# existing menu-token handling falls through: +capability = self._capabilities.get(action.callback_token) +if capability is not None and capability.action_kind in ("diff", "toollog"): + del self._capabilities[action.callback_token] + if time.monotonic() - capability.created_at > _CAPABILITY_TTL_SECONDS: + text = "This expired. Ask me again and I'll fetch it fresh." + else: + label = "Full diff" if capability.action_kind == "diff" else "Tool log" + redacted = _redact_text(capability.action_target) + text = f"{label}\n\n
{formatting.escape(redacted)}
" + for chunk in _split_text(text): + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=capability.connection_id, + destination_id=capability.destination_id, + text=chunk, + priority=RemoteOutboundPriority.HIGH, + ) + ) + return True +``` + +Import `formatting` and the existing `_split_text` helper (move it to a small shared location if it is not already importable from `actions.py` — `outbound.py`'s `__all__` already exports it, so `from app.remote.outbound import _split_text` is sufficient). + +- [ ] **Step 4: Wire `RemoteProjection.set_actions` and call it from `runtime.py`** + +```python +# app/remote/outbound.py — mirrors set_adapter/set_bridge +def set_actions(self, actions: "RemoteActionService | None") -> None: + self._actions = actions +``` + +```python +# app/remote/actions.py — new setter on RemoteActionService, mirroring the +# existing pattern; store as self._projection: "RemoteProjection | None" = None +def set_projection(self, projection: "RemoteProjection | None") -> None: + self._projection = projection +``` + +```python +# app/remote/runtime.py — inside _start_locked, after self._actions is built +projection.set_actions(self._actions) +self._actions.set_projection(projection) +``` + +```python +# app/remote/runtime.py — inside _stop_locked, alongside the other projection resets +if self._projection is not None: + self._projection.set_actions(None) +if self._actions is not None: + self._actions.set_projection(None) +``` + +```python +# app/remote/runtime.py — inside _handle_action's CALLBACK branch, thread a +# session through to the now-required db parameter +if self._actions is not None and action.callback_token is not None: + from app.core.db import async_session_factory + + async with async_session_factory() as callback_session: + handled = await self._actions.handle_action_callback(action, callback_session) +``` + +- [ ] **Step 5: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py tests/remote/test_outbound.py tests/remote/test_runtime.py +uv run ruff check app/remote/actions.py app/remote/outbound.py app/remote/runtime.py tests/remote/test_actions.py +uv run ty check app/remote/ +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/actions.py app/remote/outbound.py app/remote/runtime.py tests/remote/test_actions.py +git commit -m "feat(remote): on-demand Full diff / Tool log drill-down buttons" +``` + +--- + +### Task 7: `/settings` command + +**ACs:** AC-41, AC-32 (revised), AC-44 + +**Files:** + +- Modify: `app/remote/actions.py` +- Modify: `tests/remote/test_actions.py` + +**Interfaces:** + +- Adds `"settings"` to `_SLASH_COMMANDS`. +- Produces `RemoteActionService._cmd_settings(db, action) -> RemoteActionResult`. +- Adds `handle_action_callback` branches for `action_kind in ("redaction", "notify_scope")`. +- Consumes `app.core.runtime_settings.RemoteSettings/load_runtime_settings/save_runtime_settings` (the same file-backed config the `PUT /api/settings/remote` route already reads/writes — no database round trip) and `RemotePairing.notify_scope`. + +- [ ] **Step 1: Write failing command and toggle tests** + +```python +# tests/remote/test_actions.py (add) +def test_settings_is_a_known_slash_command(): + assert "settings" in _SLASH_COMMANDS + + +@pytest.mark.asyncio +async def test_cmd_settings_shows_model_and_permission_as_text_only( + service, db_session, paired_session_with_model +): + action = _make_action(text="/settings") + result = await service.dispatch_command(db_session, action) + assert "claude-sonnet-5" in result.text + assert "ask each time" in result.text.lower() or "auto" in result.text.lower() + + +@pytest.mark.asyncio +async def test_settings_redaction_callback_updates_policy( + service, db_session, adapter, monkeypatch +): + saved = {} + + def fake_save(cfg): + saved["policy"] = cfg.remote.outbound_data_policy + + monkeypatch.setattr("app.remote.actions.save_runtime_settings", fake_save) + token = service.register_capability( + connection_id=uuid4(), principal_id="", destination_id="chat-1", + session_id="sess-1", action_kind="redaction", action_target="strict", + ) + action = _make_action(callback_token=token) + handled = await service.handle_action_callback(action, db_session) + assert handled is True + assert saved["policy"] == "strict" + + +@pytest.mark.asyncio +async def test_settings_never_offers_a_model_or_permission_button( + service, db_session, paired_session_with_model +): + action = _make_action(text="/settings") + result = await service.dispatch_command(db_session, action) + # dispatch_command sends via the adapter for settings (card + buttons), + # not just plain text, so assert on what was actually sent. + sent_buttons = [b.text.lower() for msg in adapter.sent for b in msg.buttons] + assert not any("model" in t or "permission" in t for t in sent_buttons) +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py -k settings +``` + +- [ ] **Step 3: Add the command and its dispatch** + +```python +# app/remote/actions.py +_SLASH_COMMANDS: frozenset[str] = frozenset( + {"start", "help", "status", "new", "stop", "unpair", "actions", "settings"} +) +``` + +```python +async def _cmd_settings( + self, db: AsyncSession, action: RemoteInboundAction +) -> RemoteActionResult: + pairing = await self._pairing_service.get_pairing(db, action.connection_id) + if pairing is None: + return RemoteActionResult(status="refused", text="") + session_row = None + if pairing.active_session_id is not None: + session_row = await db.get(ChatSession, pairing.active_session_id) + model = (session_row.model if session_row and session_row.model else "default") + permission_mode = session_row.permission_mode if session_row else "auto" + cfg = load_runtime_settings() + + redaction_targets = [n for n in ("strict", "standard", "off") if n != cfg.remote.outbound_data_policy] + redaction_tokens = { + name: self.register_capability( + connection_id=action.connection_id, principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, session_id=str(pairing.active_session_id or ""), + action_kind="redaction", action_target=name, + ) + for name in redaction_targets + } + scope_targets = [s for s in ("all", "remote_only") if s != pairing.notify_scope] + notify_scope_tokens = { + name: self.register_capability( + connection_id=action.connection_id, principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, session_id=str(pairing.active_session_id or ""), + action_kind="notify_scope", action_target=name, + ) + for name in scope_targets + } + + text, buttons = render_settings_card( + connection_label=pairing.label, + model=model, + permission_mode=permission_mode, + redaction_policy=cfg.remote.outbound_data_policy, + notify_scope=pairing.notify_scope, + redaction_tokens=redaction_tokens, + notify_scope_tokens=notify_scope_tokens, + ) + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.INFORMATIONAL, + ) + ) + return RemoteActionResult(status="sent", text=text) +``` + +Settings are file-backed config, not database rows: `app/api/routes/settings.py`'s own `/remote` route handlers read/write them via `cfg = load_runtime_settings(); cfg.remote.outbound_data_policy` / `cfg.remote = RemoteSettings(outbound_data_policy=..., outbound_pii_policy=cfg.remote.outbound_pii_policy); save_runtime_settings(cfg)` — both synchronous, both imported `from app.core.runtime_settings import RemoteSettings, load_runtime_settings, save_runtime_settings`. `_cmd_settings` above must use `load_runtime_settings()` exactly this way (already reflected in the code block). `PairingService` has no existing "fetch this connection's single pairing" accessor — add one: + +```python +# app/remote/pairing.py — new method on PairingService +async def get_pairing( + self, db: AsyncSession, connection_id: UUID +) -> RemotePairing | None: + return ( + await db.exec( + select(RemotePairing).where(RemotePairing.connection_id == connection_id) + ) + ).first() +``` + +Add a matching unit test in `tests/remote/test_pairing.py` (create-then-fetch, and `None` for an unpaired connection) as part of this task's Step 3, following the existing test file's fixture conventions. + +- [ ] **Step 4: Add `redaction`/`notify_scope` callback branches** + +```python +# app/remote/actions.py — inside handle_action_callback, alongside the +# diff/toollog branch added in Task 6 +if capability is not None and capability.action_kind == "redaction": + del self._capabilities[action.callback_token] + cfg = load_runtime_settings() + cfg.remote = RemoteSettings( + outbound_data_policy=capability.action_target, + outbound_pii_policy=cfg.remote.outbound_pii_policy, + ) + save_runtime_settings(cfg) + return True +if capability is not None and capability.action_kind == "notify_scope": + del self._capabilities[action.callback_token] + pairing = await self._pairing_service.get_pairing(db, capability.connection_id) + if pairing is not None: + pairing.notify_scope = capability.action_target + db.add(pairing) + await db.commit() + if self._projection is not None: + self._projection.set_active_pairing( + connection_id=str(capability.connection_id), + destination_id=pairing.destination_id, + notify_scope=pairing.notify_scope, + principal_id=pairing.principal_id, + ) + return True +``` + +`db` is already available here as the parameter Task 6 added to `handle_action_callback`. `self._projection` is already available as the attribute Task 6's `set_projection` step added. + +- [ ] **Step 5: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py tests/remote/test_runtime.py +uv run ruff check app/remote/actions.py tests/remote/test_actions.py +uv run ty check app/remote/actions.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/remote/actions.py tests/remote/test_actions.py +git commit -m "feat(remote): add /settings with redaction and notify-scope toggles" +``` + +--- + +### Task 8: Guided project picker and prompt suggestions + +**ACs:** AC-40 + +**Files:** + +- Modify: `app/remote/actions.py` +- Modify: `tests/remote/test_actions.py` + +**Interfaces:** + +- Changes `_cmd_new` to send a project picker instead of clearing the pointer silently, when more than zero authorized Coding projects exist. +- Adds `handle_action_callback` branches for `action_kind in ("project_pick", "prompt_pick")`. +- Consumes `app.services.coding_project_service.list_visible_projects(db, *, kind="coding")` and `app.remote.inbound.RemoteInboundService.new_task`. + +- [ ] **Step 1: Write failing picker-flow tests** + +```python +# tests/remote/test_actions.py (add) +@pytest.mark.asyncio +async def test_cmd_new_with_projects_shows_picker_not_bare_ack( + service, db_session, adapter, one_coding_project +): + action = _make_action(text="/new") + await service.dispatch_command(db_session, action) + assert any("Which project" in m.text for m in adapter.sent) + + +@pytest.mark.asyncio +async def test_project_pick_then_shows_prompt_suggestions( + service, db_session, adapter, one_coding_project +): + token = service.register_capability( + connection_id=uuid4(), principal_id="p", destination_id="chat-1", + session_id="", action_kind="project_pick", action_target=str(one_coding_project.id), + ) + action = _make_action(callback_token=token) + handled = await service.handle_action_callback(action, db_session) + assert handled is True + assert any("Fix failing tests" in m.text or "Review my changes" in m.text for m in adapter.sent) + + +@pytest.mark.asyncio +async def test_free_form_text_still_works_after_picker_shown( + service, db_session, inbound_service, one_coding_project +): + action = _make_action(text="Refactor the login flow") + result = await inbound_service.handle_text(db_session, action) + assert result.status in ("accepted", "queued", "pending") +``` + +- [ ] **Step 2: Run and confirm failure** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py -k "picker or project_pick" +``` + +- [ ] **Step 3: Extend `_cmd_new` and add the picker/suggestion callbacks** + +```python +# app/remote/actions.py — replace the body of _cmd_new with: +async def _cmd_new( + self, db: AsyncSession, action: RemoteInboundAction +) -> RemoteActionResult: + from app.remote.inbound import RemoteInboundService + + inbound_service = RemoteInboundService(pairing_service=self._pairing_service) + await inbound_service.new_task(db, action) + + projects = await list_visible_projects(db, kind="coding") + if not projects: + return RemoteActionResult(status="cleared", text="Started a new task. What would you like to work on?") + + tokens = [ + ( + self.register_capability( + connection_id=action.connection_id, principal_id=action.principal.principal_id, + destination_id=action.principal.destination_id, session_id="", + action_kind="project_pick", action_target=str(project.id), + ), + project.name, + ) + for project in projects[:5] + ] + text, buttons = render_project_picker(projects=tokens) + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, destination_id=action.principal.destination_id, + text=text, buttons=buttons, priority=RemoteOutboundPriority.INFORMATIONAL, + ) + ) + return RemoteActionResult(status="cleared", text=text) +``` + +```python +# app/remote/actions.py — new callback branches inside handle_action_callback +_CURATED_PROMPTS = ( + ("Fix failing tests", "Fix failing tests"), + ("Review my changes", "Review my changes"), + ("Add a feature…", "I'd like to add a feature. Ask me what before starting."), +) + +if capability is not None and capability.action_kind == "project_pick": + del self._capabilities[action.callback_token] + project = await get_project(db, UUID(capability.action_target)) + if project is None: + return True + last_session = await self._find_last_session_for_project(db, project.id) + suggestion_tokens = [ + ( + self.register_capability( + connection_id=capability.connection_id, principal_id=capability.principal_id, + destination_id=capability.destination_id, session_id="", + action_kind="prompt_pick", action_target=f"{project.id}:{prompt_text}", + ), + label, + ) + for label, prompt_text in _CURATED_PROMPTS + ] + continue_token = None + if last_session is not None: + continue_token = self.register_capability( + connection_id=capability.connection_id, principal_id=capability.principal_id, + destination_id=capability.destination_id, session_id="", + action_kind="prompt_pick", action_target=f"{project.id}:__continue__:{last_session.id}", + ) + text, buttons = render_prompt_suggestions( + project_name=project.name, + context_line=f"Last session {self._relative_time(last_session)}" if last_session else "No prior sessions", + suggestions=suggestion_tokens, + continue_token=continue_token, + ) + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=capability.connection_id, destination_id=capability.destination_id, + text=text, buttons=buttons, priority=RemoteOutboundPriority.INFORMATIONAL, + ) + ) + return True + +if capability is not None and capability.action_kind == "prompt_pick": + del self._capabilities[action.callback_token] + from app.remote.inbound import RemoteInboundService + + project_id_str, _, remainder = capability.action_target.partition(":") + inbound_service = RemoteInboundService(pairing_service=self._pairing_service) + if remainder.startswith("__continue__:"): + session_id = UUID(remainder.removeprefix("__continue__:")) + await inbound_service.continue_task(db, action, session_id) + else: + await self._start_coding_task(db, action, UUID(project_id_str), remainder) + return True +``` + +`_find_last_session_for_project` and `_relative_time` are small private helpers: the first queries the most recent top-level `ChatSession` with `project_id == project.id`, ordered by `created_at` descending; the second formats a `datetime` as `"2h ago"`. The existing `_exec_coding_task` (used by the `/actions` menu's "Coding projects" entry) only creates an empty session and tells the user to "Send your first message" — it does not accept or submit a prompt, so it cannot be reused as-is here. `_start_coding_task` is new, and unlike `_exec_coding_task` it also registers the session with the projection so the resulting turn gets the same live status card and typing indicator a free-typed message would: + +```python +# app/remote/actions.py — new private helper on RemoteActionService +async def _start_coding_task( + self, + db: AsyncSession, + action: RemoteInboundAction, + project_id: UUID, + prompt_text: str, +) -> None: + from app.services.chat_service import create_chat_session + from app.services.coding_project_service import get_project + from app.remote.inbound import RemoteInboundService + + project = await get_project(db, project_id) + if project is None: + await self._reply_text(action.principal.destination_id, "Project not found.") + return + + chat = await create_chat_session(db) + chat.mode = "coding" + chat.project_id = project.id + chat.tags = ["remote_origin", f"remote_connection:{action.connection_id}"] + db.add(chat) + await db.commit() + + pairing = await self._pairing_service.authorize( + db, connection_id=action.connection_id, principal_id=action.principal.principal_id, + ) + if pairing is not None: + pairing.active_session_id = chat.id + db.add(pairing) + await db.commit() + + if self._projection is not None: + self._projection.register_session( + str(chat.id), + connection_id=str(action.connection_id), + destination_id=action.principal.destination_id, + tags=frozenset({"remote_origin", f"remote_connection:{action.connection_id}"}), + ) + + prompt_action = replace(action, text=prompt_text) + inbound_service = RemoteInboundService(pairing_service=self._pairing_service) + result = await inbound_service.handle_text(db, prompt_action) + + if self._projection is not None: + self._projection.begin_phone_turn( + str(chat.id), + connection_id=str(action.connection_id), + destination_id=action.principal.destination_id, + principal_id=action.principal.principal_id, + title=project.name, + status=result.status, + ) +``` + +`replace` is `dataclasses.replace` — `RemoteInboundAction` is frozen, so build a copy with `text` overridden rather than mutating it. Import it at the top of `actions.py` alongside the module's other `dataclasses` imports. + +- [ ] **Step 4: Run and confirm pass** + +```powershell +uv run pytest --no-cov -q tests/remote/test_actions.py +uv run ruff check app/remote/actions.py tests/remote/test_actions.py +uv run ty check app/remote/actions.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/remote/actions.py tests/remote/test_actions.py +git commit -m "feat(remote): guided project picker and prompt suggestions for /new" +``` + +--- + +### Task 9: Gate cards adopt the shared renderer + +**ACs:** AC-24 (revised, applied to gates) + +**Files:** + +- Modify: `app/remote/gates.py` +- Modify: `tests/remote/test_gates.py` + +**Interfaces:** + +- `RemoteGateBridge.on_gate` builds its text/buttons via `formatting.render_gate_card` instead of inline f-strings. + +- [ ] **Step 1: Write a failing escaping test for gate cards** + +```python +# tests/remote/test_gates.py (add) +def test_permission_card_escapes_tool_name(bridge): + bridge.on_gate( + "sess-1", "permission_asked", {"tool": ""}, + connection_id=uuid4(), destination_id="chat-1", + ) + sent_text = bridge._adapter.sent[0].text # type: ignore[attr-defined] + assert "", + ) + action = _make_action( + kind=RemoteInboundActionKind.CALLBACK, + callback_token=token, + connection_id=connection_id, + ) + + handled = await service.handle_action_callback(action, MagicMock()) + + assert handled is True + assert adapter.calls == ["answer_callback", "send"] + assert "<script>" in adapter.sent_texts[0] + assert "", "tests/conftest.py"], + tool_call_count=4, + diff_token="diff-tok", + toollog_token="log-tok", + ) + assert "" not in text + assert "<script>" in text + assert "1m 12s" in text + assert "4 tool calls" in text + assert buttons == ( + RemoteButton(text="\U0001f4c4 Full diff", token="diff-tok"), + RemoteButton(text="\U0001f9fe Tool log", token="log-tok"), + ) + + +def test_render_done_card_includes_response_text_for_tool_free_turns(): + text, _ = formatting.render_done_card( + title="What's the weather like", + elapsed_seconds=2.0, + response_text="It's sunny and 72F.", + summary_lines=[], + tool_call_count=0, + diff_token=None, + toollog_token=None, + ) + assert "It's sunny and 72F." in text + + +def test_render_done_card_escapes_response_text(): + text, _ = formatting.render_done_card( + title="Echo", + elapsed_seconds=1.0, + response_text="", + summary_lines=[], + tool_call_count=0, + diff_token=None, + toollog_token=None, + ) + assert "" not in text + assert "<script>" in text + + +def test_render_done_card_omits_buttons_when_no_tokens(): + _, buttons = formatting.render_done_card( + title="No-op turn", + elapsed_seconds=1.0, + summary_lines=[], + tool_call_count=0, + diff_token=None, + toollog_token=None, + ) + assert buttons == () + + +def test_render_permission_card_shows_command_and_severity_icon(): + text, buttons = formatting.render_permission_card( + tool="shell", + command="rm -rf build/", + severity="high", + agent="evoflux", + always_glob=None, + always_token=None, + once_token="once-tok", + reject_token="reject-tok", + ) + assert "rm -rf build/" in text + assert "\U0001f534" in text # red circle = high severity + assert "shell" in text + assert "evoflux" in text + assert buttons == ( + RemoteButton(text="Allow once", token="once-tok"), + RemoteButton(text="Reject", token="reject-tok"), + ) + + +def test_render_permission_card_escapes_command(): + text, _ = formatting.render_permission_card( + tool="shell", + command="echo ", + severity="elevated", + agent="evoflux", + always_glob=None, + always_token=None, + once_token="once-tok", + reject_token="reject-tok", + ) + assert "" not in text + assert "<script>" in text + + +def test_render_permission_card_adds_allow_for_session_button_with_glob(): + text, buttons = formatting.render_permission_card( + tool="shell", + command="git push origin main", + severity="elevated", + agent="evoflux", + always_glob="git push *", + always_token="always-tok", + once_token="once-tok", + reject_token="reject-tok", + ) + assert "git push *" in text or any("git push *" in b.text for b in buttons) + assert buttons == ( + RemoteButton(text="Allow once", token="once-tok"), + RemoteButton( + text="\U0001f512 Allow for session — git push *", token="always-tok" + ), + RemoteButton(text="Reject", token="reject-tok"), + ) + + +def test_render_permission_card_omits_always_button_without_glob(): + _, buttons = formatting.render_permission_card( + tool="read", + command="tests/test_auth.py", + severity="normal", + agent="evoflux", + always_glob=None, + always_token=None, + once_token="once-tok", + reject_token="reject-tok", + ) + assert len(buttons) == 2 + + +def test_render_permission_resolved_card_states_decision_and_has_no_buttons(): + text, buttons = formatting.render_permission_resolved_card( + command="rm -rf build/", resolution="once" + ) + assert "rm -rf build/" in text + assert "Allowed once" in text + assert buttons == () + + +def test_render_permission_resolved_card_escapes_command(): + text, _ = formatting.render_permission_resolved_card( + command="", resolution="reject" + ) + assert "" not in text + assert "<script>" in text + + +def test_render_settings_card_shows_mode_model_and_agent_buttons(): + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="anthropic:claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={"ask": "m1", "auto": "m2"}, + agent_tokens={"evoflux": "a1", "explorer": "a2"}, + model_tokens={"anthropic:claude-sonnet-5": "d1"}, + ) + assert "ask" in text + assert "anthropic:claude-sonnet-5" in text + assert "evoflux" in text + button_tokens = {b.token for b in buttons} + assert {"m1", "m2", "a1", "a2", "d1"} <= button_tokens + + +def test_render_settings_card_shows_response_mode_and_its_toggle(): + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={"summary": "r1", "live": "r2"}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + ) + assert "summary" in text + button_tokens = {b.token for b in buttons} + assert {"r1", "r2"} <= button_tokens + + +def test_render_settings_card_shows_configured_provider_count() -> None: + text, _ = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + configured_provider_count=3, + ) + assert "Providers" in text + assert "3" in text + + +def test_render_settings_card_omits_providers_line_when_not_supplied() -> None: + text, _ = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + ) + assert "Providers" not in text + + +def test_render_settings_card_never_offers_a_bypass_button(): + _, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="anthropic:claude-sonnet-5", + permission_mode="auto", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={"auto": "m1", "bypass": "should-never-appear"}, + agent_tokens={}, + model_tokens={}, + ) + assert "should-never-appear" not in {b.token for b in buttons} + assert not any("bypass" in b.text.lower() for b in buttons) + + +def test_render_settings_card_omits_redaction_section_when_not_supplied(): + text, _ = formatting.render_settings_card( + connection_label="evoflux-api", + model="anthropic:claude-sonnet-5", + permission_mode="auto", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + ) + assert "Outbound redaction" not in text + assert "Notifications" not in text + + +def test_render_live_status_card_shows_title_elapsed_and_activity() -> None: + text, buttons = formatting.render_live_status_card( + title="Fix failing tests", + elapsed_seconds=72.0, + activity_lines=["\U0001f4ad explorer thinking", "\U0001f527 grep foo"], + ) + assert "Fix failing tests" in text + assert "1m 12s" in text + assert "explorer thinking" in text + assert "grep foo" in text + assert buttons == () + + +def test_render_live_status_card_escapes_title() -> None: + text, _ = formatting.render_live_status_card( + title="", elapsed_seconds=1.0, activity_lines=[] + ) + assert "" not in text + assert "<script>" in text + + +def test_render_live_status_card_with_no_activity_yet_still_renders() -> None: + text, buttons = formatting.render_live_status_card( + title="New task", elapsed_seconds=0.5, activity_lines=[] + ) + assert "New task" in text + assert buttons == () + + +def test_render_error_card_escapes_message(): + text, buttons = formatting.render_error_card( + title="Add rate limiter", + message="ModuleNotFoundError: ", + toollog_token="log-tok", + ) + assert "<redis>" in text + assert len(buttons) == 1 + + +def test_render_settings_card_now_emits_mode_and_model_buttons(): + """Supersedes the old AC-41 boundary (model/mode were never buttons, + "desktop only" read-only text) — AC-48/49 revise that: mode and model + are now remotely settable, so this card must offer buttons for both.""" + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={"ask": "m1"}, + agent_tokens={}, + model_tokens={"claude-sonnet-5": "d1"}, + redaction_policy="standard", + notify_scope="all", + redaction_tokens={"strict": "r1", "off": "r2"}, + notify_scope_tokens={"all": "n1", "remote_only": "n2"}, + ) + button_texts = [b.text for b in buttons] + assert any("model" in t.lower() for t in button_texts) + assert any("mode" in t.lower() for t in button_texts) + assert any("strict" in t.lower() for t in button_texts) + + +def test_render_onboarding_card_names_the_phone_and_states_auto_mode() -> None: + text, buttons = formatting.render_onboarding_card( + label="My Phone", + default_permission_mode="auto", + setup_token="setup-tok", + health_token="health-tok", + start_token="start-tok", + ) + assert "My Phone" in text + assert "connected to EvoFlux" in text + assert "auto" in text + assert "won't ask" in text + assert len(buttons) == 3 + assert {b.token for b in buttons} == {"setup-tok", "health-tok", "start-tok"} + + +def test_render_onboarding_card_escapes_label() -> None: + text, _ = formatting.render_onboarding_card( + label="", + default_permission_mode="auto", + setup_token="s", + health_token="h", + start_token="w", + ) + assert "" not in text + assert "<script>" in text + + +def test_render_onboarding_card_states_non_auto_mode_without_the_auto_warning() -> None: + text, _ = formatting.render_onboarding_card( + label="My Phone", + default_permission_mode="ask", + setup_token="s", + health_token="h", + start_token="w", + ) + assert "ask" in text + assert "won't ask" not in text + + +def test_render_gate_card_escapes_action_labels(): + text, buttons = formatting.render_gate_card( + title="Approve deploy?", + body="Ready for production", + actions=[("yes-tok", "Accept "), ("no-tok", "Reject & wait")], + ) + assert len(buttons) == 2 + assert buttons[0].text == "Accept <risks>" + assert buttons[1].text == "Reject & wait" + assert "" not in buttons[0].text + assert "& wait" not in buttons[1].text + + +def test_render_settings_card_escapes_toggle_names(): + text, buttons = formatting.render_settings_card( + connection_label="evoflux-api", + model="claude-sonnet-5", + permission_mode="ask", + agent_name="evoflux", + response_mode="summary", + response_mode_tokens={}, + mode_tokens={}, + agent_tokens={}, + model_tokens={}, + redaction_policy="standard", + notify_scope="all", + redaction_tokens={"": "r1", "off": "r2"}, + notify_scope_tokens={"all": "n1", "remote & local": "n2"}, + ) + button_texts = [b.text for b in buttons] + assert any("<strict>" in t for t in button_texts) + assert any("remote & local" in t for t in button_texts) + + +def test_render_prompt_suggestions_escapes_labels(): + text, buttons = formatting.render_prompt_suggestions( + project_name="", + context_line="line & context", + suggestions=[("sug-tok-1", "Suggest "), ("sug-tok-2", "Other & more")], + continue_token="cont-tok", + ) + assert len(buttons) == 3 # continue + 2 suggestions + # First button is continue + assert buttons[0].text == "▶ Continue last session" + # Suggestion buttons should have escaped labels + assert buttons[1].text == "Suggest <tag>" + assert buttons[2].text == "Other & more" + + +def test_render_health_card_shows_each_check_with_its_status_icon(): + text = formatting.render_health_card( + [ + {"id": "db", "label": "Database", "status": "ok", "detail": "connected"}, + { + "id": "disk", + "label": "Disk space", + "status": "fail", + "detail": "2.1 GB free", + }, + ] + ) + assert "Database" in text + assert "Disk space" in text + assert "✅" in text # ok icon + assert "❌" in text # fail icon + + +def test_render_health_card_escapes_detail_text(): + text = formatting.render_health_card( + [ + { + "id": "x", + "label": "X", + "status": "warn", + "detail": "", + } + ] + ) + assert "" not in text + assert "<script>" in text + + +def test_render_changes_card_lists_files_with_line_counts_and_buttons(): + text, buttons = formatting.render_changes_card( + title="Fix auth tests", + files=[ + ("app/auth.py", "modified", 10, 2), + ("tests/test_auth.py", "added", 5, 0), + ], + additions=15, + deletions=2, + file_tokens={"app/auth.py": "tok-1", "tests/test_auth.py": "tok-2"}, + ) + assert "app/auth.py" in text + assert "+15" in text + assert "-2" in text + assert {b.token for b in buttons} == {"tok-1", "tok-2"} + + +def test_render_changes_card_escapes_file_paths(): + text, _ = formatting.render_changes_card( + title="Task", + files=[("