diff --git a/app/agent/mode/team/team.py b/app/agent/mode/team/team.py index a6bdb6733..da45f252e 100644 --- a/app/agent/mode/team/team.py +++ b/app/agent/mode/team/team.py @@ -320,12 +320,6 @@ def __init__( self.session_tags: frozenset[str] = session_tags or frozenset() self.extra_workspace_paths: list[str] = extra_workspace_paths or [] self.read_only_paths: list[str] = read_only_paths or [] - # ASDD autopilot chain state, per session: how many phases it has run - # without the user saying anything, and what the change looked like at - # the last hop. Runtime state on purpose — a restart is a fine reason - # to let a chain start over. - self._asdd_chain: dict[str, tuple[int, tuple[str, int, int]]] = {} - self.mailbox = TeamMailbox(on_message=self._on_message) # Guard: only emit done after at least one user turn has started @@ -392,10 +386,6 @@ def reserve_user_turn(self) -> None: path instead of letting it race the still-pending snapshot/persist work. """ self._has_active_turn = True - # A person speaking ends whatever chain autopilot was on and starts - # its budget over: the cap exists to bound an unattended run, not to - # ration a session someone is sitting in. - self._asdd_chain.pop(self.lead.session_id or "", None) def release_user_turn_reservation(self) -> None: """Release a deferred ingress reservation that failed before activation.""" @@ -1375,12 +1365,6 @@ async def _try_emit_done(self) -> None: if await self._activate_goal_continuation(session_id): return - # After the durable goal, because a goal is the user's standing - # instruction for the whole session and an ASDD change is one - # piece of work inside it. - if await self._activate_asdd_autopilot(session_id): - return - try: await self._emit_completion_notification(session_id) await self._emit_turn_changes(session_id) @@ -1621,154 +1605,6 @@ async def _activate_goal_continuation(self, session_id: str) -> bool: logger.info("team_goal_continued session_id={}", session_id) return True - async def _last_asdd_change(self, session_uuid: UUID) -> str | None: - """The change the most recent phase prompt in this session named. - - The binding is the transcript itself. A phase prompt says ``Work on - ASDD change ```` and nothing else in the session does, so the - newest user message that matches names the change this chat is - carrying — no second identifier, and no session state to keep in sync - with the files (rule 2). - - Only user messages count. An agent that mentions a change while - reporting on it is describing work, not being asked to do it. - """ - - from app.services.asdd_autopilot import change_id_in - - db_factory = resolve_db_factory(self.lead.db_factory) - async with db_factory() as db: - rows = ( - await db.exec( - select(SessionMessage) - .where( - SessionMessage.session_id == session_uuid, - SessionMessage.role == "user", - ) - .order_by(col(SessionMessage.created_at).desc()) - .limit(8) - ) - ).all() - for row in rows: - change_id = change_id_in(row.content) - if change_id: - return change_id - return None - - async def _activate_asdd_autopilot(self, session_id: str) -> bool: - """Run the next ASDD phase for a change autopilot is carrying. - - This is the Continue button, pressed by the product. Whether a hop is - allowed at all is the rail's decision, reused verbatim — autopilot - off, a `hold`, an unmet gate or a blocker all come back as "no next - phase" and end the chain. - """ - - if not self.workspace: - return False - try: - session_uuid = UUID(session_id) - except ValueError: - return False - - from app.services.asdd_autopilot import next_hop, stop_reason - - try: - change_id = await self._last_asdd_change(session_uuid) - except Exception as exc: # noqa: BLE001 - never break the barrier - logger.warning( - "asdd_autopilot_lookup_failed session_id={} error={}", session_id, exc - ) - return False - if not change_id: - return False - - hops, previous = self._asdd_chain.get(session_id, (0, None)) - - try: - hop = await asyncio.to_thread(next_hop, self.workspace, change_id) - except Exception as exc: # noqa: BLE001 - never break the barrier - logger.warning( - "asdd_autopilot_failed session_id={} change_id={} error={}", - session_id, - change_id, - exc, - ) - return False - if hop is None: - self._asdd_chain.pop(session_id, None) - return False - - stop = stop_reason(hops, previous, hop) - if stop is not None: - logger.info( - "asdd_autopilot_stopped session_id={} change_id={} reason={} hops={}", - session_id, - change_id, - stop, - hops, - ) - self._asdd_chain.pop(session_id, None) - return False - - try: - await stream_store.init_turn(session_id, keep_subscribers=True) - except Exception as exc: - logger.warning("team_init_asdd_turn_failed error={}", exc) - return False - - directive_id: UUID | None = None - try: - db_factory = resolve_db_factory(self.lead.db_factory) - async with db_factory() as db: - directive = await save_message( - db, - session_uuid, - HumanMessage(content=hop.prompt), - exclude_from_context=False, - extra={ - "command": "asdd_autopilot", - "asdd_change_id": hop.change_id, - "asdd_skill": hop.skill, - "hidden_from_user": True, - "hidden_from_summary": True, - }, - ) - directive_id = directive.id - await db.commit() - except Exception as exc: # noqa: BLE001 - preserve completion barrier - logger.warning( - "asdd_autopilot_directive_failed session_id={} error={}", - session_id, - exc, - ) - return False - - self._asdd_chain[session_id] = (hops + 1, hop.progress) - self._has_active_turn = True - try: - self.lead.activate_for_continuation() - except AlreadyWorkingError: - # Another activation won the race; drop the unused directive and - # let that turn's own boundary reassess the change. - if directive_id is not None: - async with db_factory() as db: - stale = await db.get(SessionMessage, directive_id) - if stale is not None: - await db.delete(stale) - await db.commit() - return True - - logger.info( - "asdd_autopilot_advanced session_id={} change_id={} skill={} hop={}", - session_id, - hop.change_id, - hop.skill, - hops + 1, - ) - return True - - # ------------------------------------------------------------------ # User message entry point # ------------------------------------------------------------------ diff --git a/app/agent/outbound_redaction.py b/app/agent/outbound_redaction.py index 6f1e783f2..c424ba239 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 eff35ee15..81a4f453d 100644 --- a/app/api/app.py +++ b/app/api/app.py @@ -25,6 +25,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 @@ -144,6 +145,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 @@ -269,6 +282,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() @@ -356,6 +372,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 000000000..bd27cab5a --- /dev/null +++ b/app/api/routes/remote.py @@ -0,0 +1,392 @@ +"""``/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, RemotePairingAwareAdapter +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: + """Return the completed pairing for *connection_id*, if any. + + A pending phone-first pairing code (:meth:`PairingService.issue_pair_code`) + is stored as a placeholder ``RemotePairing`` row with an empty + ``principal_id`` until ``/pair `` is verified. Excluding it here is + what lets the UI keep showing the code instead of jumping to "Paired" + the instant a code is issued. + """ + result = await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == connection_id, + RemotePairing.principal_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, + capabilities=sorted(status.capabilities), + ) + + +async def _connection_response( + session: AsyncSession, connection: RemoteConnection +) -> RemoteConnectionResponse: + return RemoteConnectionResponse( + id=connection.id, + adapter=connection.adapter, + provider=connection.provider, + endpoint_url=connection.endpoint_url, + 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, + adapter=body.adapter, + provider=body.provider, + endpoint_url=body.endpoint_url, + ) + 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) + + # Adapters whose channel is bound to a fixed contact (iMessage) must be + # reset to discovery mode so the next generated pairing code can bind a + # (possibly different) contact. Telegram's adapter does not implement + # this protocol, so this is a no-op there. + adapter = remote_runtime.adapter + if isinstance(adapter, RemotePairingAwareAdapter): + adapter.clear_pairing() + await adapter.start() + if remote_runtime.projection is not None: + remote_runtime.projection.clear_active_pairing() + + +@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 5e811d750..f10afef0e 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, @@ -55,6 +56,7 @@ TeamSpawnSettingsBody, ProviderVisibleModelsResponse, ProvidersListBody, + RemoteSettingsBody, SandboxSettingsBody, SeedInstallRequest, SeedInstallResponse, @@ -635,6 +637,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 000000000..6e3e40ae0 --- /dev/null +++ b/app/api/schemas/remote.py @@ -0,0 +1,171 @@ +"""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 ( + RemoteAdapterKind, + RemoteConnectionState, + RemoteErrorClass, + RemoteProviderKind, +) + +__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) + adapter: RemoteAdapterKind = RemoteAdapterKind.TELEGRAM + provider: RemoteProviderKind | None = Field(default=None) + endpoint_url: str | None = Field(default=None, max_length=2048) + 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 + capabilities: list[str] = Field(default_factory=list) + + +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 + provider: str | None + endpoint_url: str | None + 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 fbdaf669c..c55a3fe6a 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 000000000..5468bd93e --- /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 642376c9b..baee476a8 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 01d989b4b..7fcfc4d7a 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 = "00000068" +SCHEMA_HEAD = "00000080" #: Revisions this build no longer ships, mapped to the newest ancestor it does. #: @@ -32,6 +32,15 @@ "00000062": "00000054", } +# The remote-channel branch also used 00000067–00000073 before it was merged +# with main. Main owns 00000067–00000068 now; the remote chain is moved to +# 00000074–00000080. Databases stamped by the old remote chain must be replayed +# from 00000066 so both main's cleanup and the new idempotent remote revisions +# are applied. Revisions 67 and 68 overlap main's ids, so those two are only +# treated as legacy remote revisions when the remote tables prove their origin. +LEGACY_REMOTE_REVISIONS = frozenset(f"{revision:08d}" for revision in range(69, 74)) +LEGACY_REMOTE_CONFLICT_REVISIONS = frozenset({"00000067", "00000068"}) + @dataclass(frozen=True) class SchemaStatus: @@ -58,10 +67,18 @@ def inspect_database_schema() -> SchemaStatus: if not db_path.is_file(): return SchemaStatus(current=None, at_head=False, compatible=True) + remote_tables_present = False try: database_uri = f"{db_path.resolve().as_uri()}?mode=ro" with sqlite3.connect(database_uri, uri=True, timeout=2) as db: row = db.execute("SELECT version_num FROM alembic_version").fetchone() + remote_tables_present = ( + db.execute( + "SELECT 1 FROM sqlite_master " + "WHERE type = 'table' AND name = 'remote_connections'" + ).fetchone() + is not None + ) except sqlite3.OperationalError as exc: if "no such table" in str(exc).lower(): return SchemaStatus(current=None, at_head=False, compatible=True) @@ -83,7 +100,11 @@ def inspect_database_schema() -> SchemaStatus: return SchemaStatus( current=current, at_head=False, - compatible=current in bundled or current in RETIRED_REVISIONS, + compatible=( + current in bundled + or current in RETIRED_REVISIONS + or (current in LEGACY_REMOTE_REVISIONS and remote_tables_present) + ), ) @@ -112,7 +133,21 @@ def repair_retired_revision(sqlite_path: str | Path | None = None) -> str | None with sqlite3.connect(db_path, timeout=5) as db: row = db.execute("SELECT version_num FROM alembic_version").fetchone() current = str(row[0]) if row and row[0] else None - ancestor = RETIRED_REVISIONS.get(current or "") + remote_tables_present = ( + db.execute( + "SELECT 1 FROM sqlite_master " + "WHERE type = 'table' AND name = 'remote_connections'" + ).fetchone() + is not None + ) + is_legacy_remote_revision = current in LEGACY_REMOTE_REVISIONS or ( + current in LEGACY_REMOTE_CONFLICT_REVISIONS and remote_tables_present + ) + ancestor = ( + "00000066" + if is_legacy_remote_revision and remote_tables_present + else RETIRED_REVISIONS.get(current or "") + ) if ancestor is None: return None db.execute("UPDATE alembic_version SET version_num = ?", (ancestor,)) diff --git a/app/migrations/env.py b/app/migrations/env.py index 2d5e17cc5..59a85af1d 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_remove_asdd_delegation_metadata.py b/app/migrations/versions/00000067_remove_asdd_delegation_metadata.py index 0b80360a4..ba7bfaba9 100644 --- a/app/migrations/versions/00000067_remove_asdd_delegation_metadata.py +++ b/app/migrations/versions/00000067_remove_asdd_delegation_metadata.py @@ -17,9 +17,24 @@ def upgrade() -> None: + bind = op.get_bind() + columns = { + column["name"] for column in sa.inspect(bind).get_columns("delegation_tasks") + } + indexes = { + index["name"] for index in sa.inspect(bind).get_indexes("delegation_tasks") + } + if ( + "asdd_change_id" not in columns + and "ix_delegation_tasks_asdd_change_id" not in indexes + ): + return + with op.batch_alter_table("delegation_tasks", schema=None) as batch_op: - batch_op.drop_index("ix_delegation_tasks_asdd_change_id") - batch_op.drop_column("asdd_change_id") + if "ix_delegation_tasks_asdd_change_id" in indexes: + batch_op.drop_index("ix_delegation_tasks_asdd_change_id") + if "asdd_change_id" in columns: + batch_op.drop_column("asdd_change_id") def downgrade() -> None: diff --git a/app/migrations/versions/00000074_create_remote_pairings.py b/app/migrations/versions/00000074_create_remote_pairings.py new file mode 100644 index 000000000..27e3028e5 --- /dev/null +++ b/app/migrations/versions/00000074_create_remote_pairings.py @@ -0,0 +1,101 @@ +"""create remote connection and pairing tables + +Revision ID: 00000074 +Revises: 00000068 +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 = "00000074" +down_revision: Union[str, Sequence[str], None] = "00000068" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + tables = set(sa.inspect(bind).get_table_names()) + if "remote_connections" not in tables: + 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), + ) + indexes = { + index["name"] + for index in sa.inspect(op.get_bind()).get_indexes("remote_connections") + } + if "ix_remote_connections_enabled" not in indexes: + op.create_index( + "ix_remote_connections_enabled", "remote_connections", ["enabled"] + ) + + if "remote_pairings" not in set(sa.inspect(op.get_bind()).get_table_names()): + 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", + ), + ) + indexes = { + index["name"] + for index in sa.inspect(op.get_bind()).get_indexes("remote_pairings") + } + if "ix_remote_pairings_connection" not in indexes: + 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/00000075_add_remote_pairing_notify_scope.py b/app/migrations/versions/00000075_add_remote_pairing_notify_scope.py new file mode 100644 index 000000000..d389668bf --- /dev/null +++ b/app/migrations/versions/00000075_add_remote_pairing_notify_scope.py @@ -0,0 +1,43 @@ +"""Add remote_pairings.notify_scope + +Revision ID: 00000075 +Revises: 00000074 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000075" +down_revision: str | Sequence[str] | None = "00000074" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_pairings") + } + if "notify_scope" not in columns: + op.add_column( + "remote_pairings", + sa.Column( + "notify_scope", + sa.String(20), + nullable=False, + server_default="all", + ), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_pairings") + } + if "notify_scope" in columns: + op.drop_column("remote_pairings", "notify_scope") diff --git a/app/migrations/versions/00000076_add_remote_pairing_response_mode.py b/app/migrations/versions/00000076_add_remote_pairing_response_mode.py new file mode 100644 index 000000000..78f8fe8de --- /dev/null +++ b/app/migrations/versions/00000076_add_remote_pairing_response_mode.py @@ -0,0 +1,43 @@ +"""Add remote_pairings.response_mode + +Revision ID: 00000076 +Revises: 00000075 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000076" +down_revision: str | Sequence[str] | None = "00000075" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_pairings") + } + if "response_mode" not in columns: + op.add_column( + "remote_pairings", + sa.Column( + "response_mode", + sa.String(20), + nullable=False, + server_default="live", + ), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_pairings") + } + if "response_mode" in columns: + op.drop_column("remote_pairings", "response_mode") diff --git a/app/migrations/versions/00000077_add_remote_pairing_code_fields.py b/app/migrations/versions/00000077_add_remote_pairing_code_fields.py new file mode 100644 index 000000000..a6c604c17 --- /dev/null +++ b/app/migrations/versions/00000077_add_remote_pairing_code_fields.py @@ -0,0 +1,45 @@ +"""Add remote_pairings pair_code_hash and pair_code_expires_at + +Revision ID: 00000077 +Revises: 00000076 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000077" +down_revision: str | Sequence[str] | None = "00000076" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_pairings") + } + if "pair_code_hash" not in columns: + op.add_column( + "remote_pairings", + sa.Column("pair_code_hash", sa.String(128), nullable=True), + ) + if "pair_code_expires_at" not in columns: + op.add_column( + "remote_pairings", + sa.Column("pair_code_expires_at", sa.DateTime(), nullable=True), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_pairings") + } + if "pair_code_expires_at" in columns: + op.drop_column("remote_pairings", "pair_code_expires_at") + if "pair_code_hash" in columns: + op.drop_column("remote_pairings", "pair_code_hash") diff --git a/app/migrations/versions/00000078_add_remote_connection_imessage_fields.py b/app/migrations/versions/00000078_add_remote_connection_imessage_fields.py new file mode 100644 index 000000000..07a8821a0 --- /dev/null +++ b/app/migrations/versions/00000078_add_remote_connection_imessage_fields.py @@ -0,0 +1,47 @@ +"""Add iMessage remote connection metadata. + +Revision ID: 00000078 +Revises: 00000077 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +from app.models.chat import TZDateTime + +revision: str = "00000078" +down_revision: str | Sequence[str] | None = "00000077" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_connections") + } + if "endpoint_url" not in columns: + op.add_column( + "remote_connections", + sa.Column("endpoint_url", sa.String(2048), nullable=True), + ) + if "inbound_watermark_at" not in columns: + op.add_column( + "remote_connections", + sa.Column("inbound_watermark_at", TZDateTime(), nullable=True), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_connections") + } + if "inbound_watermark_at" in columns: + op.drop_column("remote_connections", "inbound_watermark_at") + if "endpoint_url" in columns: + op.drop_column("remote_connections", "endpoint_url") diff --git a/app/migrations/versions/00000079_add_remote_connection_provider.py b/app/migrations/versions/00000079_add_remote_connection_provider.py new file mode 100644 index 000000000..11cb79076 --- /dev/null +++ b/app/migrations/versions/00000079_add_remote_connection_provider.py @@ -0,0 +1,38 @@ +"""Add remote connection provider metadata. + +Revision ID: 00000079 +Revises: 00000078 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "00000079" +down_revision: str | Sequence[str] | None = "00000078" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_connections") + } + if "provider" not in columns: + op.add_column( + "remote_connections", + sa.Column("provider", sa.String(32), nullable=True), + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_connections") + } + if "provider" in columns: + op.drop_column("remote_connections", "provider") diff --git a/app/migrations/versions/00000080_fix_remote_connection_watermark_type.py b/app/migrations/versions/00000080_fix_remote_connection_watermark_type.py new file mode 100644 index 000000000..9702474af --- /dev/null +++ b/app/migrations/versions/00000080_fix_remote_connection_watermark_type.py @@ -0,0 +1,74 @@ +"""Fix the iMessage inbound watermark's column type. + +The prior column (``inbound_watermark_at``, a ``DateTime``) assumed a +timestamp cursor. Neither real provider's catchup API works that way: imsg's +``messages.after`` takes an exclusive integer ``since_rowid``, and +BlueBubbles pages by its own opaque string cursor. Nothing had ever written +a real value through the old column, so there is no data to migrate — the +replacement is a plain opaque string column each provider adapter +interprets on its own terms (see ``app/remote/imessage/provider.py``). + +Revision ID: 00000080 +Revises: 00000079 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +from app.models.chat import TZDateTime + +revision: str = "00000080" +down_revision: str | Sequence[str] | None = "00000079" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_connections") + } + has_old = "inbound_watermark_at" in columns + has_cursor = "inbound_watermark_cursor" in columns + if has_old: + count = ( + op.get_bind() + .execute( + sa.text( + "SELECT COUNT(*) FROM remote_connections " + "WHERE inbound_watermark_at IS NOT NULL" + ) + ) + .scalar_one() + ) + if count: + raise RuntimeError( + "Cannot convert stored remote timestamp watermarks to provider " + "cursors automatically; export or clear those values before retrying." + ) + if has_old or not has_cursor: + with op.batch_alter_table("remote_connections") as batch_op: + if has_old: + batch_op.drop_column("inbound_watermark_at") + if not has_cursor: + batch_op.add_column( + sa.Column("inbound_watermark_cursor", sa.String(64), nullable=True) + ) + + +def downgrade() -> None: + columns = { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("remote_connections") + } + with op.batch_alter_table("remote_connections") as batch_op: + if "inbound_watermark_cursor" in columns: + batch_op.drop_column("inbound_watermark_cursor") + if "inbound_watermark_at" not in columns: + batch_op.add_column( + sa.Column("inbound_watermark_at", TZDateTime(), nullable=True) + ) diff --git a/app/models/__init__.py b/app/models/__init__.py index 264eb5f67..2a5ff9979 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 .webbridge import ( @@ -28,6 +29,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 000000000..2888c9af2 --- /dev/null +++ b/app/models/remote.py @@ -0,0 +1,117 @@ +"""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)) + provider: str | None = Field( + default=None, + sa_column=Column(sa.String(32), nullable=True), + ) + endpoint_url: str | None = Field( + default=None, + sa_column=Column(sa.String(2048), nullable=True), + ) + inbound_watermark_cursor: str | None = Field( + default=None, + sa_column=Column(sa.String(64), nullable=True), + ) + 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 000000000..0f613f911 --- /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 000000000..ba6ee30d4 --- /dev/null +++ b/app/remote/actions.py @@ -0,0 +1,2039 @@ +"""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, + RemotePairingAwareAdapter, +) +from app.remote.pairing import 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 ──────────────────────────────────────────────────────────── + +_COMMAND_HELP: dict[str, str] = { + "status": "See what your agent is doing now — session, providers, models", + "new": "Set aside the current task and start a fresh one", + "stop": "Stop the active task", + "settings": "Choose permission mode, agent, model, or thinking level", + "health": "Check connection and system health", + "history": "Browse recent sessions; use More sessions to load older work", + "changes": "See files changed by the current task", + "actions": "Open Work/Coding projects, saved workflows, or scheduled tasks", + "clear": "Delete your recent messages in this chat (not the agent's)", + "unpair": "Disconnect this phone from EvoFlux", + "skills": "Browse available skills by work/coding mode", + "skill": "Load a specific skill by name (e.g. /skill coding-change)", + "steer": "Redirect the agent mid-task with a new instruction", + "switch": "Switch to a different active session", + "delete": "Delete unused sessions to keep your history clean", + "agent": "Show current agent info, model, and capabilities", +} +_SLASH_COMMANDS: frozenset[str] = frozenset({"start", "help", *_COMMAND_HELP}) + + +# ── 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: +""" + + "\n".join( + f"/{name} — {description}" for name, description in _COMMAND_HELP.items() + ) + + """ + +Live updates show the active process and response as they arrive. When a task +needs approval, use the buttons on the card; in auto mode, supported commands +continue without an approval prompt. + +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, arg) + 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 == "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) + elif command == "skills": + return await self._cmd_skills(db, action) + elif command == "skill": + return await self._cmd_skill(db, action) + elif command == "switch": + return await self._cmd_switch(db, action) + elif command == "delete": + return await self._cmd_delete(db, action) + elif command == "agent": + return await self._cmd_agent(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, + arg: str = "", + ) -> RemoteActionResult: + """``/start`` entry point: pair with a code, or show help. + + When no pairing is active the user must supply an 8-digit code + (``/start 12345678``). If already paired, the help text is shown. + """ + from app.remote.pairing import ( + PairingCodeExpired, + PairingCodeMismatch, + PairingCodeRateLimited, + ) + + pairing = await self._pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + + # ── Not paired yet: try to pair with the supplied code ────────── + if pairing is None: + code = arg.strip() + if not code or not code.isdigit(): + return RemoteActionResult( + status="pair_prompt", + text=( + "Welcome to EvoFlux!\n\n" + "Open EvoFlux on your desktop and copy the 8-digit " + "pairing code, then send:\n\n" + "/start 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! Send any message to get started.", + ) + + # ── Already paired: show help ─────────────────────────────────── + return RemoteActionResult(status="ok", text=_HELP_TEXT) + + async def _cmd_status( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from sqlalchemy import func, select + + from app.models.chat import ChatSession, SessionMessage, normalize_mode + from app.remote import control + from app.remote.formatting import render_status_dashboard + + 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. + connection_state = "connected" + if self._status_provider is not None: + adapter_status = self._status_provider() + connection_state = adapter_status.state.value + + # Current task and session details. + session = None + if pairing.active_session_id is not None: + session = await db.get(ChatSession, pairing.active_session_id) + + # Provider health. + provider_count = await control.count_configured_providers() + + # Model registry info. + app_mode = normalize_mode(session.mode) if session else None + model_count = len(await control.list_model_ids(app_mode, limit=200)) + provider_groups = ( + await control.list_models_by_provider(app_mode) if app_mode else {} + ) + + # Session activity stats. + message_count = 0 + turn_count = 0 + if session is not None: + message_count = ( + await db.exec( + select(func.count()) + .select_from(SessionMessage) + .where(SessionMessage.session_id == session.id) + ) + ).one() + turn_count = ( + await db.exec( + select(func.count()) + .select_from(SessionMessage) + .where( + SessionMessage.session_id == session.id, + SessionMessage.role == "user", + ) + ) + ).one() + + text = render_status_dashboard( + connection_state=connection_state, + pairing_label=pairing.label or "This phone", + session=session, + provider_count=provider_count, + model_count=model_count, + provider_groups=provider_groups, + message_count=message_count, + turn_count=turn_count, + ) + return RemoteActionResult(status="ok", text=text) + + async def _cmd_skills( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.agent.builtin_skills.catalog import BUNDLED_SKILL_MODES + from app.models.chat import ChatSession, normalize_mode + from app.remote.formatting import render_skills_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") + + app_mode = None + if pairing.active_session_id is not None: + session = await db.get(ChatSession, pairing.active_session_id) + if session is not None: + app_mode = normalize_mode(session.mode) + + text = render_skills_card( + available_skills=BUNDLED_SKILL_MODES, + current_mode=app_mode, + ) + return RemoteActionResult(status="ok", text=text) + + async def _cmd_skill( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + """Load a specific skill by name and inject its instructions as context.""" + from app.remote.formatting import escape, render_skill_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") + + # Parse the skill name from "/skill " or "/skill " + text = (action.text or "").strip() + parts = text.split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + return RemoteActionResult( + status="ok", + text="Usage: /skill \nExample: /skill coding-change", + ) + skill_name = parts[1].strip() + extra_prompt = parts[1].strip() if len(parts) > 1 else "" + + # Find the skill by name. + from app.agent.tools.builtin.skill import discover_skill_records_runtime + + all_skills = discover_skill_records_runtime() + matched = None + for name, record in all_skills.items(): + if name == skill_name: + matched = record + break + if matched is None: + return RemoteActionResult( + status="ok", + text=f"Skill '{escape(skill_name)}' not found.\nUse /skills to browse available skills.", + ) + + # Build the skill card with available metadata. + text, buttons = render_skill_card( + skill_name=matched.name, + description=matched.description or matched.short_description or "", + modes=matched.modes, + extra_prompt=extra_prompt, + ) + + # Send the skill info card. + if self._adapter is not None: + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok", text=text) + + async def _cmd_switch( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + """Switch to a different session by number or show the session list.""" + from sqlmodel import col, select + + from app.models.chat import ChatSession + from app.remote.formatting import render_switch_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") + + # Parse "/switch" or "/switch " + text = (action.text or "").strip() + parts = text.split(maxsplit=1) + number = ( + int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else None + ) + + # Fetch recent sessions. + sessions = list( + await db.exec( + select(ChatSession) + .order_by(col(ChatSession.updated_at).desc()) + .limit(10) + ) + ) + + if number is None: + # Show session list with numbers for selection. + if not sessions: + return RemoteActionResult(status="ok", text="No sessions found.") + text, buttons = render_switch_card( + sessions=sessions, + active_session_id=pairing.active_session_id, + ) + if self._adapter is not None: + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok", text=text) + + # Switch by number. + idx = number - 1 + if idx < 0 or idx >= len(sessions): + return RemoteActionResult( + status="ok", + text=f"Invalid session number. Use /switch to see available sessions (1-{len(sessions)}).", + ) + + target = sessions[idx] + pairing.active_session_id = target.id + db.add(pairing) + await db.commit() + + title = target.title or "Untitled" + return RemoteActionResult( + status="ok", + text=f"✅ Switched to: {title}\nNext message continues this session.", + ) + + async def _cmd_delete( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + """Delete unused sessions by number or show which can be deleted.""" + from sqlmodel import col, select + + from app.models.chat import ChatSession, SessionMessage + from app.remote.formatting import render_delete_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") + + # Parse "/delete" or "/delete " + text = (action.text or "").strip() + parts = text.split(maxsplit=1) + number = ( + int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else None + ) + + # Fetch recent sessions, excluding the active one. + sessions = list( + await db.exec( + select(ChatSession) + .order_by(col(ChatSession.updated_at).desc()) + .limit(15) + ) + ) + deletable = [s for s in sessions if s.id != pairing.active_session_id] + + if number is None: + if not deletable: + return RemoteActionResult(status="ok", text="No sessions to delete.") + text, buttons = render_delete_card(sessions=deletable) + if self._adapter is not None: + await self._send(action.principal.destination_id, text, buttons=buttons) + return RemoteActionResult(status="ok", text=text) + + # Delete by number. + idx = number - 1 + if idx < 0 or idx >= len(deletable): + return RemoteActionResult( + status="ok", + text=f"Invalid session number. Use /delete to see available sessions (1-{len(deletable)}).", + ) + + target = deletable[idx] + title = target.title or "Untitled" + + # Delete all messages first, then the session. + msgs = list( + await db.exec( + select(SessionMessage).where( + col(SessionMessage.session_id) == target.id + ) + ) + ) + for msg in msgs: + await db.delete(msg) + await db.delete(target) + await db.commit() + + return RemoteActionResult( + status="ok", + text=f"🗑️ Deleted: {title}", + ) + + async def _cmd_agent( + self, db: AsyncSession, action: RemoteInboundAction + ) -> RemoteActionResult: + from app.models.chat import ChatSession + from app.remote.formatting import render_agent_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") + + session = None + if pairing.active_session_id is not None: + session = await db.get(ChatSession, pairing.active_session_id) + + # Resolve agent info from config. + agent_name = getattr(session, "agent_name", None) or "(default)" + model = getattr(session, "model", None) or "(default)" + mode = getattr(session, "mode", None) or "work" + thinking_level = getattr(session, "thinking_level", None) or "medium" + + text = render_agent_card( + agent_name=agent_name, + model=model, + mode=mode, + thinking_level=thinking_level, + ) + return RemoteActionResult(status="ok", text=text) + + 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, limit=200) + provider_groups = await control.list_models_by_provider(app_mode) + provider_model_tokens = { + provider: self._issue_settings_token( + action, session_id, "browse_model_provider", provider + ) + for provider in provider_groups + } + 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 + } + thinking_level = getattr(session, "thinking_level", None) or "medium" + thinking_tokens = { + level: self._issue_settings_token( + action, session_id, "set_thinking_level", level + ) + for level in control.ALLOWED_THINKING_LEVELS + } + 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, + thinking_level=thinking_level, + response_mode_tokens=response_mode_tokens, + mode_tokens=mode_tokens, + agent_tokens=agent_tokens, + model_tokens=model_tokens, + provider_model_tokens=provider_model_tokens, + thinking_tokens=thinking_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.""" + return await self._render_history_page(db, action) + + async def _render_history_page( + self, + db: AsyncSession, + action: RemoteInboundAction, + *, + before: str | None = None, + project_id: str | None = None, + ) -> RemoteActionResult: + from app.services.chat_service import list_sessions_page + + project_uuid = UUID(project_id) if project_id else None + sessions, cursor, has_more = await list_sessions_page( + db, before=before, limit=5, project_id=project_uuid + ) + 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 + + next_token = None + if has_more and cursor: + next_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="history_page", + action_target=cursor, + ) + text, buttons = render_session_list_card(items, next_token=next_token) + 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_switch_by_token( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Switch to a session via button callback.""" + from uuid import UUID + + from app.models.chat import ChatSession + + try: + target_id = UUID(cap.action_target) + except (ValueError, TypeError): + await self._reply_text(action.principal.destination_id, "Invalid session.") + return True + + session = await db.get(ChatSession, target_id) + 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"✅ Switched to: {title}\nNext message continues this session.", + ) + return True + + async def _exec_session_delete_by_token( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Delete a session via button callback.""" + from uuid import UUID + + from app.models.chat import ChatSession, SessionMessage + from sqlmodel import col, select + + try: + target_id = UUID(cap.action_target) + except (ValueError, TypeError): + await self._reply_text(action.principal.destination_id, "Invalid session.") + return True + + session = await db.get(ChatSession, target_id) + 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 + + # Don't delete the active session. + if pairing.active_session_id == session.id: + await self._reply_text( + action.principal.destination_id, + "Cannot delete the active session. Switch to another first.", + ) + return True + + title = session.title or "Untitled" + msgs = list( + await db.exec( + select(SessionMessage).where( + col(SessionMessage.session_id) == session.id + ) + ) + ) + for msg in msgs: + await db.delete(msg) + await db.delete(session) + await db.commit() + + await self._reply_text( + action.principal.destination_id, + f"🗑️ Deleted: {title}", + ) + 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() + is_pairing_aware = isinstance(self._adapter, RemotePairingAwareAdapter) + if isinstance(self._adapter, RemotePairingAwareAdapter): + self._adapter.clear_pairing() + await self._adapter.start() + reconnect_hint = ( + "Open EvoFlux and generate a new code, then send /pair ." + if is_pairing_aware + else "Send /start to pair again." + ) + return RemoteActionResult( + status="ok", text=f"Phone unpaired. {reconnect_hint}" + ) + return RemoteActionResult(status="ok", text="No active pairing to remove.") + + 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"Open project: {proj.name[:44]}", + description=(proj.description or "")[:100], + ) + ) + history_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="project_history", + action_target=str(proj.id), + ) + items.append( + RemoteMenuItem( + token=history_token, + label=f"History: {proj.name[:50]}", + description="Show sessions for this project", + ) + ) + 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 == "project_history": + return bool( + ( + await self._render_history_page( + db, action, project_id=cap.action_target + ) + ).status + == "ok" + ) + 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_thinking_level": + return await self._exec_set_thinking_level(cap, action, db) + elif cap.action_kind == "browse_model_provider": + return await self._exec_browse_model_provider(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 == "session_switch": + return await self._exec_session_switch_by_token(cap, action, db) + elif cap.action_kind == "session_delete": + return await self._exec_session_delete_by_token(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 == "history_page": + result = await self._render_history_page( + db, action, before=cap.action_target + ) + return result.status == "ok" + elif cap.action_kind == "stop_session": + result = await self._cmd_stop(db, action) + return result.status == "ok" + 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_thinking_level( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + from app.remote import control + + result = await control.set_thinking_level(db, cap.session_id, cap.action_target) + await self._reply_control_result( + action, result, f"Thinking level set to {cap.action_target}." + ) + return True + + async def _exec_browse_model_provider( + self, cap: _ActionCapability, action: RemoteInboundAction, db: AsyncSession + ) -> bool: + """Open the per-provider model picker card with pricing.""" + from app.models.chat import ChatSession, normalize_mode + from app.remote import control + from app.remote.formatting import render_models_card + + session = await db.get(ChatSession, cap.session_id) + if session is None: + await self._send(action.principal.destination_id, "Session not found.") + return True + + app_mode = normalize_mode(session.mode) + provider_groups = await control.list_models_by_provider(app_mode) + provider = cap.action_target + models = provider_groups.get(provider, []) + if not models: + await self._send( + action.principal.destination_id, f"No models for {provider}." + ) + return True + + model_tokens = { + model_id: self._issue_settings_token( + action, cap.session_id, "set_model", model_id + ) + for model_id in models + } + model_costs = await control.get_model_costs(models) + + text, buttons = render_models_card( + provider=provider, + models=models, + current_model=session.model or "", + model_costs=model_costs, + model_tokens=model_tokens, + ) + await self._send(action.principal.destination_id, text, buttons=buttons) + 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 the shared remote-channel outbound redaction boundary.""" + from app.remote.redaction import redact_remote_text + + return redact_remote_text(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 000000000..d1d925eab --- /dev/null +++ b/app/remote/connection_service.py @@ -0,0 +1,333 @@ +"""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, + RemoteProviderKind, +) +from app.remote.registry import DefaultRemoteAdapterRegistry, RemoteAdapterRegistry + +#: 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, + provider_registry: RemoteAdapterRegistry | None = None, + ) -> None: + self._adapter_factory = adapter_factory + self._provider_registry = provider_registry or DefaultRemoteAdapterRegistry( + telegram_validator=lambda token: adapter_factory.validate_token( + RemoteAdapterKind.TELEGRAM, token + ), + ) + 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, + adapter: RemoteAdapterKind = RemoteAdapterKind.TELEGRAM, + endpoint_url: str | None = None, + provider: str | None = None, + ) -> RemoteConnection: + """Validate, vault, and persist one connection for *adapter*. + + The credential remains named ``token`` for backward compatibility with + the Telegram API. Adapter selection is explicit at this service + boundary so future chatbot transports can provide their own validator + while retaining the same vault and persistence transaction. + """ + existing = await self.list(session) + if any(connection.adapter == adapter.value for connection in existing): + raise RemoteConnectionConflictError( + f"A {adapter.value} remote connection is already configured." + ) + + if not token.strip(): + raise RemoteAdapterValidationError("The remote credential cannot be empty.") + normalized_provider = ( + RemoteProviderKind(provider).value + if provider is not None + else ( + RemoteProviderKind.IMSG.value + if adapter is RemoteAdapterKind.IMESSAGE + else None + ) + ) + candidate = RemoteConnection( + adapter=adapter.value, + provider=normalized_provider, + endpoint_url=endpoint_url, + label=label, + enabled=False, + adapter_principal_id="", + ) + try: + identity = await self._provider_registry.validate(candidate, token) + except (ValueError, RemoteAdapterValidationError) as exc: + if isinstance(exc, RemoteAdapterValidationError): + raise + raise RemoteAdapterValidationError(str(exc)) from exc + if identity.adapter is not adapter: + raise RemoteAdapterValidationError( + f"Adapter validation returned {identity.adapter.value}, expected {adapter.value}." + ) + + connection = RemoteConnection( + adapter=adapter.value, + provider=normalized_provider, + endpoint_url=endpoint_url, + 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." + ) + + try: + identity = await self._provider_registry.validate(connection, token) + except (ValueError, RemoteAdapterValidationError) as exc: + if isinstance(exc, RemoteAdapterValidationError): + raise + raise RemoteAdapterValidationError(str(exc)) from exc + + 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 000000000..269860c0e --- /dev/null +++ b/app/remote/contracts.py @@ -0,0 +1,281 @@ +"""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, runtime_checkable +from uuid import UUID + + +class RemoteAdapterKind(StrEnum): + """Bounded set of supported remote transports.""" + + TELEGRAM = "telegram" + IMESSAGE = "imessage" + + +class RemoteProviderKind(StrEnum): + """Typed provider identities behind a remote adapter.""" + + IMSG = "imsg" + BLUEBUBBLES = "bluebubbles" + + +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 RemoteInboundAttachment: + """Provider-neutral inbound attachment materialized as bounded bytes.""" + + content: bytes + filename: str + mime_type: str | None = None + + +@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 + delivery: str | None = None + attachments: tuple[RemoteInboundAttachment, ...] = () + + +@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 RemoteAttachment: + """A provider-neutral, URL-addressable outbound attachment.""" + + url: str + mime_type: str | None = None + filename: str | None = None + size_bytes: int | None = None + + +@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) + reply_to_id: str | None = None + attachments: tuple[RemoteAttachment, ...] = 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 + capabilities: frozenset[str] = field(default_factory=frozenset) + + +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: ... + + +@runtime_checkable +class RemotePairingAwareAdapter(RemoteAdapter, Protocol): + """Optional adapter lifecycle hook for restored remote pairings. + + Implemented by adapters whose channel is bound to one fixed contact at + construction time (iMessage), so pairing/unpairing must reconfigure and + restart the channel rather than being handled by an authorization check + alone (Telegram's model). Not implemented by :class:`RemoteAdapter`\\ s + that authorize per-message instead. + """ + + def set_pairing(self, *, principal_id: str, destination_id: str) -> None: ... + + def clear_pairing(self) -> None: + """Forget the bound contact so the next ``start()`` runs in + discovery mode again, ready to accept a fresh ``/pair ``.""" + ... + + +__all__ = [ + "RemoteAdapter", + "RemoteAdapterFactory", + "RemoteAdapterKind", + "RemoteProviderKind", + "RemotePairingAwareAdapter", + "RemoteAdapterStatus", + "RemoteAdapterValidationError", + "RemoteAttachment", + "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 000000000..a710483ba --- /dev/null +++ b/app/remote/control.py @@ -0,0 +1,347 @@ +"""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 Any, 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") +ALLOWED_THINKING_LEVELS: tuple[str, ...] = ("none", "low", "medium", "high") + +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 set_thinking_level( + db: AsyncSession, session_id: str, level: str +) -> ControlResult: + if level not in ALLOWED_THINKING_LEVELS: + return ControlResult(status="invalid", detail=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") + + session.thinking_level = level + db.add(session) + 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) + + +async def list_models_by_provider( + app_mode: str | None = None, +) -> dict[str, list[str]]: + """All registered models grouped by provider id.""" + from app.api.routes.agents import get_registry + + mode_arg = cast("Literal['work', 'coding'] | None", app_mode) + registry = await get_registry(mode=mode_arg) + grouped: dict[str, list[str]] = {} + for entry in registry.models: + provider = entry.id.split(":", 1)[0] if ":" in entry.id else "other" + grouped.setdefault(provider, []).append(entry.id) + return grouped + + +async def get_model_costs( + model_ids: list[str], +) -> dict[str, dict[str, Any]]: + """Per-model cost metadata keyed by model id, omitting models with no pricing data.""" + from app.agent.providers.model_metadata import get_model_cost + + costs: dict[str, dict[str, Any]] = {} + for model_id in model_ids: + cost = get_model_cost(model_id) + if cost.input is None and cost.output is None: + continue + costs[model_id] = {k: v for k, v in cost.to_dict().items() if v is not None} + return costs diff --git a/app/remote/edit_budget.py b/app/remote/edit_budget.py new file mode 100644 index 000000000..ec4d05899 --- /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 000000000..b8b14e87e --- /dev/null +++ b/app/remote/formatting.py @@ -0,0 +1,1012 @@ +from __future__ import annotations + +import html +import re +from collections.abc import Mapping, Sequence +from typing import Any + +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], + response_text: str | None = None, + stop_token: str | None = None, + 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.""" + spinner_frames = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") + spinner = spinner_frames[int(max(0.0, elapsed_seconds) * 2) % len(spinner_frames)] + header = f"{spinner} {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(f"{_model_icon(model)} {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 response_text and response_text.strip(): + preview = response_text.strip() + if len(preview) > 3000: + preview = preview[:2997].rstrip() + "..." + parts.append(markdown_to_telegram_html(preview)) + if activity_lines: + parts.append("\n".join(activity_lines)) + else: + phases = ("Thinking", "Planning", "Working", "Checking") + phase = phases[int(max(0.0, elapsed_seconds) // 3) % len(phases)] + dots = "." * (int(max(0.0, elapsed_seconds) * 2) % 4) + parts.append(f"{spinner} {phase}{dots}") + buttons = (RemoteButton(text="Stop task", token=stop_token),) if stop_token else () + return "\n\n".join(parts), buttons + + +def _model_icon(model: str | None) -> str: + """Return a stable, provider-neutral icon for the visible model label.""" + value = (model or "").lower() + if "claude" in value or "anthropic" in value: + return "◈" + if "gpt" in value or "openai" in value or "o1" in value: + return "◉" + if "gemini" in value or "google" in value: + return "✦" + if "llama" in value or "meta" in value: + return "◌" + if "mistral" in value: + return "✧" + return "◍" + + +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"✅ {_model_icon(model)} Done: {escape(title)}" + f" · {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)) + # Tool-call details are omitted when the terminal card has no explicit + # tool-log action. Legacy callers that opt into a tool-log token retain + # their detailed rendering. + if tool_call_count and toollog_token: + 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(f"{_model_icon(model)} {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"❌ Failed: {escape(title)}\n\nError: {friendly}" + buttons = ( + (RemoteButton(text="🧾 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 _provider_label(provider: str) -> str: + labels = { + "openai": "OpenAI", + "anthropic": "Anthropic", + "google": "Google", + "deepseek": "DeepSeek", + "groq": "Groq", + "mistral": "Mistral", + "bedrock": "Bedrock", + "codex": "Codex", + "copilot": "Copilot", + } + return labels.get(provider, provider.title()) + + +def render_settings_card( + *, + connection_label: str, + model: str, + permission_mode: str, + agent_name: str, + response_mode: str, + thinking_level: str | None = None, + response_mode_tokens: Mapping[str, str], + mode_tokens: Mapping[str, str], + agent_tokens: Mapping[str, str], + model_tokens: Mapping[str, str], + provider_model_tokens: Mapping[str, str] | None = None, + thinking_tokens: Mapping[str, str] | None = None, + 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 {_model_icon(model)}\n{escape(model)}", + "", + f"Lead agent\n{escape(agent_name)}", + "", + f"Responses\n{escape(response_mode)}", + ] + if thinking_level is not None: + lines += ["", f"Thinking\n{escape(thinking_level)}"] + 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"Permission mode: {escape(name)}", token=token) + for name, token in mode_tokens.items() + if name != "bypass" + ] + buttons += [ + RemoteButton(text=f"Use agent: {escape(name)}", token=token) + for name, token in agent_tokens.items() + ] + buttons += [ + RemoteButton(text=f"Updates: {escape(name)}", token=token) + for name, token in response_mode_tokens.items() + ] + # Replace flat model buttons with grouped provider cards. + if provider_model_tokens: + buttons += [ + RemoteButton( + text=f"{_model_icon(None)} {_provider_label(provider)} ({count})", + token=token, + ) + for provider, token in provider_model_tokens.items() + for count in [len(model_tokens)] + ][:3] + else: + buttons += [ + RemoteButton(text=f"Use model: {escape(name)}", token=token) + for name, token in model_tokens.items() + ] + if thinking_tokens: + buttons += [ + RemoteButton( + text=f"Thinking: {escape(name)}{' ✓' if name == thinking_level else ''}", + token=token, + ) + for name, token in thinking_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_models_card( + *, + provider: str, + models: Sequence[str], + current_model: str, + model_costs: Mapping[str, dict[str, Any]] | None = None, + model_tokens: Mapping[str, str] | None = None, +) -> tuple[str, tuple[RemoteButton, ...]]: + """A provider-specific model picker with pricing and token buttons.""" + icon = _model_icon(f"{provider}:" if provider else None) + header = f"{icon} {_provider_label(provider)} models" + parts = [header] + + for model_id in models: + label = escape(model_id) + marker = " ◀" if model_id == current_model else "" + cost_line = "" + if model_costs and model_id in model_costs: + cost = model_costs[model_id] + inp = cost.get("input", 0) + out = cost.get("output", 0) + cost_line = f"\n ${inp:.2f} / ${out:.2f} per 1M tok" + parts.append(f"{label}{marker}{cost_line}") + + text = "\n\n".join(parts) + buttons: list[RemoteButton] = [] + if model_tokens: + for model_id in models: + if model_id not in model_tokens: + continue + token = model_tokens[model_id] + short = model_id.split(":", 1)[1] if ":" in model_id else model_id + if len(short) > 40: + short = short[:37] + "…" + marker = " ✓" if model_id == current_model else "" + buttons.append(RemoteButton(text=f"{short}{marker}", token=token)) + 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]], + *, + next_token: str | None = None, +) -> 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.") + if next_token: + buttons.append(RemoteButton(text="More sessions", token=next_token)) + 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) + + +def render_status_dashboard( + *, + connection_state: str, + pairing_label: str, + session: Any = None, + provider_count: int = 0, + model_count: int = 0, + provider_groups: Mapping[str, Sequence[str]] | None = None, + message_count: int = 0, + turn_count: int = 0, +) -> str: + """Rich status dashboard showing connection, session, and provider info.""" + from datetime import UTC, datetime + + model = getattr(session, "model", None) or "(default)" + agent = getattr(session, "agent_name", None) or "(default)" + mode = getattr(session, "mode", None) or "work" + thinking = getattr(session, "thinking_level", None) or "medium" + permission = getattr(session, "permission_mode", None) or "work" + title = getattr(session, "title", None) + response_mode = getattr(session, "response_mode", None) + + state_icon = { + "used_elsewhere": "🔄", + "error": "❌", + "connected": "✅", + "backoff": "⏳", + } + icon = state_icon.get(connection_state, "🟢") + + lines = [ + f"{icon} EvoFlux Status", + "", + f"Connection {connection_state}", + f"Phone {escape(pairing_label)}", + "", + f"Mode {escape(mode)}", + f"Model {_model_icon(model)} {escape(model)}", + f"Agent {escape(agent)}", + f"Thinking {escape(thinking)}", + f"Permissions {escape(permission)}", + ] + + if response_mode is not None: + lines.append(f"Responses {escape(response_mode)}") + + lines.append("") + lines.append(f"Providers {provider_count} configured") + lines.append(f"Models {model_count} available") + + if provider_groups: + provider_summary = ", ".join( + f"{_provider_label(p)} ({len(models)})" + for p, models in provider_groups.items() + ) + lines.append(f"Catalog {provider_summary}") + + if title: + lines.append("") + lines.append(f"Active task\n{escape(title)}") + elif getattr(session, "id", None) is not None: + lines.append("") + lines.append("Active task\nNo title") + + # Session activity stats. + if getattr(session, "id", None) is not None: + lines.append("") + stats_parts: list[str] = [] + if message_count > 0: + stats_parts.append(f"{message_count} messages") + if turn_count > 0: + stats_parts.append(f"{turn_count} turns") + if stats_parts: + lines.append(f"Activity {' · '.join(stats_parts)}") + + created_at = getattr(session, "created_at", None) + if created_at is not None: + now = datetime.now(UTC) + created_utc = ( + created_at.replace(tzinfo=UTC) + if created_at.tzinfo is None + else created_at + ) + age = now - created_utc + if age.days > 0: + lines.append(f"Session age {age.days}d {age.seconds // 3600}h") + else: + hours = age.seconds // 3600 + mins = (age.seconds % 3600) // 60 + lines.append(f"Session age {hours}h {mins}m") + + lines.append("") + now = datetime.now(UTC).strftime("%H:%M UTC") + lines.append(f"Updated {now}") + + return "\n".join(lines) + + +def render_skills_card( + *, + available_skills: Mapping[str, Any], + current_mode: str | None = None, +) -> str: + """Display skills grouped by mode.""" + work_skills: list[str] = [] + coding_skills: list[str] = [] + both_skills: list[str] = [] + + for name, modes in available_skills.items(): + mode_names = [str(m) for m in modes] + if "work" in mode_names and "coding" in mode_names: + both_skills.append(name) + elif "work" in mode_names: + work_skills.append(name) + elif "coding" in mode_names: + coding_skills.append(name) + else: + both_skills.append(name) + + lines = ["🧩 Skills Catalog", ""] + + if current_mode: + lines.append(f"Current mode: {escape(current_mode)}") + lines.append("") + + if both_skills: + lines.append("Both modes") + for name in sorted(both_skills): + lines.append(f" • {escape(name)}") + lines.append("") + + if work_skills: + lines.append("Work mode") + for name in sorted(work_skills): + lines.append(f" • {escape(name)}") + lines.append("") + + if coding_skills: + lines.append("Coding mode") + for name in sorted(coding_skills): + lines.append(f" • {escape(name)}") + + total = len(both_skills) + len(work_skills) + len(coding_skills) + lines.append("") + lines.append(f"{total} skills available") + + return "\n".join(lines) + + +def render_agent_card( + *, + agent_name: str, + model: str, + mode: str, + thinking_level: str, +) -> str: + """Detailed agent info card.""" + lines = [ + f"{_model_icon(model)} Agent Info", + "", + f"Name {escape(agent_name)}", + f"Model {_model_icon(model)} {escape(model)}", + f"Mode {escape(mode)}", + f"Thinking {escape(thinking_level)}", + "", + "Thinking levels:", + " none — fast, no reasoning", + " low — light reasoning", + " medium — balanced (default)", + " high — deep reasoning, more tokens", + ] + return "\n".join(lines) + + +def render_skill_card( + *, + skill_name: str, + description: str, + modes: Any = None, + extra_prompt: str = "", +) -> tuple[str, tuple[RemoteButton, ...]]: + """Card shown when a skill is loaded via /skill command.""" + lines = [ + f"🧩 Skill: {escape(skill_name)}", + "", + ] + if description: + lines.append(escape(description)) + + if modes: + mode_list = ", ".join(str(m) for m in modes) + lines.append(f"\nModes: {escape(mode_list)}") + + if extra_prompt: + lines.append(f"\nYour instruction:\n{escape(extra_prompt)}") + + lines.append("\nThe skill has been loaded. Send your message to use it.") + + text = "\n".join(lines) + buttons = () + return text, buttons + + +def render_switch_card( + *, + sessions: Sequence[Any], + active_session_id: Any = None, +) -> tuple[str, tuple[RemoteButton, ...]]: + """Card showing numbered sessions for quick switching.""" + from datetime import UTC, datetime as _dt + + lines = ["🔄 Switch Session", ""] + buttons: list[RemoteButton] = [] + + for i, session in enumerate(sessions, 1): + title = getattr(session, "title", None) or "Untitled" + updated = getattr(session, "updated_at", None) + marker = ( + " ◀ active" if getattr(session, "id", None) == active_session_id else "" + ) + if updated is not None: + age = _dt.now(UTC) - updated + if age.days > 0: + age_str = f"{age.days}d ago" + elif age.seconds > 3600: + age_str = f"{age.seconds // 3600}h ago" + elif age.seconds > 60: + age_str = f"{age.seconds // 60}m ago" + else: + age_str = "just now" + else: + age_str = "" + age_part = f" · {age_str}" if age_str else "" + lines.append(f"{i}. {escape(title)}{marker}{age_part}") + + token = f"session_switch:{session.id}" + buttons.append(RemoteButton(text=f"{i}. {title[:30]}{marker}", token=token)) + + text = "\n".join(lines) + return text, tuple(buttons) + + +def render_delete_card( + *, + sessions: Sequence[Any], +) -> tuple[str, tuple[RemoteButton, ...]]: + """Card showing sessions that can be deleted.""" + from datetime import UTC, datetime as _dt + + lines = ["🗑️ Delete Sessions", ""] + buttons: list[RemoteButton] = [] + + for i, session in enumerate(sessions, 1): + title = getattr(session, "title", None) or "Untitled" + updated = getattr(session, "updated_at", None) + if updated is not None: + age = _dt.now(UTC) - updated + if age.days > 0: + age_str = f"{age.days}d ago" + elif age.seconds > 3600: + age_str = f"{age.seconds // 3600}h ago" + elif age.seconds > 60: + age_str = f"{age.seconds // 60}m ago" + else: + age_str = "just now" + else: + age_str = "" + age_part = f" · {age_str}" if age_str else "" + lines.append(f"{i}. {escape(title)}{age_part}") + + token = f"session_delete:{session.id}" + buttons.append(RemoteButton(text=f"🗑️ {title[:25]}", token=token)) + + text = "\n".join(lines) + return text, tuple(buttons) diff --git a/app/remote/gates.py b/app/remote/gates.py new file mode 100644 index 000000000..f1b1dba92 --- /dev/null +++ b/app/remote/gates.py @@ -0,0 +1,553 @@ +"""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] + question_text = str(first_q.get("question", "")).strip() + options = [str(option).strip() for option in first_q.get("options", [])] + option_lines = "\n".join( + f"{index}. {option}" for index, option in enumerate(options[:8], 1) + ) + text = f"Question: {question_text}" + if option_lines: + text += f"\n\nAnswers:\n{option_lines}" + actions = [ + ( + option, + f"{index}. {option[:45].rstrip()}" + + ("…" if len(option) > 45 else ""), + ) + for index, option in enumerate(options[:8], 1) + ] + 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 the shared remote-channel outbound redaction boundary.""" + from app.remote.redaction import redact_remote_text + + return redact_remote_text(text) diff --git a/app/remote/imessage/__init__.py b/app/remote/imessage/__init__.py new file mode 100644 index 000000000..ddcadfbdc --- /dev/null +++ b/app/remote/imessage/__init__.py @@ -0,0 +1,13 @@ +"""Provider implementations for the iMessage remote channel.""" + +from app.remote.imessage.rpc import ( + IMessageRpcClient, + IMessageRpcError, + IMessageRpcProtocolError, +) + +__all__ = [ + "IMessageRpcClient", + "IMessageRpcError", + "IMessageRpcProtocolError", +] diff --git a/app/remote/imessage/adapter.py b/app/remote/imessage/adapter.py new file mode 100644 index 000000000..afa9ead5f --- /dev/null +++ b/app/remote/imessage/adapter.py @@ -0,0 +1,253 @@ +"""RemoteAdapter compatibility wrapper for the iMessage channel.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +from loguru import logger + +from app.remote.contracts import ( + RemoteAdapterStatus, + RemoteConnectionState, + RemoteErrorClass, + RemoteInboundAction, + RemoteInboundActionKind, + RemoteOutboundMessage, + RemoteProviderKind, +) +from app.remote.imessage.channel import IMessageChannel + + +class IMessageRemoteAdapter: + def __init__( + self, + *, + connection_id: UUID, + credential: str, + on_action, + provider_name: RemoteProviderKind | str = RemoteProviderKind.IMSG, + endpoint_url: str | None = None, + ) -> None: + self.connection_id = connection_id + self._credential = credential + self._provider_name = RemoteProviderKind(provider_name) + self._endpoint_url = endpoint_url + self._on_action = on_action + self._channel: IMessageChannel | None = None + self._status = RemoteAdapterStatus( + connection_id=connection_id, + state=RemoteConnectionState.DISABLED, + ) + self._paired_principal_id: str | None = None + self._paired_destination_id: str | None = None + + def set_pairing(self, *, principal_id: str, destination_id: str) -> None: + self._paired_principal_id = principal_id + self._paired_destination_id = destination_id + + def clear_pairing(self) -> None: + self._paired_principal_id = None + self._paired_destination_id = None + + async def _materialize_media( + self, action: RemoteInboundAction + ) -> RemoteInboundAction: + """Download iMessage attachments via provider and attach to action. + + Attachments are bounded to 10 MiB. Download failures are caught + and logged; the text fallback is preserved. + """ + from app.remote.contracts import RemoteInboundAttachment + + if action.kind != RemoteInboundActionKind.TEXT: + return action + + # Check if the originating payload had attachments — stored in + # the action's text field as "[iMessage attachment]" for pure-media + # messages, or alongside real text. + attachments: list[RemoteInboundAttachment] = [] + + # The iMessage channel stores raw payload metadata on the action + # text. Pure attachment messages have "[iMessage attachment]" as + # their text, while captioned messages keep the caption text. In + # both cases, the channel provider can be asked to materialize + # attachment bytes if a download URL is available. + if self._channel is None or self._channel._provider is None: + return action + + try: + provider = self._channel._provider + materialize = getattr(provider, "materialize_attachments", None) + if materialize is not None: + raw_atts = await materialize( + self._paired_destination_id or "", + max_bytes=10 * 1024 * 1024, + ) + for att in raw_atts: + attachments.append( + RemoteInboundAttachment( + content=att["content"], + filename=att.get("filename", "attachment"), + mime_type=att.get("mime_type"), + ) + ) + except Exception: + logger.debug( + "imessage_attachment_download_failed destination_id={}", + self._paired_destination_id, + ) + + if not attachments: + return action + + from dataclasses import replace + + return replace(action, attachments=tuple(attachments)) + + async def _on_action_with_media(self, action: RemoteInboundAction) -> None: + """Materialize attachments before dispatching to the runtime.""" + action = await self._materialize_media(action) + await self._on_action(action) + + + async def start(self) -> None: + """Start (or restart) the channel. + + Runs in discovery mode when no contact is bound yet — the poller is + live and reachable, but ``IMessageChannel``/``normalize_inbound`` + recognize nothing from an unrecognized sender except a ``/pair + `` attempt (see ``app/remote/imessage/inbound.py``). Reported + state stays ``PAIRING`` until a caller supplies a contact via + :meth:`set_pairing` and calls ``start`` again, which is idempotent: + an already-running channel (discovery or previously paired) is + stopped first so it is never leaked. + """ + if self._channel is not None: + await self._channel.stop() + self._channel = None + + from app.remote.imessage.provider import IMessageProviderFactory + + self._channel = IMessageChannel( + connection_id=self.connection_id, + provider_name=self._provider_name, + provider_factory=IMessageProviderFactory(), + endpoint_url=self._endpoint_url, + password=self._credential, + paired_principal_id=self._paired_principal_id, + paired_destination_id=self._paired_destination_id, + load_watermark=self._load_watermark, + save_watermark=self._save_watermark, + on_action=self._on_action_with_media, + ) + try: + capabilities = await self._channel.start() + except Exception as exc: + logger.warning( + "imessage_adapter_start_failed connection_id={} error_type={}", + self.connection_id, + type(exc).__name__, + ) + try: + await self._channel.stop() + except Exception as cleanup_exc: + logger.warning( + "imessage_adapter_start_cleanup_failed connection_id={} error_type={}", + self.connection_id, + type(cleanup_exc).__name__, + ) + self._channel = None + is_paired = ( + self._paired_principal_id is not None + and self._paired_destination_id is not None + ) + self._status = RemoteAdapterStatus( + connection_id=self.connection_id, + state=RemoteConnectionState.ERROR, + last_error_class=RemoteErrorClass.TRANSPORT, + paired=is_paired, + ) + return + is_paired = ( + self._paired_principal_id is not None + and self._paired_destination_id is not None + ) + healthy = capabilities.health.value == "healthy" + if not healthy: + state = RemoteConnectionState.ERROR + elif is_paired: + state = RemoteConnectionState.POLLING + else: + state = RemoteConnectionState.PAIRING + self._status = RemoteAdapterStatus( + connection_id=self.connection_id, + state=state, + last_successful_poll_at=datetime.now(UTC) if healthy else None, + last_error_class=( + RemoteErrorClass.NONE if healthy else RemoteErrorClass.UNKNOWN + ), + paired=is_paired, + capabilities=capabilities.features, + ) + + async def stop(self) -> None: + if self._channel is not None: + await self._channel.stop() + self._channel = None + self._status = RemoteAdapterStatus( + connection_id=self.connection_id, + state=RemoteConnectionState.DISABLED, + ) + + async def send(self, message: RemoteOutboundMessage) -> None: + if self._channel is None: + return + await self._channel.send_text( + text=message.text, + reply_to_id=message.reply_to_id, + attachments=message.attachments, + ) + + async def edit(self, message: RemoteOutboundMessage) -> None: + await self.send(message) + + async def answer_callback(self, callback_token: str) -> None: + return None + + async def indicate_typing(self, destination_id: str) -> None: + return None + + def status(self) -> RemoteAdapterStatus: + return self._status + + async def _load_watermark(self) -> str | None: + """Load the connection's durable, provider-opaque inbound cursor.""" + from sqlmodel import select + + from app.core.db import read_session_factory + from app.models.remote import RemoteConnection + + async with read_session_factory() as session: + connection = await session.scalar( + select(RemoteConnection).where( + RemoteConnection.id == self.connection_id + ) + ) + if connection is None: + return None + return connection.inbound_watermark_cursor + + async def _save_watermark(self, value: str) -> None: + """Persist the latest provider cursor without storing message + content. Opaque here by design — see `app/remote/imessage/provider.py`.""" + from app.core.db import async_session_factory + from app.models.remote import RemoteConnection + + async with async_session_factory() as session: + connection = await session.get(RemoteConnection, self.connection_id) + if connection is None: + return + connection.inbound_watermark_cursor = value + session.add(connection) + await session.commit() diff --git a/app/remote/imessage/bluebubbles.py b/app/remote/imessage/bluebubbles.py new file mode 100644 index 000000000..018fdeb9e --- /dev/null +++ b/app/remote/imessage/bluebubbles.py @@ -0,0 +1,128 @@ +"""BlueBubbles REST provider kept behind the iMessage provider contract.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any +from urllib.parse import urljoin + +import httpx + +from app.remote.contracts import RemoteAttachment +from app.remote.imessage.provider import ( + DEFAULT_QUERY_LIMIT, + IMessageProviderError, + MessagePage, +) + + +class BlueBubblesProvider: + def __init__( + self, + *, + endpoint_url: str, + password: str, + client: httpx.AsyncClient | None = None, + ) -> None: + endpoint = endpoint_url.strip() + if not endpoint.startswith(("https://", "http://")): + raise IMessageProviderError("BlueBubbles endpoint must use HTTP(S).") + self._base_url = endpoint.rstrip("/") + "/" + self._password = password + self._client = client or httpx.AsyncClient(timeout=30.0) + self._owns_client = client is None + + async def start(self) -> None: + await self.status() + + async def stop(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def status(self) -> Mapping[str, Any]: + return await self._request("api/v1/server") + + async def query_messages( + self, *, since_cursor: str | None = None, limit: int = DEFAULT_QUERY_LIMIT + ) -> MessagePage: + """Catch up via BlueBubbles' own REST cursor. + + Unlike imsg's ``messages.after``, BlueBubbles' query endpoint + returns no explicit next-cursor or has-more flag, so the highest + message identifier actually observed becomes the next cursor — + the same derivation the shared poller used to do itself before the + cursor contract moved behind this provider boundary. + """ + params: dict[str, str] = {"password": self._password} + if since_cursor is not None: + params["after"] = since_cursor + payload = await self._request("api/v1/message/query", params=params) + raw_messages = payload.get("data", payload.get("messages", [])) + messages = [message for message in raw_messages if isinstance(message, dict)] + next_cursor = since_cursor + for message in messages: + candidate = message.get("guid") or message.get("id") + if isinstance(candidate, str) and ( + next_cursor is None or candidate > next_cursor + ): + next_cursor = candidate + return MessagePage(messages=messages, next_cursor=next_cursor, has_more=False) + + async def send_text( + self, + *, + chat_id: str, + text: str, + reply_to_id: str | None = None, + attachments: Sequence[RemoteAttachment] = (), + ) -> Mapping[str, Any]: + payload: dict[str, Any] = { + "guid": chat_id, + "text": text, + "password": self._password, + } + if reply_to_id is not None: + payload["replyTo"] = reply_to_id + if attachments: + payload["attachments"] = [ + { + key: value + for key, value in { + "url": attachment.url, + "mimeType": attachment.mime_type, + "filename": attachment.filename, + }.items() + if value is not None + } + for attachment in attachments + ] + return await self._request( + "api/v1/message/text", + method="POST", + json=payload, + ) + + async def _request( + self, + path: str, + *, + method: str = "GET", + params: Mapping[str, str] | None = None, + json: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = await self._client.request( + method, + urljoin(self._base_url, path), + params=params, + json=json, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise IMessageProviderError( + f"BlueBubbles request failed ({type(exc).__name__})." + ) from exc + if not isinstance(payload, dict): + raise IMessageProviderError("BlueBubbles returned an invalid response.") + return payload diff --git a/app/remote/imessage/capabilities.py b/app/remote/imessage/capabilities.py new file mode 100644 index 000000000..9cec6b81f --- /dev/null +++ b/app/remote/imessage/capabilities.py @@ -0,0 +1,51 @@ +"""Capability probing and safe provider health classification.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from app.remote.imessage.provider import IMessageProvider +from app.remote.imessage.rpc import IMessageRpcError, IMessageRpcProtocolError + + +class IMessageHealth(StrEnum): + HEALTHY = "healthy" + ENDPOINT_UNREACHABLE = "endpoint_unreachable" + AUTHENTICATION_FAILED = "authentication_failed" + PROVIDER_ERROR = "provider_error" + + +@dataclass(frozen=True) +class IMessageCapabilities: + provider: str + health: IMessageHealth + features: frozenset[str] + detail: str | None = None + + +async def probe_provider( + provider: IMessageProvider, *, provider_name: str +) -> IMessageCapabilities: + """Probe without returning provider payloads or secrets.""" + try: + status: Any = await provider.status() + except IMessageRpcError as exc: + health = ( + IMessageHealth.AUTHENTICATION_FAILED + if exc.code in {-32001, 401, 403} + else IMessageHealth.PROVIDER_ERROR + ) + return IMessageCapabilities(provider_name, health, frozenset()) + except (IMessageRpcProtocolError, OSError, TimeoutError): + return IMessageCapabilities( + provider_name, IMessageHealth.ENDPOINT_UNREACHABLE, frozenset() + ) + if not isinstance(status, dict): + return IMessageCapabilities( + provider_name, IMessageHealth.PROVIDER_ERROR, frozenset() + ) + features = status.get("features", []) + safe_features = frozenset(str(item) for item in features if isinstance(item, str)) + return IMessageCapabilities(provider_name, IMessageHealth.HEALTHY, safe_features) diff --git a/app/remote/imessage/cards.py b/app/remote/imessage/cards.py new file mode 100644 index 000000000..bcbcb1055 --- /dev/null +++ b/app/remote/imessage/cards.py @@ -0,0 +1,16 @@ +"""Provider-neutral card projection for text-first iMessage clients.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from app.remote.contracts import RemoteButton + + +def render_card(text: str, buttons: Sequence[RemoteButton]) -> str: + """Render generic remote buttons as stable numbered text actions.""" + if not buttons: + return text + lines = [text.rstrip(), "", "Reply with a number:"] + lines.extend(f"{index}. {button.text}" for index, button in enumerate(buttons, 1)) + return "\n".join(lines) diff --git a/app/remote/imessage/channel.py b/app/remote/imessage/channel.py new file mode 100644 index 000000000..77742af32 --- /dev/null +++ b/app/remote/imessage/channel.py @@ -0,0 +1,92 @@ +"""End-to-end iMessage channel orchestration seam.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from uuid import UUID + +from app.remote.contracts import RemoteAttachment, RemoteInboundAction +from app.remote.imessage.capabilities import IMessageCapabilities, probe_provider +from app.remote.imessage.inbound import normalize_inbound +from app.remote.imessage.poller import IMessagePoller +from app.remote.imessage.provider import IMessageProvider, IMessageProviderFactory +from app.remote.imessage.redaction import redact + +ActionSink = Callable[[RemoteInboundAction], Awaitable[None]] + + +class IMessageChannel: + """Connect one configured provider to normalized remote actions.""" + + def __init__( + self, + *, + connection_id: UUID, + provider_name: str, + provider: IMessageProvider | None = None, + provider_factory: IMessageProviderFactory | None = None, + endpoint_url: str | None = None, + password: str | None = None, + paired_principal_id: str | None = None, + paired_destination_id: str | None = None, + load_watermark: Callable[[], Awaitable[str | None]], + save_watermark: Callable[[str], Awaitable[None]], + on_action: ActionSink, + ) -> None: + self.connection_id = connection_id + self.provider_name = provider_name + self._provider = provider or ( + provider_factory or IMessageProviderFactory() + ).create(provider_name, endpoint_url=endpoint_url, password=password) + self._paired_principal_id = paired_principal_id + self._paired_destination_id = paired_destination_id + self._on_action = on_action + self._poller = IMessagePoller( + self._provider, + on_message=self._handle_message, + load_watermark=load_watermark, + save_watermark=save_watermark, + ) + self.capabilities: IMessageCapabilities | None = None + + async def start(self) -> IMessageCapabilities: + await self._provider.start() + self.capabilities = await probe_provider( + self._provider, provider_name=self.provider_name + ) + if self.capabilities.health.value != "healthy": + return self.capabilities + await self._poller.start() + return self.capabilities + + async def stop(self) -> None: + await self._poller.stop() + + async def send_text( + self, + *, + text: str, + reply_to_id: str | None = None, + attachments: Sequence[RemoteAttachment] = (), + ) -> Mapping[str, object]: + """Send only to the configured paired destination.""" + if self._paired_destination_id is None: + raise RuntimeError( + "cannot send on an iMessage channel that has no paired destination yet" + ) + return await self._provider.send_text( + chat_id=self._paired_destination_id, + text=redact(text), + reply_to_id=reply_to_id, + attachments=attachments, + ) + + async def _handle_message(self, message: dict[str, object]) -> None: + action = normalize_inbound( + message, + connection_id=self.connection_id, + paired_principal_id=self._paired_principal_id, + paired_destination_id=self._paired_destination_id, + ) + if action is not None: + await self._on_action(action) diff --git a/app/remote/imessage/imsg_provider.py b/app/remote/imessage/imsg_provider.py new file mode 100644 index 000000000..c26e5cbe7 --- /dev/null +++ b/app/remote/imessage/imsg_provider.py @@ -0,0 +1,92 @@ +"""Provider adapter for the native ``imsg`` JSON-RPC process.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from app.remote.contracts import RemoteAttachment +from app.remote.imessage.provider import DEFAULT_QUERY_LIMIT, MessagePage +from app.remote.imessage.rpc import IMessageRpcClient + + +class IMsgProvider: + def __init__(self, *, client: IMessageRpcClient) -> None: + self._client = client + + async def start(self) -> None: + await self._client.start() + + async def stop(self) -> None: + await self._client.stop() + + async def status(self) -> Mapping[str, Any]: + return await self._client.request("status") + + async def query_messages( + self, *, since_cursor: str | None = None, limit: int = DEFAULT_QUERY_LIMIT + ) -> MessagePage: + """Catch up in stable ROWID order using the RPC's ``messages.after``. + + Not ``messages.list``, which does not exist in the ``imsg`` RPC + surface and always fails with a JSON-RPC "Method not found" error + that a poller's generic retry/backoff handler swallows silently, + looking exactly like "no messages ever arrive" rather than a wrong + method name. + + The shared cursor contract is an opaque string; this provider is + the only place that knows it encodes imsg's own exclusive integer + ``since_rowid``, so the string<->int conversion stays local to it. + """ + since_rowid = 0 + if since_cursor is not None: + try: + since_rowid = int(since_cursor) + except ValueError: + since_rowid = 0 + result = await self._client.request( + "messages.after", + params={"since_rowid": since_rowid, "limit": limit}, + ) + raw_messages = result.get("messages", []) + messages = [message for message in raw_messages if isinstance(message, dict)] + next_rowid = result.get("next_rowid") + if not isinstance(next_rowid, int): + next_rowid = since_rowid + return MessagePage( + messages=messages, + next_cursor=str(next_rowid), + has_more=bool(result.get("has_more", False)), + ) + + async def send_text( + self, + *, + chat_id: str, + text: str, + reply_to_id: str | None = None, + attachments: Sequence[RemoteAttachment] = (), + ) -> Mapping[str, Any]: + # `chat_id` here is EvoFlux's own generic "destination" parameter + # name; it always carries imsg's *string* `chat_identifier` (see + # `app/remote/imessage/inbound.py`), never the numeric `chat_id` + # `messages.after` reports — `send`'s `chat_id` selector wants that + # integer database rowid instead, so passing our string through + # under that key would resolve to the wrong (or no) chat. + params: dict[str, Any] = {"chat_identifier": chat_id, "text": text} + if reply_to_id is not None: + params["reply_to"] = reply_to_id + if attachments: + params["attachments"] = [ + { + key: value + for key, value in { + "url": attachment.url, + "mime_type": attachment.mime_type, + "filename": attachment.filename, + }.items() + if value is not None + } + for attachment in attachments + ] + return await self._client.request("send", params=params) diff --git a/app/remote/imessage/inbound.py b/app/remote/imessage/inbound.py new file mode 100644 index 000000000..6be96ad88 --- /dev/null +++ b/app/remote/imessage/inbound.py @@ -0,0 +1,120 @@ +"""Normalize iMessage provider updates at the remote authorization boundary.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from uuid import UUID + +from app.remote.contracts import ( + RemoteInboundAction, + RemoteInboundActionKind, + RemotePrincipal, +) + +_NUMBERED_REPLY = re.compile(r"^\s*(\d+)\s*$") + + +def normalize_inbound( + payload: Mapping[str, object], + *, + connection_id: UUID, + paired_principal_id: str | None, + paired_destination_id: str | None, +) -> RemoteInboundAction | None: + """Return a normalized action, or ``None`` for unauthorized/unsupported data. + + ``paired_principal_id``/``paired_destination_id`` are ``None`` before a + contact has ever been bound (discovery mode). There is no strict-match + filter to apply yet, so the only thing recognized from *any* sender is a + ``/pair `` attempt — mirroring Telegram's pre-pairing ``/start + ``, which is likewise open to anyone holding the token/code and + is authorized by the code's own entropy plus + :class:`~app.remote.pairing.PairingService` rate limiting, not by sender + identity. Everything else from an unrecognized sender is dropped so no + unpaired contact can inject arbitrary text before pairing exists. + """ + # Attachment-only messages are still rejected; attachment + text goes through + # as text with metadata resolved later by the adapter materializer. + has_attachment = payload.get("attachment") is not None or bool( + payload.get("attachments") + ) + principal_id = _string(payload, "principal_id", "sender") + # `chat_identifier`/`chat_guid` are imsg's *portable string* handles for + # a chat. Its numeric `chat_id` (a database rowid) is deliberately not a + # fallback here: `_string()` only extracts string values, so an int + # would already be skipped, but sending it would also require the + # outbound `send` RPC call to select by that same integer rather than + # the string identifier this module binds as the pairing's destination. + destination_id = _string( + payload, "destination_id", "chat_identifier", "chat_guid" + ) + if principal_id is None or destination_id is None: + return None + + if paired_principal_id is None or paired_destination_id is None: + text = _string(payload, "text", "body") + if text is None or not text.strip().lower().startswith("/pair"): + return None + source_key = _string(payload, "guid", "id", "source_key") + if source_key is None: + return None + return RemoteInboundAction( + connection_id=connection_id, + kind=RemoteInboundActionKind.TEXT, + principal=RemotePrincipal( + connection_id=connection_id, + principal_id=principal_id, + destination_id=destination_id, + display=_string(payload, "display", "sender_name") or "", + ), + source_key=source_key, + text=text, + callback_token=None, + ) + + if principal_id != paired_principal_id or destination_id != paired_destination_id: + return None + source_key = _string(payload, "guid", "id", "source_key") + if source_key is None: + return None + text = _string(payload, "text", "body") + callback = _string(payload, "callback_token", "tapback") + if callback is not None: + kind = RemoteInboundActionKind.CALLBACK + callback_token = callback + elif text is not None or has_attachment: + # If there is attachment but no text, create a synthetic text + # placeholder so the message still reaches the pipeline. + if text is None and has_attachment: + text = "[iMessage attachment]" + numbered = _NUMBERED_REPLY.fullmatch(text or "") + if numbered is not None: + kind = RemoteInboundActionKind.CALLBACK + callback_token = f"number:{numbered.group(1)}" + else: + kind = RemoteInboundActionKind.TEXT + callback_token = None + else: + return None + return RemoteInboundAction( + connection_id=connection_id, + kind=kind, + principal=RemotePrincipal( + connection_id=connection_id, + principal_id=principal_id, + destination_id=destination_id, + display=_string(payload, "display", "sender_name") or "", + ), + source_key=source_key, + text=text, + callback_token=callback_token, + ) + + +def _string(payload: Mapping[str, object], *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value: + return value + return None diff --git a/app/remote/imessage/live.py b/app/remote/imessage/live.py new file mode 100644 index 000000000..1dced15de --- /dev/null +++ b/app/remote/imessage/live.py @@ -0,0 +1,9 @@ +"""Compatibility import for the shared rolling live edit budget.""" + +from __future__ import annotations + +from app.remote.live_budget import RollingEditBudget + +IMessageLiveBudget = RollingEditBudget + +__all__ = ["IMessageLiveBudget", "RollingEditBudget"] diff --git a/app/remote/imessage/poller.py b/app/remote/imessage/poller.py new file mode 100644 index 000000000..3e812bbdf --- /dev/null +++ b/app/remote/imessage/poller.py @@ -0,0 +1,98 @@ +"""Cancellable iMessage polling with watermark and bounded backoff.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable + +from app.remote.imessage.provider import IMessageProvider + +MessageHandler = Callable[[dict[str, object]], Awaitable[None]] +WatermarkLoader = Callable[[], Awaitable[str | None]] +WatermarkSaver = Callable[[str], Awaitable[None]] + +#: Safety bound on pages drained in one tick — guards against a provider bug +#: that reports `has_more=True` without ever advancing `next_cursor`. 25 +#: pages already covers a multi-thousand-message backlog at a provider's +#: typical ~200-per-page default. +_MAX_PAGES_PER_TICK = 25 + + +class IMessagePoller: + def __init__( + self, + provider: IMessageProvider, + *, + on_message: MessageHandler, + load_watermark: WatermarkLoader, + save_watermark: WatermarkSaver, + interval: float = 2.0, + max_backoff: float = 60.0, + ) -> None: + self._provider = provider + self._on_message = on_message + self._load_watermark = load_watermark + self._save_watermark = save_watermark + self._interval = interval + self._max_backoff = max_backoff + self._task: asyncio.Task[None] | None = None + self._stop = asyncio.Event() + self._watermark: str | None = None + + async def start(self) -> None: + if self._task is not None and not self._task.done(): + return + self._stop.clear() + self._watermark = await self._load_watermark() + await self._provider.start() + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + self._stop.set() + task = self._task + self._task = None + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await self._provider.stop() + + async def _run(self) -> None: + backoff = self._interval + while not self._stop.is_set(): + try: + await self._drain() + backoff = self._interval + except asyncio.CancelledError: + raise + except Exception: + await asyncio.sleep(backoff) + backoff = min(backoff * 2, self._max_backoff) + continue + try: + await asyncio.wait_for(self._stop.wait(), timeout=self._interval) + except asyncio.TimeoutError: + continue + + async def _drain(self) -> None: + """Page through every message since the persisted cursor. + + Each provider owns what its cursor means and what forward-progress + guarantee it makes (see `app/remote/imessage/provider.py`); imsg's + exclusive `since_rowid` never re-delivers a message once its cursor + has passed it, which is the only provider this poller is currently + verified against. + """ + for _ in range(_MAX_PAGES_PER_TICK): + page = await self._provider.query_messages(since_cursor=self._watermark) + for message in page.messages: + await self._on_message(dict(message)) + if page.next_cursor != self._watermark: + self._watermark = page.next_cursor + if self._watermark is not None: + await self._save_watermark(self._watermark) + elif not page.messages: + return + if not page.has_more: + return diff --git a/app/remote/imessage/provider.py b/app/remote/imessage/provider.py new file mode 100644 index 000000000..c16e5fa7c --- /dev/null +++ b/app/remote/imessage/provider.py @@ -0,0 +1,90 @@ +"""Provider-neutral iMessage transport contracts and provider selection.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Protocol + +from app.remote.contracts import RemoteAttachment, RemoteProviderKind +from app.remote.imessage.rpc import IMessageRpcClient + +#: Default bounded page size for one catchup call. +DEFAULT_QUERY_LIMIT = 200 + + +@dataclass(frozen=True) +class MessagePage: + """One page of new messages plus an opaque forward cursor. + + ``since_cursor``/``next_cursor`` are deliberately opaque strings at this + shared boundary: each concrete provider owns what the string actually + means (imsg encodes its numeric, exclusive ``since_rowid``; BlueBubbles + keeps its own native cursor) so the poller and the durable watermark + column never need to know which provider produced it. + """ + + messages: Sequence[Mapping[str, Any]] + next_cursor: str | None + has_more: bool + + +class IMessageProvider(Protocol): + """Transport contract consumed by the remote runtime.""" + + async def start(self) -> None: ... + + async def stop(self) -> None: ... + + async def status(self) -> Mapping[str, Any]: ... + + async def query_messages( + self, *, since_cursor: str | None = None, limit: int = DEFAULT_QUERY_LIMIT + ) -> MessagePage: ... + + async def send_text( + self, + *, + chat_id: str, + text: str, + reply_to_id: str | None = None, + attachments: Sequence[RemoteAttachment] = (), + ) -> Mapping[str, Any]: ... + + +class IMessageProviderError(Exception): + """Safe provider failure without raw payloads.""" + + +class IMessageProviderFactory: + """Construct the configured provider without leaking provider DTOs.""" + + def __init__(self, *, rpc_client: IMessageRpcClient | None = None) -> None: + self._rpc_client = rpc_client + + def create( + self, + provider: RemoteProviderKind | str, + *, + endpoint_url: str | None = None, + password: str | None = None, + ) -> IMessageProvider: + try: + normalized = RemoteProviderKind(provider).value + except ValueError as exc: + raise IMessageProviderError( + f"Unsupported iMessage provider: {provider}" + ) from exc + if normalized == "imsg": + from app.remote.imessage.imsg_provider import IMsgProvider + + return IMsgProvider(client=self._rpc_client or IMessageRpcClient()) + if normalized == "bluebubbles": + from app.remote.imessage.bluebubbles import BlueBubblesProvider + + if endpoint_url is None or password is None: + raise IMessageProviderError( + "BlueBubbles provider requires endpoint and credential." + ) + return BlueBubblesProvider(endpoint_url=endpoint_url, password=password) + raise IMessageProviderError(f"Unsupported iMessage provider: {normalized}") diff --git a/app/remote/imessage/redaction.py b/app/remote/imessage/redaction.py new file mode 100644 index 000000000..353a6bd16 --- /dev/null +++ b/app/remote/imessage/redaction.py @@ -0,0 +1,12 @@ +"""Compatibility wrapper for the shared remote redaction boundary.""" + +from __future__ import annotations + +from app.remote.redaction import redact_remote_text + + +def redact(text: str) -> str: + return redact_remote_text(text) + + +__all__ = ["redact"] diff --git a/app/remote/imessage/rpc.py b/app/remote/imessage/rpc.py new file mode 100644 index 000000000..156705b02 --- /dev/null +++ b/app/remote/imessage/rpc.py @@ -0,0 +1,182 @@ +"""Small JSON-RPC 2.0 client for the ``imsg rpc`` stdio protocol. + +The protocol is line-delimited JSON on stdin/stdout. This client deliberately +keeps provider details behind a narrow request surface: it never opens a TCP +socket, logs payloads, or treats a notification as a response. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import itertools +import os +import json +from collections.abc import Mapping +from typing import Any + +_DEFAULT_COMMAND = ("imsg", "rpc") +_DEFAULT_TIMEOUT_SECONDS = 30.0 + + +class IMessageRpcError(Exception): + """A provider-level JSON-RPC error.""" + + def __init__(self, *, code: int, message: str) -> None: + self.code = code + self.message = message + super().__init__(f"imsg RPC error {code}: {message}") + + +class IMessageRpcProtocolError(Exception): + """The child emitted malformed or otherwise unusable JSON-RPC data.""" + + +class IMessageRpcClient: + """Long-lived, serialized JSON-RPC client for one ``imsg rpc`` child.""" + + def __init__( + self, + *, + command: tuple[str, ...] = _DEFAULT_COMMAND, + timeout: float = _DEFAULT_TIMEOUT_SECONDS, + env: Mapping[str, str] | None = None, + ) -> None: + self._command = command + self._timeout = timeout + self._env = {**os.environ, **env} if env is not None else None + self._process: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task[None] | None = None + self._write_lock = asyncio.Lock() + self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._request_ids = itertools.count(1) + self._closed = False + + @property + def running(self) -> bool: + """Whether the child process is currently available for requests.""" + return self._process is not None and self._process.returncode is None + + async def start(self) -> dict[str, Any]: + """Start the child and return the provider's readiness snapshot.""" + if self.running: + return await self.request("status") + if self._reader_task is not None: + self._reader_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reader_task + self._closed = False + try: + self._process = await asyncio.create_subprocess_exec( + *self._command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._env, + ) + except OSError as exc: + self._process = None + raise IMessageRpcProtocolError( + f"Unable to start imsg provider ({type(exc).__name__})." + ) from exc + assert self._process.stdout is not None + self._reader_task = asyncio.create_task(self._read_responses()) + return await self.request("initialize", params={"protocol_version": 1}) + + async def stop(self) -> None: + """Close stdin and await the child, cancelling outstanding requests.""" + self._closed = True + process = self._process + self._process = None + if process is None: + return + if process.stdin is not None: + process.stdin.close() + with contextlib.suppress(BrokenPipeError): + await process.stdin.wait_closed() + try: + await asyncio.wait_for(process.wait(), timeout=self._timeout) + except asyncio.TimeoutError: + process.kill() + await process.wait() + finally: + self._fail_pending("imsg provider stopped") + if self._reader_task is not None: + self._reader_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reader_task + self._reader_task = None + + async def request( + self, + method: str, + *, + params: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + """Send one request and await its matching JSON-RPC response.""" + process = self._process + if self._closed or process is None or process.stdin is None: + raise IMessageRpcProtocolError("imsg provider is not running") + request_id = str(next(self._request_ids)) + future: asyncio.Future[dict[str, Any]] = ( + asyncio.get_running_loop().create_future() + ) + self._pending[request_id] = future + payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + payload["params"] = dict(params) + try: + async with self._write_lock: + process.stdin.write( + (json.dumps(payload, separators=(",", ":")) + "\n").encode() + ) + await process.stdin.drain() + response = await asyncio.wait_for(future, timeout=self._timeout) + except asyncio.TimeoutError as exc: + self._pending.pop(request_id, None) + raise IMessageRpcProtocolError(f"imsg request timed out: {method}") from exc + finally: + self._pending.pop(request_id, None) + if "error" in response: + error = response["error"] + if not isinstance(error, dict): + raise IMessageRpcProtocolError("imsg returned an invalid error object") + raise IMessageRpcError( + code=int(error.get("code", -32603)), + message=str(error.get("message", "provider error")), + ) + result = response.get("result") + if not isinstance(result, dict): + raise IMessageRpcProtocolError("imsg returned a non-object result") + return result + + async def _read_responses(self) -> None: + process = self._process + if process is None or process.stdout is None: + return + try: + async for line in process.stdout: + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + self._fail_pending("imsg returned malformed JSON") + raise IMessageRpcProtocolError( + "imsg returned malformed JSON" + ) from exc + if not isinstance(record, dict): + continue + response_id = record.get("id") + if response_id is None: + continue + future = self._pending.get(str(response_id)) + if future is not None and not future.done(): + future.set_result(record) + except (asyncio.CancelledError, IMessageRpcProtocolError): + raise + except Exception: + self._fail_pending("imsg provider stream closed") + + def _fail_pending(self, message: str) -> None: + for future in self._pending.values(): + if not future.done(): + future.set_exception(IMessageRpcProtocolError(message)) diff --git a/app/remote/imessage/streaming.py b/app/remote/imessage/streaming.py new file mode 100644 index 000000000..e697c0abf --- /dev/null +++ b/app/remote/imessage/streaming.py @@ -0,0 +1,7 @@ +"""Compatibility imports for the shared non-editing streaming strategy.""" + +from __future__ import annotations + +from app.remote.streaming import BlockStreaming, chunk_text + +__all__ = ["BlockStreaming", "chunk_text"] diff --git a/app/remote/inbound.py b/app/remote/inbound.py new file mode 100644 index 000000000..58734f904 --- /dev/null +++ b/app/remote/inbound.py @@ -0,0 +1,255 @@ +"""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.agent_service import RawAttachment +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() + raw_attachments = ( + [ + RawAttachment( + data=att.content, + filename=att.filename, + content_type=att.mime_type, + ) + for att in action.attachments + ] + if action.attachments + else None + ) + result = await submit_persisted_interactive_message( + db, + session=session, + team=team, + content=content, + attachments=raw_attachments or None, + message_extra={ + "interactive_source": { + "channel": "remote", + "adapter": "telegram", + "connection_id": str(action.connection_id), + "key": action.source_key, + "request_hash": request_hash, + "state": "persisted", + }, + **( + {"delivery": action.delivery} + if action.delivery in {"steer", "queue"} + else {} + ), + }, + 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 000000000..b726958b6 --- /dev/null +++ b/app/remote/live_activity.py @@ -0,0 +1,155 @@ +"""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: + """Apply the shared remote-channel outbound redaction boundary.""" + from app.remote.redaction import redact_remote_text + + return redact_remote_text(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/live_budget.py b/app/remote/live_budget.py new file mode 100644 index 000000000..1f76d7c2c --- /dev/null +++ b/app/remote/live_budget.py @@ -0,0 +1,62 @@ +"""Shared rolling edit budget for transports without provider-specific limits.""" + +from __future__ import annotations + +import time +from collections import deque + + +class RollingEditBudget: + """Bounded rolling edit allowance with per-key duplicate suppression.""" + + def __init__(self, *, window_seconds: float = 15 * 60, max_edits: int = 4) -> None: + if window_seconds <= 0 or max_edits < 1: + raise ValueError( + "window_seconds must be positive and max_edits must be >= 1" + ) + self._window_seconds = window_seconds + self._max_edits = max_edits + self._edits: dict[str, deque[float]] = {} + self._last: dict[str, str] = {} + + def allow_edit( + self, *, connection_id: str, key: str, text: str, now: float | None = None + ) -> bool: + moment = time.monotonic() if now is None else now + edits = self._edits.setdefault(connection_id, deque()) + while edits and edits[0] <= moment - self._window_seconds: + edits.popleft() + return self._last.get(key) != text and len(edits) < self._max_edits + + def record_edit( + self, *, connection_id: str, key: str, text: str, now: float | None = None + ) -> None: + moment = time.monotonic() if now is None else now + self._edits.setdefault(connection_id, deque()).append(moment) + self._last[key] = text + + def forget(self, key: str) -> None: + self._last.pop(key, None) + + def delivery_mode( + self, + *, + connection_id: str, + key: str, + text: str, + now: float | None = None, + ) -> str: + """Return ``edit``, ``new``, or ``skip`` for a live status update.""" + if self._last.get(key) == text: + return "skip" + if self.allow_edit(connection_id=connection_id, key=key, text=text, now=now): + self.record_edit(connection_id=connection_id, key=key, text=text, now=now) + return "edit" + self._last[key] = text + return "new" + + def completion_allowed(self) -> bool: + return True + + +__all__ = ["RollingEditBudget"] diff --git a/app/remote/outbound.py b/app/remote/outbound.py new file mode 100644 index 000000000..bb1a2104a --- /dev/null +++ b/app/remote/outbound.py @@ -0,0 +1,1315 @@ +"""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 types import SimpleNamespace +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, + markdown_to_telegram_html, + 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 +_PROCESS_HEARTBEAT_SECONDS = 3.0 + +#: 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) + process_heartbeat_task: "asyncio.Task[None] | None" = field( + default=None, repr=False + ) + stop_token: str | 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 + response_text: str = "" + draft_id: int = field(default_factory=lambda: uuid.uuid4().int % 2_000_000_000 + 1) + draft_task: asyncio.Task[None] | None = field(default=None, repr=False) + + +@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) + #: The event loop that owns adapter delivery. Stream observers can arrive + #: from worker threads, so `_schedule_drain` must be able to hand work back + #: to this loop instead of silently dropping the notification. + _delivery_loop: asyncio.AbstractEventLoop | None = field(default=None, 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 not None: + try: + self._delivery_loop = asyncio.get_running_loop() + except RuntimeError: + pass + else: + self._delivery_loop = 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)) + if response_mode == "live": + turn.process_heartbeat_task = loop.create_task( + self._run_process_heartbeat(turn) + ) + + async def _run_process_heartbeat(self, turn: _TurnDeliveryState) -> None: + """Refresh the live process card while a turn has no new event.""" + try: + while not turn.completion_sent: + await asyncio.sleep(_PROCESS_HEARTBEAT_SECONDS) + if turn.completion_sent or self._adapter is None: + return + self._maybe_schedule_live_edit(turn, allow_draft=False) + except asyncio.CancelledError: + pass + + 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 or "remote_origin" not in tags: + # Desktop-origin sessions may be registered with ordinary tags (or + # not registered in this projection at all). When the active + # pairing explicitly requests all notifications, still deliver + # terminal outcomes through the addressability-checked fallback. + # Live streaming remains reserved for remote-origin turns until a + # session has a verified title and destination-bound card. + active = self._active_pairing + if active is None or active[2] != "all": + 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 + + 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": + delta = envelope.data.get("text", "") + if isinstance(delta, str) and delta: + turn.response_text += _redact_text(delta) + 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() + + @staticmethod + def _cancel_draft_task(turn: _TurnDeliveryState) -> None: + task = turn.draft_task + if task is not None and not task.done(): + task.cancel() + turn.draft_task = None + heartbeat = turn.process_heartbeat_task + if heartbeat is not None and not heartbeat.done(): + heartbeat.cancel() + turn.process_heartbeat_task = None + + def _handle_done(self, turn: _TurnDeliveryState, envelope) -> None: + if turn.completion_sent: + return + logger.info( + "remote_handle_done session_id={} phone_admitted={}", + turn.session_id, + turn.phone_admitted, + ) + turn.completion_sent = True + self._cancel_draft_task(turn) + 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 + self._cancel_draft_task(turn) + 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 + 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, + ) + + 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=None, + ) + else: + text, buttons = render_done_card( + title=heading, + elapsed_seconds=elapsed, + response_text=( + _redact_text(activity.response_text) + if activity.response_text + else None + ), + # Tool names and arguments are internal execution details; + # terminal delivery keeps only the user-facing response and + # usage/credit footer. + summary_lines=(), + tool_call_count=0, + diff_token=diff_token, + toollog_token=None, + 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, + ) + + # Live edits are obsolete once the turn has completed. Drop them before + # queueing the terminal card so a burst of deltas cannot delay or + # overwrite the done/error notification. + if turn.lifecycle_correlation_id is not None: + self._pending_edits = [ + message + for message in self._pending_edits + if message.correlation_id != turn.lifecycle_correlation_id + ] + + # 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, + ) + # Telegram edits do not generate a phone notification. Adapters + # whose edits are silent also receive a fresh terminal message. + if not getattr(self._adapter, "edit_notifies_user", True): + self._enqueue_send( + destination_id=turn.destination_id, + text=text, + buttons=buttons, + priority=RemoteOutboundPriority.HIGH, + ) + 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, *, allow_draft: bool = True + ) -> None: + correlation_id = turn.lifecycle_correlation_id + if correlation_id is None: + return + adapter = self._adapter + provider = getattr(adapter, "streaming_provider", None) + send_draft = getattr(adapter, "send_draft", None) + if ( + allow_draft + and turn.response_text + and provider == "private_draft" + and callable(send_draft) + ): + draft_text = markdown_to_telegram_html(turn.response_text[:3000]) + draft_key = f"draft:{turn.session_id}" + if self._edit_budget.should_edit( + connection_id=turn.connection_id, key=draft_key, text=draft_text + ): + self._edit_budget.record_edit( + connection_id=turn.connection_id, key=draft_key, text=draft_text + ) + turn.draft_task = asyncio.create_task( + self._send_draft_update( + send_draft=send_draft, + destination_id=turn.destination_id, + draft_id=turn.draft_id, + text=draft_text, + ) + ) + return + if self._actions is not None and turn.stop_token is None: + turn.stop_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="stop_session", + action_target=turn.session_id, + ) + 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(), + response_text=turn.response_text, + stop_token=turn.stop_token, + 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, + ) + + async def _send_draft_update( + self, + *, + send_draft: Any, + destination_id: str, + draft_id: int, + text: str, + ) -> None: + try: + await send_draft( + destination_id=destination_id, draft_id=draft_id, text=text + ) + except Exception as exc: + logger.warning( + "remote_telegram_draft_update_failed destination_id={} error={}", + destination_id, + exc, + ) + + 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 + + # The turn-usage publisher emits a cumulative snapshot marked by + # metadata.turn_total. Per-call events are additive; cumulative + # snapshots replace the running total or they would be double-counted + # in the remote Done card. + cumulative = metadata.get("turn_total") is True + prompt = data.get("prompt_tokens") or data.get("input_tokens") + if isinstance(prompt, int): + turn.usage_input_tokens = ( + prompt if cumulative else (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 = ( + completion + if cumulative + else (turn.usage_output_tokens or 0) + completion + ) + + cached = data.get("cached_tokens") + if isinstance(cached, int): + turn.usage_cached_tokens = ( + cached if cumulative else (turn.usage_cached_tokens or 0) + cached + ) + + thoughts = data.get("thoughts_tokens") + if isinstance(thoughts, int): + turn.usage_reasoning_tokens = ( + thoughts + if cumulative + else (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 = ( + float(estimated) + if cumulative + else (turn.usage_cost_usd or 0.0) + float(estimated) + ) + else: + amount = sum(v for v in cost.values() if isinstance(v, (int, float))) + turn.usage_cost_usd = ( + amount if cumulative else (turn.usage_cost_usd or 0.0) + amount + ) + elif isinstance(cost, (int, float)): + turn.usage_cost_usd = ( + float(cost) + if cumulative + else (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 delivery on the adapter-owning loop. + + Agent stream callbacks may execute on a worker thread. Calling + ``get_running_loop`` there used to silently drop every queued terminal + notification; hand the coroutine back to the loop captured at adapter + startup instead. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = self._delivery_loop + if loop is None or loop.is_closed(): + return + loop.call_soon_threadsafe(lambda: loop.create_task(self.drain_pending())) + return + loop.create_task(self.drain_pending()) + + 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, + ) + # Fallback: send a plain terminal message so the user + # always knows the turn finished, even if the rich + # card failed to render. + try: + fallback = ( + f"\u274c Task failed: {error_message}" + if error_message + else "\u2705 Task complete." + ) + await adapter.send( + RemoteOutboundMessage( + connection_id=turn.connection_id, + destination_id=turn.destination_id, + text=fallback, + buttons=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + except Exception: + pass + # 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 event_type not in {"done", "error"}: + # Admission is now validated. Register the session before + # replaying the first event so subsequent deltas follow the + # ordinary live lifecycle instead of re-entering fallback. + self.register_session( + session_id, + connection_id=connection_id, + destination_id=destination_id, + tags=frozenset({"remote_origin", "desktop_admitted"}), + ) + if session_id not in self._turns: + self.begin_phone_turn( + session_id, + connection_id=connection_id, + destination_id=destination_id, + principal_id=principal_id, + title=session_row.title or "Desktop task", + status="Working", + response_mode="live", + ) + self.observe( + session_id, + SimpleNamespace(event=event_type, data=envelope_data), + ) + continue + 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 the shared remote-channel outbound redaction boundary.""" + from app.remote.redaction import redact_remote_text + + return redact_remote_text(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 000000000..8f8377cda --- /dev/null +++ b/app/remote/pairing.py @@ -0,0 +1,643 @@ +"""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 + + # Exclude a pending phone-first pairing code (empty + # principal_id) — it is not a completed pairing, and treating + # it as one would block a legitimate link-based consume on a + # connection that merely has an unrelated code outstanding. + existing = ( + await session.exec( + select(RemotePairing).where( + RemotePairing.connection_id == principal.connection_id, + RemotePairing.principal_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/redaction.py b/app/remote/redaction.py new file mode 100644 index 000000000..037d0aa86 --- /dev/null +++ b/app/remote/redaction.py @@ -0,0 +1,20 @@ +"""Shared outbound redaction boundary for remote transports.""" + +from __future__ import annotations + +from app.agent.outbound_redaction import OutboundContext, protect_outbound_text + + +def redact_remote_text(text: str) -> str: + """Protect remote-bound text without making decoration fail a turn.""" + try: + protected, _report = protect_outbound_text( + text, + context=OutboundContext(channel="remote"), + ) + return protected + except Exception: + return text + + +__all__ = ["redact_remote_text"] diff --git a/app/remote/registry.py b/app/remote/registry.py new file mode 100644 index 000000000..4ded40b65 --- /dev/null +++ b/app/remote/registry.py @@ -0,0 +1,115 @@ +"""Common registry for provider-neutral remote adapter construction.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Protocol +from uuid import UUID + +from app.models.remote import RemoteConnection +from app.remote.contracts import ( + RemoteAdapter, + RemoteAdapterKind, + RemoteInboundAction, + RemoteProviderKind, + ValidatedRemoteIdentity, +) +from app.remote.imessage.adapter import IMessageRemoteAdapter +from app.remote.imessage.provider import IMessageProviderError, IMessageProviderFactory +from app.remote.imessage.rpc import IMessageRpcError, IMessageRpcProtocolError + +RemoteActionHandler = Callable[[RemoteInboundAction], Awaitable[object]] +AdapterConstructor = Callable[[UUID, str, RemoteActionHandler], RemoteAdapter] +IdentityValidator = Callable[[str], Awaitable[ValidatedRemoteIdentity]] + + +class RemoteAdapterRegistry(Protocol): + async def validate( + self, + connection: RemoteConnection, + credential: str, + ) -> ValidatedRemoteIdentity: ... + + def create( + self, + connection: RemoteConnection, + credential: str, + on_action: RemoteActionHandler, + ) -> RemoteAdapter: ... + + +class DefaultRemoteAdapterRegistry: + def __init__( + self, + *, + telegram_constructor: AdapterConstructor | None = None, + telegram_validator: IdentityValidator | None = None, + imessage_provider_factory: IMessageProviderFactory | None = None, + ) -> None: + self._telegram_constructor = telegram_constructor + self._telegram_validator = telegram_validator + self._imessage_provider_factory = ( + imessage_provider_factory or IMessageProviderFactory() + ) + + async def validate( + self, + connection: RemoteConnection, + credential: str, + ) -> ValidatedRemoteIdentity: + adapter = RemoteAdapterKind(connection.adapter) + if adapter is RemoteAdapterKind.TELEGRAM: + if self._telegram_validator is None: + raise ValueError("Telegram validation is not configured") + return await self._telegram_validator(credential) + if adapter is RemoteAdapterKind.IMESSAGE: + provider = RemoteProviderKind( + connection.provider or RemoteProviderKind.IMSG + ) + try: + transport = self._imessage_provider_factory.create( + provider, + endpoint_url=connection.endpoint_url, + password=credential, + ) + try: + await transport.start() + finally: + await transport.stop() + except ( + IMessageProviderError, + IMessageRpcError, + IMessageRpcProtocolError, + OSError, + ) as exc: + raise ValueError(f"iMessage provider validation failed: {exc}") from exc + return ValidatedRemoteIdentity( + adapter=RemoteAdapterKind.IMESSAGE, + principal_id=f"imessage:{provider.value}", + username=provider.value, + ) + raise ValueError(f"Unsupported remote adapter: {connection.adapter}") + + def create( + self, + connection: RemoteConnection, + credential: str, + on_action: RemoteActionHandler, + ) -> RemoteAdapter: + adapter = RemoteAdapterKind(connection.adapter) + if adapter is RemoteAdapterKind.TELEGRAM: + if self._telegram_constructor is None: + raise ValueError("Telegram construction is not configured") + return self._telegram_constructor(connection.id, credential, on_action) + if adapter is RemoteAdapterKind.IMESSAGE: + provider = RemoteProviderKind( + connection.provider or RemoteProviderKind.IMSG + ) + return IMessageRemoteAdapter( + connection_id=connection.id, + credential=credential, + on_action=on_action, + provider_name=provider, + endpoint_url=connection.endpoint_url, + ) + raise ValueError(f"Unsupported remote adapter: {connection.adapter}") diff --git a/app/remote/runtime.py b/app/remote/runtime.py new file mode 100644 index 000000000..dea7626f5 --- /dev/null +++ b/app/remote/runtime.py @@ -0,0 +1,731 @@ +"""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.registry import DefaultRemoteAdapterRegistry, RemoteAdapterRegistry +from app.remote.contracts import ( + RemoteAdapter, + RemoteAdapterKind, + RemoteAdapterStatus, + RemotePairingAwareAdapter, + 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, + adapter_registry: RemoteAdapterRegistry | 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 + self._adapter_registry = adapter_registry or DefaultRemoteAdapterRegistry( + telegram_constructor=lambda connection_id, token, on_action: ( + self._adapter_constructor(connection_id, token, on_action) + ) + ) + + #: 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 + + @property + def projection(self) -> "RemoteProjection | None": + """The currently running outbound projection, if any. Read-only.""" + return self._projection + + 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_registry.create( + connection, + token, + self._handle_action, + ) + if hasattr(adapter, "streaming_provider"): + setattr(adapter, "streaming_provider", connection.provider) + 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 *completed* pairing per connection (AC-3), so + # `.first()` is always the right row to cache — but a pending + # phone-first pairing code (empty principal_id) must be excluded, + # or restoring it would bind the iMessage adapter to an empty + # contact instead of leaving it in discovery mode. + 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, + RemotePairing.principal_id != "", + ) + ) + ).first() + if pairing is not None: + if isinstance(adapter, RemotePairingAwareAdapter): + adapter.set_pairing( + principal_id=pairing.principal_id, + destination_id=pairing.destination_id, + ) + await adapter.start() + 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: + text = (action.text or "").strip() + if text.lower().startswith("/steer") and ( + len(text) == 6 or text[6].isspace() + ): + await self._handle_text(action) + elif text.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: + """Handle ``/start``: deep-link token, 8-digit code, or bare prompt.""" + from app.core.db import async_session_factory + from app.remote.pairing import pairing_service + + token = action.pairing_token + + # ── Bare /start (no token): show pairing prompt or help ───────── + if not token: + async with async_session_factory() as db: + pairing = await pairing_service.authorize( + db, + connection_id=action.connection_id, + principal_id=action.principal.principal_id, + ) + if pairing is not None: + # Already paired — show help text. + if self._adapter is not None: + from app.remote.actions import _HELP_TEXT + + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text=_HELP_TEXT, + buttons=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + else: + # Not paired — show pairing prompt. + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text=( + "Welcome to EvoFlux!\n\n" + "Open EvoFlux on your desktop and copy the " + "8-digit pairing code, then send:\n\n" + "/start 12345678" + ), + buttons=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + return + + # ── 8-digit code: verify pair code ────────────────────────────── + if token.isdigit(): + from app.remote.pairing import ( + PairingCodeExpired, + PairingCodeMismatch, + PairingCodeRateLimited, + ) + + try: + async with async_session_factory() as db: + pairing = await pairing_service.verify_pair_code( + db, + connection_id=action.connection_id, + principal=action.principal, + raw_code=token, + ) + except PairingCodeExpired: + text = "Code expired. Open EvoFlux and generate a new one." + except PairingCodeMismatch: + text = "That code didn't match. Check the code and try again." + except PairingCodeRateLimited: + text = "Too many attempts. Wait a moment and try again." + else: + if pairing is not None: + logger.info( + "remote_pairing_code_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, + ) + if self._adapter is not None: + await self._adapter.send( + RemoteOutboundMessage( + connection_id=action.connection_id, + destination_id=action.principal.destination_id, + text="Phone connected! Send any message to get started.", + buttons=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + return + text = "Pairing failed. Try again." + + 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=(), + priority=RemoteOutboundPriority.HIGH, + ) + ) + return + + # ── Deep-link token: consume pairing token ────────────────────── + + 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, including explicit steering messages.""" + from dataclasses import replace + + from app.core.db import async_session_factory + + if action.text and action.text.startswith("/steer"): + steering_text = action.text[6:].strip() + if not steering_text: + return + action = replace(action, text=steering_text, delivery="steer") + 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 000000000..082e90953 --- /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/streaming.py b/app/remote/streaming.py new file mode 100644 index 000000000..a71568308 --- /dev/null +++ b/app/remote/streaming.py @@ -0,0 +1,68 @@ +"""Provider-capability based remote streaming strategy selection.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from enum import StrEnum + +from app.remote.contracts import RemoteAdapterKind + +_MAX_BLOCK_LENGTH = 4096 + + +class StreamingStrategyKind(StrEnum): + DRAFT = "draft" + EDIT = "edit" + BLOCK = "block" + + +def chunk_text(text: str, *, limit: int = _MAX_BLOCK_LENGTH) -> list[str]: + """Split text without breaking Unicode code points or dropping content.""" + if limit < 1: + raise ValueError("limit must be positive") + return [text[index : index + limit] for index in range(0, len(text), limit)] or [] + + +class BlockStreaming: + """Provider-neutral bounded delivery for transports without message edits.""" + + def __init__(self, send: Callable[[str], Awaitable[None]]) -> None: + self._send = send + self._buffer = "" + self._finished = False + + async def append(self, delta: str) -> None: + if self._finished: + raise RuntimeError("stream already finished") + self._buffer += delta + blocks = chunk_text(self._buffer) + while len(blocks) > 1: + await self._send(blocks.pop(0)) + self._buffer = blocks[0] if blocks else "" + + async def finish(self) -> None: + if self._finished: + return + self._finished = True + if self._buffer: + await self._send(self._buffer) + self._buffer = "" + + +def select_streaming_strategy( + *, adapter: RemoteAdapterKind | str, provider: str | None = None +) -> StreamingStrategyKind: + adapter_kind = RemoteAdapterKind(adapter) + if adapter_kind is RemoteAdapterKind.IMESSAGE: + return StreamingStrategyKind.BLOCK + if adapter_kind is RemoteAdapterKind.TELEGRAM and provider == "private_draft": + return StreamingStrategyKind.DRAFT + return StreamingStrategyKind.EDIT + + +__all__ = [ + "BlockStreaming", + "StreamingStrategyKind", + "chunk_text", + "select_streaming_strategy", +] diff --git a/app/remote/telegram/__init__.py b/app/remote/telegram/__init__.py new file mode 100644 index 000000000..74e18d584 --- /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 000000000..baff627b2 --- /dev/null +++ b/app/remote/telegram/adapter.py @@ -0,0 +1,754 @@ +"""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 dataclasses import replace +from datetime import UTC, datetime +from urllib.parse import urlparse +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 +MAX_PHOTO_SIZE_BYTES = 10 * 1024 * 1024 + +#: 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. + """ + + # Terminal cards stay consolidated into the live process message. Telegram + # edits are intentionally treated as the canonical completion transition; + # callers must not emit a duplicate terminal message. + edit_notifies_user = True + + 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.streaming_provider: str | None = None + 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: + for attachment in message.attachments: + parsed = urlparse(attachment.url) + if parsed.scheme != "https" or not parsed.netloc: + logger.warning( + "telegram_attachment_rejected reason=invalid_url destination_id={}", + message.destination_id, + ) + continue + if attachment.mime_type and not attachment.mime_type.startswith("image/"): + logger.warning( + "telegram_attachment_rejected reason=unsupported_mime destination_id={}", + message.destination_id, + ) + continue + if attachment.size_bytes is not None and ( + attachment.size_bytes < 0 + or attachment.size_bytes > MAX_PHOTO_SIZE_BYTES + ): + logger.warning( + "telegram_attachment_rejected reason=size_limit destination_id={}", + message.destination_id, + ) + continue + try: + photo = await self._client.send_photo( + chat_id=message.destination_id, + photo=attachment.url, + ) + self._message_history[message.destination_id].append(photo.message_id) + except TelegramApiError as exc: + self._record_delivery_failure(exc) + logger.warning( + "telegram_attachment_failed destination_id={} error={}", + message.destination_id, + exc, + ) + 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 send_draft( + self, *, destination_id: str, draft_id: int, text: str + ) -> None: + """Send an ephemeral partial response for private-draft providers.""" + try: + await self._client.send_message_draft( + chat_id=destination_id, + draft_id=draft_id, + text=text, + ) + 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``, ``skills``, ``skill``, ``steer``, + ``switch``, ``delete``, and ``agent`` are deliberate additions + beyond the control-surface spec's original AC-58 bounded set (9 + commands) — requested directly by users testing this feature + live, after that set was first implemented. ``/pair`` was + removed: ``/start `` now handles code-based pairing. + """ + 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 thinking level"), + ("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"), + ("skills", "Browse available skills"), + ("skill", "Load a skill by name"), + ("steer", "Redirect the agent mid-task"), + ("switch", "Switch to another session"), + ("delete", "Delete unused sessions"), + ("agent", "Show agent info and model"), + ("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: + action = await self._materialize_media(action, update) + 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: + return None + text = message.text or message.caption + if text is None and message.photo: + largest = message.photo[-1] + text = ( + f"[Image attached: {largest.width}×{largest.height}; " + f"file_id={largest.file_id}]" + ) + elif text is None and message.document is not None: + document = message.document + name = document.file_name or "unnamed file" + details = document.mime_type or "unknown type" + text = f"[File attached: {name}; {details}; file_id={document.file_id}]" + if text is None: + return None # unsupported service message + text = str(text) + normalized_text = text + 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 = normalized_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) + + async def _materialize_media( + self, action: RemoteInboundAction, update: TelegramUpdate + ) -> RemoteInboundAction: + """Download media from Telegram and attach it to the inbound action. + + Files are bounded to 10 MiB. Download failures are swallowed with a + log; the text fallback (caption or descriptive placeholder) is kept. + """ + from app.remote.contracts import RemoteInboundAttachment + + message = update.message + if message is None or action.kind != RemoteInboundActionKind.TEXT: + return action + + attachments: list[RemoteInboundAttachment] = [] + + # Photo — always use the largest resolution. + if message.photo: + largest = message.photo[-1] + try: + content = await self._client.download_file(largest.file_id) + attachments.append( + RemoteInboundAttachment( + content=content, + filename=f"photo_{largest.file_id[:12]}.jpg", + mime_type="image/jpeg", + ) + ) + except Exception: + logger.debug( + "telegram_photo_download_failed file_id={}", + largest.file_id, + ) + + # Document — download by file_id. + if message.document is not None: + document = message.document + try: + content = await self._client.download_file(document.file_id) + filename = document.file_name or f"document_{document.file_id[:12]}" + attachments.append( + RemoteInboundAttachment( + content=content, + filename=filename, + mime_type=document.mime_type, + ) + ) + except Exception: + logger.debug( + "telegram_document_download_failed file_id={}", + document.file_id, + ) + + if not attachments: + return action + return replace(action, attachments=tuple(attachments)) + + # ------------------------------------------------------------------ + # 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 000000000..3f47a57b2 --- /dev/null +++ b/app/remote/telegram/client.py @@ -0,0 +1,419 @@ +"""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) + rows: list[list[dict[str, str]]] = [] + current: list[dict[str, str]] = [] + for button in buttons: + item = {"text": button.text, "callback_data": button.token} + # Compact labels share a row; descriptive labels stay full-width for + # readability and reliable tapping on small screens. + if len(button.text) > 24: + if current: + rows.append(current) + current = [] + rows.append([item]) + else: + current.append(item) + if len(current) == 2: + rows.append(current) + current = [] + if current: + rows.append(current) + return {"inline_keyboard": rows} + + +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() + + async def download_file( + self, file_id: str, *, max_bytes: int = 10 * 1024 * 1024 + ) -> bytes: + """Resolve and download one Telegram file with a bounded body.""" + result = await self._call( + "getFile", {"file_id": file_id}, result_model=dict[str, Any] + ) + file_path = result.get("file_path") + if not isinstance(file_path, str) or not file_path: + raise TelegramMalformedResponseError("Telegram file response had no path.") + try: + async with self._http.stream( + "GET", f"https://api.telegram.org/file/bot{self._token}/{file_path}" + ) as response: + response.raise_for_status() + content_length = response.headers.get("content-length") + if content_length is not None and int(content_length) > max_bytes: + raise TelegramApiError( + error_code=413, + description="Telegram attachment exceeds the size limit.", + ) + chunks: list[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + total += len(chunk) + if total > max_bytes: + raise TelegramApiError( + error_code=413, + description="Telegram attachment exceeds the size limit.", + ) + chunks.append(chunk) + except httpx.HTTPError as exc: + raise TelegramTransportError(cause_type=type(exc).__name__) from None + return b"".join(chunks) + + 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 send_photo( + self, + *, + chat_id: str | int, + photo: str, + caption: str | None = None, + ) -> TelegramMessage: + """Send a validated URL-addressable image to a Telegram chat.""" + payload: dict[str, Any] = {"chat_id": chat_id, "photo": photo} + if caption: + payload["caption"] = caption + payload["parse_mode"] = "HTML" + return await self._call("sendPhoto", payload, result_model=TelegramMessage) + + async def send_message_draft( + self, + *, + chat_id: str | int, + draft_id: int, + text: str, + ) -> None: + """Stream an ephemeral partial response using Telegram's Bot API.""" + if draft_id <= 0: + raise ValueError("draft_id must be positive") + await self._call( + "sendMessageDraft", + { + "chat_id": chat_id, + "draft_id": draft_id, + "text": text, + "parse_mode": "HTML", + }, + result_model=bool, + ) + + 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 000000000..dcdc6a0b2 --- /dev/null +++ b/app/remote/telegram/models.py @@ -0,0 +1,155 @@ +"""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 TelegramPhotoSize(BaseModel): + model_config = ConfigDict(extra="ignore") + + file_id: str + width: int + height: int + file_size: int | None = None + + +class TelegramDocument(BaseModel): + model_config = ConfigDict(extra="ignore") + + file_id: str + file_name: str | None = None + mime_type: str | None = None + file_size: int | None = None + + +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 + caption: str | None = None + photo: list[TelegramPhotoSize] | None = None + document: TelegramDocument | 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 000000000..2b0ea11c2 --- /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/asdd_autopilot.py b/app/services/asdd_autopilot.py deleted file mode 100644 index 79184a95f..000000000 --- a/app/services/asdd_autopilot.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Carrying an ASDD change from one phase to the next, without a click. - -`autopilot: true` on a change meant one thing and read as another. It let an -agent clear a gate on its own judgment — write `auto_approvals.specs`, move -`status` on — and then the turn ended, because that is what every phase Skill -says to do. The rail put a **Continue** button up and waited. Autopilot -approved; a person still drove every hop. - -This module is the driver. At a turn boundary it asks the same question the -rail's Continue button asks — *what phase does autopilot run next?* — and -answers it by starting that phase instead of rendering a button. - -Everything that decides whether a hop is allowed already exists and is reused -verbatim: :func:`app.services.asdd_service.prepare_action` resolves -`autopilot_continue` through the rail, which refuses when autopilot is off, -when a `hold` is raised, when the phase is not offered at this status, and -when a blocker stands. A refusal is the loop's stop condition, not an error to -route around. - -Two bounds of its own: - -- **Progress.** A hop that leaves the change exactly where it was does not get - another one. Without this a change that cannot advance would re-run its - phase every turn, which is the compaction failure with a different name. -- **Hops.** A chain is capped, so a change that oscillates costs a bounded - number of turns rather than a night. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from pathlib import Path - -from loguru import logger - -from app.services.asdd_service import ( - AsddActionBlocked, - catalogue_for, - prepare_action, -) -from app.services.asdd_setup_service import ASDD_MANIFEST -from app.services.asdd_store import AsddStoreError - -#: How many phases autopilot may run before it hands the turn back. A full -#: cycle is propose → specify → (design) → tasks → implement → verify, and -#: implementation re-enters itself while tasks remain, so the cap is generous -#: enough for a real change and small enough to bound a mistake. -MAX_CHAIN_HOPS = 12 - -#: The change a phase prompt names, as `action_prompt` writes it: -#: ``Work on ASDD change `add-note-search` — “…”``. Parsing our own sentence -#: is what lets the driver bind a session to a change without a second -#: identifier: the prompt is already in the transcript, and rule 2 forbids -#: minting an id to carry alongside it. -_CHANGE_IN_PROMPT = re.compile(r"ASDD change `([a-z0-9][a-z0-9-]*)`") - - -@dataclass(frozen=True) -class AutopilotHop: - """The next phase to run, and the state it is running from.""" - - change_id: str - action: str - skill: str - prompt: str - #: What "no progress" means for this change. Compared against the previous - #: hop's value to decide whether the last turn moved anything. - progress: tuple[str, int, int] - - -def stop_reason( - hops: int, previous: tuple[str, int, int] | None, hop: "AutopilotHop" -) -> str | None: - """Why this chain should stop before running *hop*, or ``None`` to run it. - - The rail already said the phase is allowed; these are the loop's own two - bounds, kept here so they can be reasoned about without a running team. - """ - - if hops >= MAX_CHAIN_HOPS: - return "chain_capped" - if previous is not None and hop.progress == previous: - # The last hop ran and left the change exactly where it was. Another - # one would do the same thing for the same reason. - return "no_progress" - return None - - -def change_id_in(text: str | None) -> str | None: - """The change a phase prompt names, or ``None`` when it names none.""" - - if not text: - return None - match = _CHANGE_IN_PROMPT.search(text) - return match.group(1) if match else None - - -def next_hop(workspace: str | Path, change_id: str) -> AutopilotHop | None: - """The phase autopilot runs next for *change_id*, or ``None`` to stop. - - ``None`` is the ordinary outcome, not a failure: autopilot is off, a hold - is up, the change is `ready` and waiting for a person to archive it, or - the rail refuses the next phase because a blocker stands. - """ - - root = Path(workspace).expanduser() - if not (root / ASDD_MANIFEST).is_file(): - return None - - try: - catalogue = catalogue_for(root) - record, prompt, skill = prepare_action( - catalogue, change_id, "autopilot_continue" - ) - except AsddActionBlocked as exc: - logger.debug( - "asdd_autopilot_stop change_id={} reason={}", change_id, exc - ) - return None - except (AsddStoreError, OSError, ValueError) as exc: - logger.warning( - "asdd_autopilot_unreadable change_id={} error={}", change_id, exc - ) - return None - - artifacts = record.artifacts - return AutopilotHop( - change_id=record.change_id, - action="autopilot_continue", - skill=skill, - prompt=prompt, - progress=(artifacts.status, artifacts.tasks_done, len(artifacts.evidence_ids)), - ) diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 27d52ce0e..7e77d9d18 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 e2fad9396..417908de7 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 971bc2c15..e63e0af72 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 000000000..ae638df17 --- /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 000000000..0beb90fe0 --- /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 000000000..743d6f1f4 --- /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/remote-access.md b/documents/architecture/remote-access.md new file mode 100644 index 000000000..1d9f8d028 --- /dev/null +++ b/documents/architecture/remote-access.md @@ -0,0 +1,15 @@ +# Remote access architecture + +## Provider-neutral boundary + +Remote connections are persisted as adapter-scoped records. Telegram and iMessage share connection lifecycle, credential-vault ordering, pairing authorization, redaction, rate limits, gates, status, and provider-neutral adapter construction. Provider startup failures are translated to bounded transport status, and partial startup is cleaned up before status is exposed. Construction and validation flow through `RemoteAdapterRegistry` and the provider-neutral contracts in `app/remote/contracts.py`. + +## iMessage trust and process boundary + +The iMessage adapter is disabled by default and does not open a listening socket. The native `imsg` provider runs as a supervised JSON-RPC stdio child process. BlueBubbles is an explicit REST provider selected by connection metadata; EvoFlux never silently switches providers. + +Provider DTOs and process/HTTP details remain under `app/remote/imessage/`. Generic contracts carry normalized inbound actions, safe status/capabilities, reply identifiers, and URL-addressable attachment metadata only. Credentials remain in the OS credential vault under `connection:`. + +## Testing boundary + +`tests/remote/fixtures/mock_imsg_rpc.py` provides deterministic protocol and restart testing, including capability probes, send/reply/attachment payloads, persisted mock state, and watermark filtering. It does not emulate macOS Messages permissions, TCC, Apple ID/iCloud, encryption, or real phone delivery; those require a real Mac smoke test. diff --git a/documents/architecture/system-overview.md b/documents/architecture/system-overview.md index 1edcf582f..a51a52f50 100644 --- a/documents/architecture/system-overview.md +++ b/documents/architecture/system-overview.md @@ -93,6 +93,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-imessage.md b/documents/features/remote-access-imessage.md new file mode 100644 index 000000000..2f476b123 --- /dev/null +++ b/documents/features/remote-access-imessage.md @@ -0,0 +1,39 @@ +# Remote access — iMessage channel + +Status: **Implemented behind an explicit provider selection** — requires a user-owned Mac running `imsg` for the native provider, or a configured BlueBubbles server as the explicit fallback. + +## Overview + +EvoFlux can expose the existing remote-access workflow through iMessage without changing Telegram behavior. The iMessage adapter is disabled until explicitly configured and uses the same pairing, authorization, redaction, rate-limit, gate, completion, and audit boundaries as the Telegram adapter. + +## Providers + +- `imsg` is the primary provider and communicates through line-delimited JSON-RPC over stdio. +- `bluebubbles` is an explicit REST fallback. EvoFlux never silently switches providers. +- Provider credentials are stored in the OS credential vault under `connection:`; raw credentials are never returned by the API or stored in application tables. + +## Setup + +1. Open Settings → Remote access. +2. Select `iMessage`. +3. Select `imsg` for a reachable Mac host or `BlueBubbles` for a configured BlueBubbles endpoint. +4. Enter the provider credential and endpoint when required. +5. Validate and enable the connection. +6. Complete pairing with the configured phone identity. + +BlueBubbles requires an endpoint URL and credential. Native `imsg` requires the provider process to be reachable and authorized by macOS Messages. Capability and health information is safe metadata only; provider payloads, credentials, and message history are not exposed. + +## Runtime behavior + +The adapter starts the provider before capability probing, restores pairing before inbound polling, and persists an inbound receipt watermark. Provider startup failures become safe transport status and partial startup is cleaned up rather than leaking provider exceptions. On restart, messages at or before the watermark are not replayed. Outbound messages pass through the common remote redaction boundary and may carry a provider-neutral reply target and URL-addressable attachment metadata. + +## Security and trust boundaries + +- iMessage remains disabled by default and does not open an inbound listening socket. +- Provider-specific RPC/HTTP DTOs stay behind `app/remote/imessage/`. +- Generic remote contracts carry only normalized actions, safe status, reply identifiers, and attachment metadata. +- macOS Messages/TCC, Apple ID state, and phone delivery require a real Mac and cannot be proven by the protocol mock. + +## Verification + +Protocol and channel tests can use `tests/remote/fixtures/mock_imsg_rpc.py`, including process restart and persisted mock state. A real-device smoke test remains required for macOS permissions, provider reachability, pairing, phone delivery, and recovery. diff --git a/documents/features/remote-access.md b/documents/features/remote-access.md new file mode 100644 index 000000000..5142453e0 --- /dev/null +++ b/documents/features/remote-access.md @@ -0,0 +1,366 @@ +# 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 with animated + spinner and phase labels (Thinking/Planning/Working/Checking). The card + includes the current model, token usage, and estimated cost in real time. +4. On completion or error, the adapter edits that card into a bounded final + summary. It never mirrors tool-call details, arguments, or file-path deltas + into the live status text. The final card shows only user-facing results, + model info, token/cache/reasoning usage, and estimated cost. +5. A completed card can offer **Full diff** if file changes are available. + Tool-log buttons are no longer shown on terminal cards. + +### Media delivery + +**Outbound:** Photos and other media can be sent from EvoFlux to Telegram using +the `sendPhoto` API. HTTPS-only URLs are enforced; non-HTTPS URLs and images +over 10 MiB are rejected with a text fallback. + +**Inbound:** Telegram photos and documents sent to the bot are downloaded, +validated (size, MIME type, magic bytes), and passed through the provider-neutral +attachment pipeline to agent context. Files over 10 MiB are rejected with a +text fallback. iMessage attachments follow the same pipeline when the provider +supports materialization. + +### Steering chat + +Users can send steering instructions to an active task using `/steer`: + +``` +/steer Focus on authentication before UI changes +/steer Don't modify tests yet, keep them as-is +``` + +Steering messages are injected into the active task's context as priority +instructions. + +### Settings + +The `/settings` command displays a rich card with: + +- Connection status with emoji indicator +- Current model (with provider icon) +- Lead agent +- Response mode (live/terminal) +- Thinking level (none/low/medium/high) +- Permission mode +- Provider count and total models available + +Models are grouped by provider. Tapping a provider button opens a per-provider +card with pricing (input/output cost per 1M tokens) and a checkmark on the +active selection. + +### Info commands + +- `/status` — Full dashboard: connection, session, providers, models, active task +- `/skills` — Skills catalog grouped by work/coding mode +- `/skill ` — Load a specific skill by name with description and modes +- `/agent` — Current agent info with thinking level reference + +### 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. +- Desktop-origin sessions are lazily admitted for live streaming when the + active pairing requests `notify_scope=all`. This means desktop prompts now + get live process cards, streaming deltas, heartbeat updates, and terminal + notifications — not just the final card. +- Stream callbacks from worker threads are properly forwarded to the + adapter-owning event loop using `call_soon_threadsafe`, ensuring terminal + notifications are never silently dropped. +- 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. + +## Completion and error notifications + +Final Telegram notifications identify the outcome and the work itself: success +cards start with `Done: `, and failure cards start with +`Failed: ` followed by a user-readable error. The card may include +the final response, tool count, elapsed time, and safe follow-up buttons, so a +user receiving only the notification can understand what finished or failed +without reopening the laptop. + +## Telegram streaming responses + +When a remote-origin turn is configured for live delivery, Telegram receives +incremental redacted assistant output in the same correlated status card. The +projection coalesces deltas and uses the existing Telegram edit budget; output +is previewed up to the provider message limit and finalized into the existing +completion or error card. A provider that does not advertise the private draft +capability uses `editMessageText`; draft-specific delivery is not required for +correctness. Partial delivery state is process-local and is abandoned safely on +restart, cancellation, or connection shutdown. Pairing, addressability, +redaction, and HTML escaping are applied to partial and final payloads alike. + +## 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 31fff29c0..d57470c0b 100644 --- a/documents/features/security-and-permissions.md +++ b/documents/features/security-and-permissions.md @@ -73,6 +73,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 000000000..6c7e38cd0 --- /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 000000000..1e002da3e --- /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-imessage.md b/documents/plans/remote-channel-imessage.md new file mode 100644 index 000000000..e8c849526 --- /dev/null +++ b/documents/plans/remote-channel-imessage.md @@ -0,0 +1,469 @@ +# Remote channel: iMessage + +Status: proposed + +**Extends `documents/plans/remote-channel-telegram.md` and the plans built +on it.** Those documents remain normative for the Telegram adapter except +where revised below. This document adds a second transport and, as a +direct consequence of adding one, revises three accepted contracts: + +- **AC-1** (off and free by default) is extended verbatim to iMessage: with + no enabled, credentialed iMessage connection, nothing imports + `app.remote.imessage.*`, creates a task, or opens a socket. +- **AC-3** (exactly one configured connection) is revised to *one connection + per adapter kind, at most one enabled at a time*. The schema was already + connection-aware, so this is a service-level change with no rekeying. +- **AC-57** (shared edit budget for live-mode edits) is revised twice over. + Answer *text* no longer flows through it on either transport: it moves to + a streaming-strategy seam, with Telegram private chats using + `sendMessageDraft` and iMessage using block streaming. The budget + survives for *status-card* edits, gaining a count-in-window dimension + that iMessage requires and Telegram opts out of. + +## Problem and outcome + +The remote channel works, but reaching it costs the operator a Telegram +account and a bot token. For an operator whose phone is an iPhone, the app +they already live in is Messages. The outcome is a second transport that +behaves like the first — pair a phone, send a prompt, watch the answer +arrive, approve or reject a permission — delivered natively in Messages, +with no third-party bot and no account to create. + +Three things make this harder than "write a second adapter", and the +design exists to absorb them. + +**iMessage has no bot API.** Apple exposes nothing. Every working +integration puts a Mac in the loop: a machine signed into Messages, +reading `~/Library/Messages/chat.db` and sending through Messages.app. +Self-hosted bridges over that Mac (BlueBubbles Server; `imessage-rs`, +which is API-compatible with it) expose a REST surface. Cloud resellers +that rent the Mac cost $39-100/month and send from a business number, +which contradicts this product's premise. The decision is therefore: speak +the BlueBubbles REST protocol, and let the operator supply the Mac. An +operator with no Mac keeps using Telegram, which already works. + +**iMessage has no buttons.** The entire interaction surface built in +`remote-telegram-control-surface.md` — permission cards, `/settings`, +project pickers — resolves through inline buttons carrying opaque +capability tokens. iMessage has no equivalent and never will. What it has +instead is tapbacks, threaded replies, and text. The design maps +capability tokens onto those without changing `app/remote/actions.py` or +`app/remote/gates.py`, which never learn that a second transport exists. + +**iMessage cannot be edited freely.** Apple permits at most five edits +within fifteen minutes of sending, each one visibly marking the message +as edited and exposing its full revision history. The live-mode design in +`remote-telegram-live-mode-implementation.md` edits a status card roughly +every two seconds; over a fifteen-minute turn that is ~450 edits against +a budget of five. Live mode's *cadence* therefore does not port. What +ports instead is *progressive delivery*: completed blocks of the answer, +sent as ordinary messages as they are produced, with the status card +falling back to a handful of edits spent only on genuine transitions. + +That last constraint turns out to be worth acting on for Telegram too. +Telegram Bot API 9.3 (2025-12-31) added `sendMessageDraft` — "allowing +partial messages to be streamed to a user while being generated" — and +9.5 (2026-03-01) opened it to all bots. Today's edit-based live mode is +exactly the workaround that method replaces; `EditBudget.note_rate_limited` +exists only to absorb the 429s it causes. Introducing a streaming seam for +iMessage and not using it to retire that workaround would mean opening +`app/remote/outbound.py` twice. + +## Goals + +- A second remote transport speaking the BlueBubbles REST protocol, so one + adapter serves BlueBubbles Server and `imessage-rs` alike. +- The core remote loop on iMessage: pair, prompt, answer, approve or + reject a permission, stop. +- True progressive delivery of answer text on both transports — + `sendMessageDraft` on Telegram, block streaming on iMessage. +- Native presentation: subject lines, `attributedBody` formatting, + tapbacks, threaded replies, rendered images for diffs. +- Graceful degradation when the operator's Mac has SIP enabled and the + BlueBubbles Private API is therefore unavailable. +- No new inbound network surface. The sidecar keeps binding `127.0.0.1`. + +## Non-goals + +- Running iMessage without a Mac. Reimplementing Apple's registration + protocol (rustpush, OpenBubbles) risks the operator's Apple ID being + banned from iMessage; Apple did exactly this to Beeper users in January + 2024, and OpenBubbles' own documentation recommends a throwaway Apple + ID. Out of scope, permanently. +- Cloud iMessage APIs. A business sender and a monthly fee contradict the + product premise. +- Porting `/settings`, `/health`, `/changes`, `/history`, `/clear` to + iMessage. They stay Telegram-only in v1 and get their own plan once the + presentation seam has been proven by the core loop. +- Telegram Rich Messages (Bot API 10.1-10.3). Native tables, collapsible + block quotes, real headings, and `RichBlockButtons` would replace the + emoji-and-`` structure that cards fake today, and the `Card` value + introduced here is what makes that a renderer swap rather than a second + rewrite. Deferred to `documents/plans/remote-telegram-rich-messages.md`, + which depends on AC-67 landing first. Adopting it here would retire + AC-68's byte-identical guarantee — the only regression proof covering a + refactor that already reaches into `app/remote/outbound.py`. +- Native Messages polls. They exist (iOS/macOS 26+, up to 12 options) and + are the closest thing iMessage has to buttons, but they are not in the + BlueBubbles protocol, and requiring every device in the conversation to + run OS 26 is too narrow a v1 gate. +- Group chats. Pairing is one private conversation, as on Telegram. +- Inbound attachments. Images sent to the bot are ignored in v1. +- Two adapters polling at once. At most one connection is enabled. + +## Operator setup + +Documented, not automated. The operator: + +1. Creates a second Apple ID for the bot (email-only; no phone number + required) and signs it into Messages on a separate macOS user account. +2. Runs BlueBubbles Server (or `imessage-rs`) on that Mac and notes its + URL and password. +3. Optionally installs the BlueBubbles Private API helper, which requires + disabling SIP. Without it the channel still works; editing, typing + indicators, tapbacks, and rich formatting are unavailable. +4. In EvoFlux, adds an iMessage connection with that URL and password, and + pairs by sending the 8-digit code to the bot's address from their + iPhone. + +The Mac must stay awake and reachable on the same network. This is stated +plainly in the setup UI, because it is the single most common cause of a +channel that "stopped working". + +## User flows and states + +**Connect.** The operator picks iMessage in Settings, enters the server +URL and password. EvoFlux calls `GET /api/v1/server/info` once. Success +persists a connection row and vaults the password; failure returns a +classified error that names which of the three failure modes occurred +(unreachable, unauthorized, reachable-but-iMessage-broken) without +echoing any provider response. + +**Pair.** Identical to Telegram: the desktop shows an 8-digit code, the +operator texts it to the bot's address, the adapter matches it against +`RemotePairing.pair_code_hash` before its expiry. A message from any +unpaired sender receives **no reply of any kind** — not an error, not a +prompt to pair. The sender learns nothing. + +**Prompt and answer.** The operator texts a prompt. The adapter admits it, +the turn runs, and the answer arrives as a sequence of blocks — each a +real message, each a completed paragraph or code fence — as the model +produces them. A typing indicator fills the gaps between blocks when the +Private API is available. The turn closes with a compact card carrying +model, token counts, cost, and elapsed time, and no repetition of text +already delivered. + +**Approve.** A permission request renders as a card naming the command and +its severity, with its three resolutions presented three ways at once: as +tapback targets, as a numbered list, and as text commands. Any of the +three resolves it. The card is then edited in place if editing is +available, or followed by a resolution message if it is not. + +**Degraded.** If the Private API is unavailable, the channel runs on plain +sends alone. Blocks still stream. Cards still render, in plain text with +no attributed runs. Approvals fall back to numbered replies and commands. +The status surface says which features are unavailable and why. + +## Requirements and acceptance criteria + +### Transport and lifecycle + +- **AC-59.** The iMessage adapter polls outward. It opens no listening + socket and requires no change to the sidecar's `127.0.0.1` bind + (`desktop/src-tauri/src/sidecar.rs:128`). Inbound updates are fetched by + polling `GET /api/v1/message/query`, mirroring the owner-task plus + interruptible-event shape of `app/remote/telegram/adapter.py` so that + `stop()` never waits out a poll interval or a backoff sleep. +- **AC-60.** No new runtime dependency. The client is built on `httpx`, + already a dependency. Socket.IO push is explicitly deferred; the polling + client is the seam it would later occupy. +- **AC-61.** On `start()`, the adapter establishes an inbound watermark of + `max(now - 300s, last_processed_at)`, persisted on the connection row. + Any message whose timestamp precedes the watermark is logged and + discarded, never admitted as a prompt. This is distinct from AC-16's + `source_key` idempotency, which prevents duplicate processing but not the + processing of stale messages that Apple flushes after a push recovery or + that accumulated while EvoFlux was not running. +- **AC-62.** On `start()` and on every reconnection, the adapter probes + `GET /api/v1/server/info` and derives a capability set: private API + present, and from it whether edit, typing, tapback, and attributed-body + sends are available. Every feature gated on those degrades rather than + raising when the capability is absent. +- **AC-63.** `RemoteConnectionState` gains `ENDPOINT_UNREACHABLE` (no TCP + route to the bridge), `ENDPOINT_UNAUTHORIZED` (HTTP 401), and + `IMESSAGE_UNAVAILABLE` (bridge reachable, Messages.app unhealthy). The + three have different operator remedies and are never collapsed into + `ERROR`. `ENDPOINT_UNAUTHORIZED` is terminal for the current `start()` + call and is not retried. +- **AC-64.** AC-1 holds for iMessage verbatim. With no enabled, + credentialed iMessage connection, `app.remote.imessage.*` is never + imported. Enforced by extending the existing + `test_disabled_start_does_not_import_telegram` guard. + +### Connections + +- **AC-65.** At most one connection exists per `RemoteAdapterKind`, and at + most one connection across all kinds is `enabled`. `RemoteRuntime` + continues to own at most one live adapter. Configuring a second kind is + permitted; enabling it disables the first. +- **AC-66.** A connection credential is a `RemoteCredential(token, + endpoint)`. Telegram supplies a token and a null endpoint; iMessage + supplies the server password as the token and the server URL as the + endpoint. `RemoteAdapterFactory.validate_token` takes this value rather + than a bare string. + +### Presentation + +- **AC-67.** Card construction is separated from card rendering. A + transport-neutral `Card` value (heading, sections, actions) is produced + by builders that know nothing about any transport; per-transport + renderers turn it into wire form. +- **AC-68.** The Telegram renderer's output is byte-identical to today's + for every card. This is verified by leaving + `tests/remote/test_formatting.py` unmodified — if the refactor changes + Telegram output, that suite fails. +- **AC-69.** The iMessage renderer emits a subject line for the card + heading and an `attributedBody` run list for emphasis. Recipients below + macOS 15 receive clean plain text with no residual markup characters. + Markdown is never sent literally; iMessage does not render it. +- **AC-70.** Card actions are offered three ways simultaneously: as tapback + targets when the Private API is available; as a numbered list in the card + body; and as text commands. Any of the three resolves the card. The + tapback mapping is positional — thumbs-up, heart, thumbs-down for the + first three actions in card order. A card with more than three actions + offers tapbacks for the first three only; the numbered list, which is + always present, remains the complete surface. A card whose actions are + not orderable by that convention (a project picker, for instance) omits + tapbacks entirely rather than mapping them arbitrarily. +- **AC-71.** `app/remote/actions.py` and `app/remote/gates.py` are not + modified. The iMessage adapter holds a bounded, ephemeral, + per-destination map from a sent card's action ordinal to its capability + token, and synthesizes a `RemoteInboundAction` of kind `CALLBACK` + carrying that token when a tapback or numbered reply arrives. The map is + bounded in the manner of `MAX_PENDING_CALLBACK_IDS`. +- **AC-72.** Diff and tool-log drill-downs render to PNG and are sent as + attachments via `POST /api/v1/message/attachment`. Ordinary answer text + is never rendered as an image; it must remain selectable and copyable. + Renders exceeding the 16 MB attachment ceiling are truncated with an + explicit truncation notice. +- **AC-73.** An answer is delivered as a threaded reply to the prompt + message that produced it, when the capability is available. + +### Streaming + +- **AC-74.** Answer delivery goes through a `StreamingStrategy` seam with + `begin`/`push`/`finish`/`abort`. `RemoteProjection.observe` stops + discarding the payload of `message` events + (`app/remote/outbound.py:489`) and forwards `text` to the active + strategy. +- **AC-75.** `DraftStreaming` is used for Telegram private chats against a + server supporting Bot API 9.5 or later. It streams via + `sendMessageDraft` with `can_stop=True`, and handles the resulting + `stopped_message_generation` update as a turn cancellation. Because a + draft is ephemeral — it disappears shortly after, or as soon as the bot + sends a real message — `finish()` sends the complete answer text as a + real message. +- **AC-76.** `EditStreaming` preserves today's behaviour exactly, including + `EditBudget` and its 429 backoff, and is selected when `sendMessageDraft` + is unavailable. No Telegram operator loses functionality. +- **AC-77.** `BlockStreaming` is used for iMessage. It accumulates deltas + and flushes a block when the buffer has reached `min_chars` *and* either + the stream has been idle for `idle_ms` or the buffer has reached + `max_chars`. Break points are preferred in the order paragraph, newline, + sentence, whitespace, hard break. A block is never split inside a fenced + code block; a forced split closes the fence and the next block reopens + it. Defaults for iMessage: `min_chars=280`, `max_chars=1200`, + `idle_ms=900`. +- **AC-78.** Because blocks are durable messages, `BlockStreaming.finish()` + emits only the completion card — model, tokens, cost, elapsed, changed + files — and never repeats text already delivered. The strategy tracks how + much text it has streamed in order to guarantee this. +- **AC-79.** The block chunker performs no I/O and no sleeping. Cadence is + decided from a caller-supplied clock, as in `app/remote/edit_budget.py`, + so its tests are deterministic. +- **AC-80.** `_split_text`'s 4096-character bound + (`app/remote/outbound.py:1069`) is unchanged and applies to both + transports. It sits below any iMessage limit and below the 4000 default + that comparable implementations use. + +### Live mode + +- **AC-81.** Live mode on iMessage surfaces tool activity without editing + the status card on a time-based cadence. The typing indicator runs for + the duration of the turn and is the primary liveness signal; the status + card is edited only on a meaningful transition (a long-running tool + starting, the active agent changing, a gate opening), and only while a + count-bounded budget allows. `EditBudget` gains a count-in-window + dimension — `max_edits` within `window_seconds` — for this purpose; + Telegram passes `None` for both and its behaviour is unchanged. iMessage + uses four edits within fifteen minutes, reserving the fifth for the + completion card. On exhaustion the adapter sends a fresh status card + rather than failing or silently dropping the update, which starts a new + fifteen-minute window. `LiveActivityWindow` is reused unchanged; only the + delivery cadence differs. +- **AC-82.** The completion card is never gated by any budget, on either + transport, preserving the existing contract that liveliness is + best-effort and completion is not. + +### Security + +- **AC-83.** A message from a principal that is not the paired principal + produces no outbound message of any kind. Unlike Telegram, where a bot + only receives messages from users who started it, any party who learns + the bot's iMessage address can send to it; the principal check is the + only boundary and is therefore silent. Rejections are logged server-side + with the principal redacted. +- **AC-84.** The BlueBubbles server password is stored in the OS credential + vault under the existing `connection:` account key, never returned + by any API route, and never logged. The server URL is non-secret and is + stored on the connection row. +- **AC-85.** Inbound attachments are discarded before reaching the agent. +- **AC-86.** All outbound text passes through + `protect_outbound_text(..., context=OutboundContext(channel="remote"))` + unchanged, on both transports and on every streaming strategy. + +## API, event, tool, and UI contracts + +**Outbound (BlueBubbles REST).** `POST /api/v1/message/text` for sends; +`POST /api/v1/message/edit` and `/unsend` under the Private API; +`POST /api/v1/message/react` for tapbacks; `POST /api/v1/message/attachment` +(multipart) for rendered images; `POST /api/v1/chat/{guid}/typing` for the +typing indicator. Authentication is the `password` query parameter. No +BlueBubbles payload type escapes `app/remote/imessage/`. + +**Inbound.** `GET /api/v1/message/query` with an `after` bound, polled on a +two-second interval. Each result is classified into an existing +`RemoteInboundActionKind`; tapbacks and numbered replies are classified as +`CALLBACK` with a synthesized token (AC-71). + +**Telegram.** `TelegramClient` gains `send_message_draft`, built on the +existing `_call` helper. `sendMessageDraft` accepts `chat_id`, `text`, +`parse_mode`, `entities`, `link_preview_options`, `reply_parameters`, +`message_thread_id`, `reply_markup`, `can_stop`, `keep_on_stop`, and is +valid only for private chats — which is the only shape remote pairing +produces. + +**Contracts.** `RemoteAdapterKind` gains `IMESSAGE`. `RemoteOutboundMessage` +gains `attachment` and `reply_to`. `RemoteConnectionState` gains the three +states of AC-63. `RemoteAdapter` gains `react(message_id, kind)`, a no-op +when the capability is absent. + +**Desktop UI.** `web/src/routes/settings.remote-access.tsx` gains an adapter +selector. The iMessage branch collects a URL and a password instead of a +bot token, and surfaces the capability set after connecting so the operator +can see whether the Private API was detected. + +## Data model, migration, and retention + +Migration `00000071` adds two nullable columns to `remote_connections`: +`endpoint_url` (the bridge's base URL; null for Telegram) and +`inbound_watermark_at` (AC-61). Both are nullable with no backfill, so +existing Telegram rows are untouched. `RemotePairing` is unchanged — +`principal_id` holds the operator's iMessage handle and `destination_id` +the chat GUID, which is exactly the separation the existing docstring +anticipated. The AC-71 ordinal map and all streaming state are in-memory +and discarded with the turn. + +## Permissions, security, privacy, and trust + +The trust boundary moves. On Telegram, delivery is gated twice: Telegram +only routes to a bot from users who started it, and then the principal +check runs. On iMessage there is one gate. AC-83 makes it silent so that an +unpaired sender cannot confirm the address is live. + +Disabling SIP, which the Private API requires, weakens the operator's Mac. +The setup documentation states this plainly and the channel is fully +functional without it; nothing in the product pressures the operator into +it. + +The bot's Apple ID is a second account under the operator's control. No +credential for it is held by EvoFlux; the only secret EvoFlux stores is the +bridge password. + +## Concurrency, failure, recovery, and idempotency + +Inbound polling and outbound delivery remain independent failure domains, +as on Telegram: an unreachable destination affects delivery status, never +the poll loop. Transport failures use exponential backoff with jitter; +`ENDPOINT_UNAUTHORIZED` is terminal (AC-63). + +Recovery is where the transports genuinely differ. Telegram's `getUpdates` +offset is a server-held cursor, so a restart resumes exactly. A +`chat.db`-backed bridge has no cursor; it has history. Without AC-61's +watermark, restarting EvoFlux after a weekend would replay Friday's +messages as fresh prompts. `source_key` would correctly suppress the +duplicates of anything already seen, and would do nothing about the rest. + +A capability lost mid-session — the operator quitting the Private API +helper — is observed on the next probe, and every dependent feature +degrades. No turn fails because of it. + +## Observability and diagnostics + +`RemoteAdapterStatus` carries the capability set alongside the existing +fields, so the desktop can state which features are unavailable rather than +leaving the operator to infer it from absence. The chain is long — iPhone, +Apple, Mac, bridge, LAN, EvoFlux — so the three states of AC-63 exist to +place a failure on it. As with Telegram, no raw provider response, +credential, or token ever appears in a status or a log. + +## Compatibility, rollout, and rollback + +Every change to Telegram behaviour is additive or guarded. `DraftStreaming` +is selected only when `sendMessageDraft` is available; otherwise +`EditStreaming` reproduces today's behaviour exactly (AC-76). The +formatting split is verified by an unmodified test suite (AC-68). The +migration is two nullable columns. + +Rollback is per-layer: disabling the iMessage connection returns the system +to a Telegram-only configuration with no code path into +`app/remote/imessage/`; forcing `EditStreaming` returns Telegram to today's +delivery. + +Build order is chosen so that value lands before the hardware dependency +does. The `Card` seam and the streaming seam are pure refactors verifiable +by the existing suite; `DraftStreaming` then improves Telegram on its own, +and every step to that point is testable on a machine with no Mac. Only +after that does work depend on a bridge being reachable. + +## Verification matrix + +| Requirement | Verified by | +| --- | --- | +| AC-59, AC-61 | `tests/remote/imessage/test_adapter.py` — poll loop, prompt `stop()`, stale messages discarded | +| AC-60 | Dependency review; no addition to `pyproject.toml` | +| AC-62, AC-63 | `tests/remote/imessage/test_capability.py`, `test_client.py` — probe and error classification | +| AC-64 | Extended `tests/remote/test_runtime.py` import guard | +| AC-65, AC-66 | `tests/remote/test_connection_service.py` | +| AC-67, AC-69, AC-70 | `tests/remote/test_cards.py`, `tests/remote/imessage/test_renderer.py` | +| AC-68 | Unmodified `tests/remote/test_formatting.py` | +| AC-71 | `tests/remote/imessage/test_adapter.py` — tapback and numbered reply resolve a capability token | +| AC-72, AC-73 | `tests/remote/imessage/test_client.py` — multipart, truncation, reply threading | +| AC-74, AC-77, AC-78, AC-79 | `tests/remote/test_block_stream.py`, `tests/remote/test_streaming.py` | +| AC-75, AC-76 | `tests/remote/test_streaming.py`; unmodified `tests/remote/test_outbound.py` | +| AC-80 | `tests/remote/test_outbound.py`, unmodified | +| AC-81, AC-82 | `tests/remote/test_edit_budget.py` — count-in-window exhaustion rolls a new card; Telegram passing `None` is unchanged | +| AC-83, AC-84, AC-85 | `tests/remote/imessage/test_adapter.py` — stranger silence, tapback from non-principal ignored, attachments dropped | +| AC-86 | `tests/remote/test_streaming.py` — redaction on every strategy | + +## Ownership and source map + +| Area | Location | +| --- | --- | +| Streaming seam | `app/remote/streaming.py` (new) | +| Block chunker | `app/remote/block_stream.py` (new) | +| Count-in-window edit budget | `app/remote/edit_budget.py` (modified) | +| BlueBubbles client | `app/remote/imessage/client.py` (new) | +| Adapter lifecycle | `app/remote/imessage/adapter.py` (new) | +| Capability probe | `app/remote/imessage/capability.py` (new) | +| Attributed-body rendering | `app/remote/imessage/attributed.py` (new) | +| Card values and builders | `app/remote/formatting/cards.py` (new) | +| Telegram rendering | `app/remote/formatting/telegram.py` (moved) | +| iMessage rendering | `app/remote/formatting/imessage.py` (new) | +| Image rendering | `app/remote/formatting/images.py` (new) | +| Turn projection | `app/remote/outbound.py` (modified) | +| Telegram draft method | `app/remote/telegram/client.py` (modified) | +| Connection limits | `app/remote/connection_service.py` (modified) | +| Adapter dispatch | `app/remote/runtime.py` (modified) | +| Schema | `app/models/remote.py`, `app/migrations/versions/00000071_*.py` | +| Desktop UI | `web/src/routes/settings.remote-access.tsx` (modified) | diff --git a/documents/plans/remote-channel-telegram.md b/documents/plans/remote-channel-telegram.md new file mode 100644 index 000000000..a257f9104 --- /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-services-commonization.md b/documents/plans/remote-services-commonization.md new file mode 100644 index 000000000..9cd3b865c --- /dev/null +++ b/documents/plans/remote-services-commonization.md @@ -0,0 +1,186 @@ +# Commonization plan: remote services and adapter providers + +Status: proposed + +## Goal + +Create one provider-neutral remote service boundary before adding more iMessage +behavior. Telegram and iMessage should be implementations of the same lifecycle, +credential, pairing, inbound, outbound, streaming, and status contracts. Adding a +future chatbot must register a provider instead of adding another branch to +`RemoteRuntime`. + +## Current evidence and problems + +- `app/remote/contracts.py` already defines `RemoteAdapter`, but runtime still + constructs iMessage through a special `if connection.adapter == "imessage"` + branch in `app/remote/runtime.py`. +- `IMessageRemoteAdapter` and `IMessageChannel` duplicate lifecycle and pairing + concerns rather than implementing a registry-owned adapter contract. +- Provider names and strategy names are free-form strings (`imsg`, + `bluebubbles`, `private_draft`). +- `RemoteConnectionService` still calls the Telegram-shaped `validate_token` + API and uses `token` as the cross-provider credential field. +- iMessage adapter watermark callbacks are placeholders and are not connected + to `RemoteConnection.inbound_watermark_at`. +- `app/remote/edit_budget.py`, `app/remote/formatting.py`, and + `app/remote/outbound.py` already provide shared budget, card, redaction, and + projection behavior, but new iMessage modules partially duplicate those + responsibilities. + +## Target architecture + +```text +RemoteRuntime + └── RemoteAdapterRegistry + ├── TelegramAdapterFactory + └── IMessageAdapterFactory + ├── IMsgRpcProvider + └── BlueBubblesProvider + +RemoteConnectionService + └── RemoteCredentialValidator + +RemoteProjection + └── RemoteAdapter (send/edit/callback/typing/status) +``` + +## Design decisions + +### 1. Typed provider identity + +Add a `RemoteProviderKind(StrEnum)` for provider identity. The connection +stores a nullable provider for legacy Telegram rows and defaults iMessage to +`IMSG`. Unknown provider values are rejected at API/service boundaries. + +### 2. Registry-owned adapter creation + +Add a registry/factory interface: + +```python +class RemoteAdapterRegistry(Protocol): + def factory_for(self, adapter: RemoteAdapterKind) -> RemoteAdapterFactory: ... +``` + +`RemoteRuntime` asks the registry for an adapter. It must not inspect adapter +strings, import provider implementations dynamically, or call optional methods +through `hasattr`. + +### 3. One common lifecycle contract + +Extend the common adapter contract with explicit pairing/configuration before +start, rather than starting an unpaired adapter and restarting it later: + +```python +async def start(self, *, pairing: RemotePrincipal | None) -> None: ... +``` + +If changing the existing protocol is too disruptive, introduce a second +versioned protocol and adapt Telegram atomically. Do not preserve a permanent +runtime branch. + +### 4. Generic credential contract + +Replace the service's provider-neutral `token` concept with: + +```python +RemoteCredentialInput( + adapter=RemoteAdapterKind, + provider=RemoteProviderKind, + secret: str, + endpoint_url: str | None, +) +``` + +Keep `token` as a deprecated Telegram API alias. Each provider validator owns +reachability/authentication checks and returns `ValidatedRemoteIdentity`; vault +write-before-DB-commit remains in the shared service. + +### 5. Durable watermark ownership + +Move watermark load/save into the connection service/repository boundary. The +poller accepts a repository-backed cursor object, never no-op callbacks. Cursor +advance must occur after normalized action admission and must be idempotent by +`connection_id + source_key`. + +### 6. Reuse shared outbound primitives + +- Use `app/remote/edit_budget.py` for live status cadence; add a generic + `new_card` decision there instead of maintaining `IMessageLiveBudget`. +- Use `app/remote/formatting.py` for shared card content and add an adapter + renderer only for iMessage's numbered/tapback projection. +- Use the redaction call already owned by `app/remote/outbound.py`; iMessage + must not create a second independent redaction boundary. +- Make streaming strategy a typed capability returned by the adapter, not a + free-form provider string. + +## Execution phases + +### Phase 1 — contract and registry + +- Add typed provider/capability enums and generic credential input. +- Extend tests for Telegram compatibility and unknown-provider rejection. +- Add registry implementation and inject it into `RemoteRuntime`. +- Remove the direct iMessage branch from runtime. + +### Phase 2 — credential and persistence + +- Add provider validators for `imsg` and BlueBubbles. +- Preserve vault-before-commit and safe cleanup on failure. +- Persist provider and watermark through repository/service methods. +- Add migration upgrade/downgrade and restart/replay tests. + +### Phase 3 — adapter lifecycle + +- Make Telegram and iMessage implement the same pairing-aware lifecycle. +- Route inbound normalized actions through the existing remote authorization + service. +- Route outbound messages, cards, callbacks, typing, and status through the + common `RemoteAdapter` contract. + +### Phase 4 — shared outbound behavior + +- Move block chunking/strategy selection into shared remote streaming code. +- Reuse `EditBudget`, formatting, and redaction. +- Add iMessage renderer for numbered/tapback actions only. +- Add cross-adapter tests proving Telegram output remains unchanged. + +### Phase 5 — product integration and E2E + +- Update API schemas/client/UI for typed adapter/provider choices. +- Add provider validation and safe status/capability display. +- Run E2E with a fake provider in CI. +- Run real-device smoke test using `imsg` on macOS; keep BlueBubbles fallback + as a separate provider scenario. + +## Acceptance criteria + +- Adding a new provider requires registry/validator/provider implementation, + not a new `if adapter == ...` in runtime. +- Telegram focused tests remain green with no response-shape regression. +- No provider secret appears in rows, logs, API responses, or exception text. +- Pairing is supplied before provider polling begins. +- Restart resumes from the persisted watermark without replaying admitted + inbound actions. +- All outbound strategies share the existing redaction and budget boundaries. +- A fake-provider E2E test covers configure → start → inbound action → agent + handler → outbound response. +- `imsg` real-device smoke test is documented with macOS host prerequisites. + +## Verification commands + +```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 +``` + +## Non-goals + +- No general third-party plugin SDK in this phase. +- No media transfer before text/card/callback semantics are stable. +- No replacement of existing Telegram API behavior. +- No silent provider fallback after a configured provider fails; fallback is an + explicit provider selection or an explicitly documented policy. diff --git a/documents/plans/remote-telegram-approvals-implementation.md b/documents/plans/remote-telegram-approvals-implementation.md new file mode 100644 index 000000000..92a88383f --- /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 000000000..d0a38a67a --- /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 000000000..4be235fdd --- /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 000000000..4b6f589f3 --- /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 000000000..658efcb00 --- /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 000000000..9544df728 --- /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 000000000..e088a7b00 --- /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 000000000..b46dc931d --- /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 000000000..26bbaa8a1 --- /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_live_status_card_animates_spinner_by_elapsed_time(): + early, _ = formatting.render_live_status_card( + title="Streaming", elapsed_seconds=0.0, activity_lines=[] + ) + later, _ = formatting.render_live_status_card( + title="Streaming", elapsed_seconds=1.0, activity_lines=[] + ) + assert early != later + assert "Streaming" in early + assert "Streaming" in later + + +def test_render_live_status_card_includes_response_preview(): + text, _ = formatting.render_live_status_card( + title="Streaming", + elapsed_seconds=1.0, + activity_lines=[], + response_text="Partial response", + ) + assert "Partial response" in text + + +def test_render_live_status_card_includes_stop_action(): + text, buttons = formatting.render_live_status_card( + title="Running task", + elapsed_seconds=1.0, + activity_lines=[], + stop_token="stop-token", + ) + assert "Running task" in text + assert [button.text for button in buttons] == ["Stop task"] + assert buttons[0].token == "stop-token" + + +def test_render_live_status_card_truncates_response_preview(): + text, _ = formatting.render_live_status_card( + title="Streaming", + elapsed_seconds=1.0, + activity_lines=[], + response_text="x" * 4000, + ) + assert len(text) < 3200 + assert "..." 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=[("