diff --git a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md index ffc0884..10e2cb7 100644 --- a/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md +++ b/docs/superpowers/plans/2026-06-02-channel-connectors-foundation.md @@ -18,7 +18,7 @@ Included: - `ChannelConnection` data model and persistent JSON store. - Restricted local credential store with secret redaction. -- One-time pairing token store, used now by tests and future terminal/QR connectors. +- One-time pairing token store, used now by tests and future terminal/QR connectors. It is implemented in this phase but not exposed through APIs; future terminal and QR connectors will consume it. - Connector protocol and registry. - Telegram connector with fake-client test hooks. - Connection control APIs. @@ -33,6 +33,7 @@ Excluded from this plan: - Frontend connection wizard. - Hot starting/stopping adapters without backend restart. - Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store. +- Credential garbage collection. Updating secrets writes a new credential reference and leaves the old reference in the local credential file until a later cleanup pass. ## File Structure @@ -1361,7 +1362,7 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N "displayName": "Telegram Main", "authType": "token", "secrets": {"botToken": "token-1"}, - "config": {"maxMessageChars": 4096}, + "config": {"maxMessageChars": 4096, "requireMentionInGroups": True}, }, ) assert created.status_code == 200 @@ -1370,6 +1371,10 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N assert body["connection"]["kind"] == "telegram" assert body["connection"]["status"] == "draft" assert "credentials_ref" not in body["connection"] + assert body["connection"]["runtime_config"] == { + "max_message_chars": 4096, + "require_mention_in_groups": True, + } assert body["credentials"] == {"botToken": "***"} patched = client.patch( @@ -1382,7 +1387,7 @@ def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> N ) assert patched.status_code == 200 assert patched.json()["connection"]["display_name"] == "Telegram Ops" - assert patched.json()["connection"]["runtime_config"] == {"maxMessageChars": 2048} + assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048} assert patched.json()["credentials"] == {"botToken": "***"} listed = client.get("/api/channel-connections") @@ -1502,13 +1507,31 @@ def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry if not isinstance(registry, ChannelConnectorRegistry): workspace = getattr(request.app.state, "channel_connection_workspace", None) if workspace is None: - agent_service = get_agent_service(request) - workspace = agent_service.loader.workspace + raise RuntimeError("Channel connector registry unavailable before service boot") registry = _build_channel_connector_registry(workspace) request.app.state.channel_connector_registry = registry return registry +def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(config, dict): + return {} + return { + _camel_to_snake_text(str(key)): value + for key, value in config.items() + if str(key).strip() + } + + +def _camel_to_snake_text(value: str) -> str: + result: list[str] = [] + for char in value: + if char.isupper() and result: + result.append("_") + result.append(char.lower()) + return "".join(result) + + def _connection_response_view(connection: Any) -> dict[str, Any]: view = connection.to_dict() view.pop("credentials_ref", None) @@ -1553,7 +1576,7 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ owner_user_id=_clean_text(payload.owner_user_id) or None, auth_type=_clean_text(payload.auth_type) or "token", credentials_ref=credentials_ref, - runtime_config=payload.config or {}, + runtime_config=_normalize_connection_config(payload.config), ) return WebChannelConnectionResponse( connection=_connection_response_view(connection), @@ -1576,10 +1599,11 @@ Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/ if payload.account_id is not None: connection.account_id = _clean_text(payload.account_id) or connection.account_id if payload.config is not None: - connection.runtime_config = payload.config + connection.runtime_config = _normalize_connection_config(payload.config) if payload.secrets: secrets = {key: value for key, value in payload.secrets.items() if value} if secrets: + # TODO: add credential GC when connection updates credentials. connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) connection = registry.connection_store.update(connection) return WebChannelConnectionResponse(