# External Connector Sidecar 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 a repo-local `external-connector` sidecar service with a provider abstraction, deterministic fake provider tests, service-level auth, outbound send idempotency, and a production provider that shells out to real vendor CLI commands supplied by environment variables.

**Architecture:** The sidecar exposes a stable HTTP contract to Beaver and delegates platform-specific behavior to `ConnectorProvider`. The fake provider makes tests deterministic; the production provider runs configured vendor commands and persists connector/session/send state under `CONNECTOR_HOME`.

**Tech Stack:** Python 3.12, FastAPI, Pydantic v2, pytest, httpx, local JSON stores, Docker.

---

## Scope

Included:

- `external-connector/` Python service.
- `ConnectorProvider` protocol.
- Fake provider for tests and local dry runs.
- Production `VendorCliProvider` with environment-driven command templates.
- Service-level bearer authentication for Beaver-to-sidecar requests.
- Connector session state persistence.
- `/send` idempotency by `connectionId + requestId`, including processing TTL retry semantics.
- Dockerfile and local compose declaration.

Excluded:

- Beaver backend bridge implementation.
- Frontend UI.
- Hardcoded vendor command strings in repo files.
- Accepting command strings from frontend or sidecar HTTP request bodies.
- Docker socket access.
- Dynamic container creation.

## Vendor Command Safety Contract

`VendorCliProvider` may execute vendor install/send commands because the sidecar is a controlled deployment container, but command execution has fixed boundaries:

- Command templates only come from sidecar startup environment variables.
- No frontend or HTTP API payload can supply or override command strings.
- `cwd` is fixed to `CONNECTOR_HOME`; per-connection state paths are passed as formatted arguments only.
- Every command uses a hard timeout from `CONNECTOR_COMMAND_TIMEOUT_SECONDS`, defaulting to 120 seconds.
- stdout and stderr are redacted before being stored or returned.

## File Structure

- Create `external-connector/pyproject.toml`
  - Sidecar dependencies and test runner.
- Create `external-connector/Dockerfile`
  - Python runtime plus Node/npm so configured vendor CLI commands can run.
- Create `external-connector/external_connector/__init__.py`
- Create `external-connector/external_connector/models.py`
  - Pydantic request/response models.
- Create `external-connector/external_connector/state.py`
  - JSON-backed session and send idempotency state.
- Create `external-connector/external_connector/providers/base.py`
  - `ConnectorProvider` protocol.
- Create `external-connector/external_connector/providers/fake.py`
  - Deterministic provider for tests.
- Create `external-connector/external_connector/providers/vendor_cli.py`
  - Command-template provider.
- Create `external-connector/external_connector/app.py`
  - FastAPI app factory and routes.
- Create `external-connector/external_connector/main.py`
  - Uvicorn entrypoint.
- Create `external-connector/tests/test_sidecar_api.py`
- Create `external-connector/tests/test_state.py`
- Create `external-connector/tests/test_vendor_cli_provider.py`
- Create `docker-compose.external-connectors.yml`
- Modify `.env.example`
  - Document sidecar env variables without embedding real secrets.

---

### Task 1: Sidecar State Store

**Files:**
- Create: `external-connector/external_connector/state.py`
- Create: `external-connector/external_connector/__init__.py`
- Create: `external-connector/tests/test_state.py`
- Create: `external-connector/pyproject.toml`

- [ ] **Step 1: Create sidecar package metadata**

Create `external-connector/pyproject.toml`:

```toml
[project]
name = "external-connector"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "fastapi>=0.115.0,<1.0",
  "httpx>=0.27.0,<1.0",
  "pydantic>=2.7.0,<3.0",
  "uvicorn[standard]>=0.30.0,<1.0",
]

[dependency-groups]
dev = [
  "pytest>=8.0.0,<9.0",
]

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
```

Create `external-connector/external_connector/__init__.py`:

```python
"""Generic external connector sidecar."""
```

- [ ] **Step 2: Write failing state tests**

Create `external-connector/tests/test_state.py`:

```python
from __future__ import annotations

from external_connector.state import SidecarStateStore


def test_state_store_saves_and_loads_connector_sessions(tmp_path) -> None:
    store = SidecarStateStore(tmp_path / "state.json")

    session = store.create_session(
        kind="weixin",
        connection_id="conn_1",
        channel_id="weixin-main",
        display_name="Weixin Main",
        options={},
    )
    store.update_session(session.session_id, status="connected", account_id="weixin:me", display_name="Me")
    loaded = store.get_session(session.session_id)

    assert session.session_id.startswith("cs_")
    assert loaded.status == "connected"
    assert loaded.account_id == "weixin:me"


def test_state_store_dedupes_send_results(tmp_path) -> None:
    store = SidecarStateStore(tmp_path / "state.json")

    first = store.begin_send(connection_id="conn_1", request_id="out_1")
    store.complete_send(first.dedupe_key, provider_message_id="provider-1")
    duplicate = store.begin_send(connection_id="conn_1", request_id="out_1")

    assert first.should_send is True
    assert duplicate.should_send is False
    assert duplicate.status == "completed"
    assert duplicate.http_status == 200
    assert duplicate.provider_message_id == "provider-1"


def test_state_store_returns_conflict_for_active_send_processing(tmp_path) -> None:
    store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)

    store.begin_send(connection_id="conn_1", request_id="out_1")
    duplicate = store.begin_send(connection_id="conn_1", request_id="out_1")

    assert duplicate.should_send is False
    assert duplicate.status == "processing"
    assert duplicate.http_status == 409
    assert duplicate.retry_after_seconds == 5


def test_state_store_retries_stale_send_processing(tmp_path) -> None:
    store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=0)

    store.begin_send(connection_id="conn_1", request_id="out_1")
    retry = store.begin_send(connection_id="conn_1", request_id="out_1")

    assert retry.should_send is True
    assert retry.status == "processing"
```

- [ ] **Step 3: Run tests to verify failure**

Run:

```bash
cd external-connector
uv run pytest tests/test_state.py -q
```

Expected: fail with `ModuleNotFoundError: No module named 'external_connector.state'`.

- [ ] **Step 4: Implement state store**

Create `external-connector/external_connector/state.py`:

```python
from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4


def iso_now() -> str:
    return datetime.now(timezone.utc).isoformat()


@dataclass(slots=True)
class ConnectorSessionState:
    session_id: str
    kind: str
    connection_id: str
    channel_id: str
    display_name: str
    status: str
    options: dict[str, Any] = field(default_factory=dict)
    qr_code: str | None = None
    qr_image: str | None = None
    instructions: list[str] = field(default_factory=list)
    account_id: str | None = None
    error: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)
    created_at: str = field(default_factory=iso_now)
    updated_at: str = field(default_factory=iso_now)

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ConnectorSessionState":
        return cls(
            session_id=str(data.get("session_id") or ""),
            kind=str(data.get("kind") or ""),
            connection_id=str(data.get("connection_id") or ""),
            channel_id=str(data.get("channel_id") or ""),
            display_name=str(data.get("display_name") or ""),
            status=str(data.get("status") or "pending"),
            options=dict(data.get("options") or {}),
            qr_code=str(data["qr_code"]) if data.get("qr_code") is not None else None,
            qr_image=str(data["qr_image"]) if data.get("qr_image") is not None else None,
            instructions=[str(item) for item in data.get("instructions") or []],
            account_id=str(data["account_id"]) if data.get("account_id") is not None else None,
            error=str(data["error"]) if data.get("error") is not None else None,
            metadata=dict(data.get("metadata") or {}),
            created_at=str(data.get("created_at") or iso_now()),
            updated_at=str(data.get("updated_at") or iso_now()),
        )


@dataclass(slots=True)
class SendBeginResult:
    should_send: bool
    dedupe_key: str
    status: str
    http_status: int
    retry_after_seconds: int | None = None
    provider_message_id: str | None = None


class SidecarStateStore:
    def __init__(self, path: Path, *, send_processing_ttl_seconds: int = 60) -> None:
        self.path = Path(path)
        self.send_processing_ttl_seconds = int(send_processing_ttl_seconds)
        self._lock = Lock()

    def create_session(
        self,
        *,
        kind: str,
        connection_id: str,
        channel_id: str,
        display_name: str,
        options: dict[str, Any],
    ) -> ConnectorSessionState:
        session = ConnectorSessionState(
            session_id=f"cs_{uuid4().hex}",
            kind=kind,
            connection_id=connection_id,
            channel_id=channel_id,
            display_name=display_name,
            status="pending",
            options=dict(options),
        )
        with self._lock:
            data = self._load()
            data["sessions"][session.session_id] = session.to_dict()
            self._save(data)
        return session

    def get_session(self, session_id: str) -> ConnectorSessionState:
        data = self._load()
        raw = data["sessions"].get(session_id)
        if not isinstance(raw, dict):
            raise KeyError(session_id)
        return ConnectorSessionState.from_dict(raw)

    def update_session(self, session_id: str, **updates: Any) -> ConnectorSessionState:
        with self._lock:
            data = self._load()
            raw = data["sessions"].get(session_id)
            if not isinstance(raw, dict):
                raise KeyError(session_id)
            session = ConnectorSessionState.from_dict(raw)
            for key, value in updates.items():
                if hasattr(session, key):
                    setattr(session, key, value)
            session.updated_at = iso_now()
            data["sessions"][session_id] = session.to_dict()
            self._save(data)
            return session

    def begin_send(self, *, connection_id: str, request_id: str) -> SendBeginResult:
        dedupe_key = f"{connection_id}:{request_id}"
        with self._lock:
            data = self._load()
            existing = data["sends"].get(dedupe_key)
            if isinstance(existing, dict):
                status = str(existing.get("status") or "processing")
                if status == "completed":
                    return SendBeginResult(False, dedupe_key, "completed", 200, None, str(existing.get("provider_message_id") or ""))
                if status == "processing" and not self._send_is_stale(existing):
                    return SendBeginResult(False, dedupe_key, "processing", 409, 5)
            data["sends"][dedupe_key] = {
                "connection_id": connection_id,
                "request_id": request_id,
                "status": "processing",
                "updated_at": iso_now(),
            }
            self._save(data)
            return SendBeginResult(True, dedupe_key, "processing", 200)

    def complete_send(self, dedupe_key: str, *, provider_message_id: str | None) -> None:
        with self._lock:
            data = self._load()
            item = dict(data["sends"].get(dedupe_key) or {})
            item.update({"status": "completed", "provider_message_id": provider_message_id, "updated_at": iso_now()})
            data["sends"][dedupe_key] = item
            self._save(data)

    def _send_is_stale(self, item: dict[str, Any]) -> bool:
        updated_at = str(item.get("updated_at") or iso_now())
        updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
        return (datetime.now(timezone.utc) - updated).total_seconds() >= self.send_processing_ttl_seconds

    def _load(self) -> dict[str, Any]:
        if not self.path.exists():
            return {"sessions": {}, "sends": {}}
        try:
            data = json.loads(self.path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return {"sessions": {}, "sends": {}}
        if not isinstance(data, dict):
            return {"sessions": {}, "sends": {}}
        if not isinstance(data.get("sessions"), dict):
            data["sessions"] = {}
        if not isinstance(data.get("sends"), dict):
            data["sends"] = {}
        return data

    def _save(self, data: dict[str, Any]) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path = self.path.with_name(f"{self.path.name}.tmp")
        tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        tmp_path.replace(self.path)
```

- [ ] **Step 5: Run state tests**

Run:

```bash
cd external-connector
uv run pytest tests/test_state.py -q
```

Expected: `2 passed`.

- [ ] **Step 6: Commit Task 1**

```bash
git add external-connector
git commit -m "feat: add external connector sidecar state"
```

---

### Task 2: Provider Contract And Fake Provider

**Files:**
- Create: `external-connector/external_connector/models.py`
- Create: `external-connector/external_connector/providers/base.py`
- Create: `external-connector/external_connector/providers/fake.py`
- Test: `external-connector/tests/test_sidecar_api.py`

- [ ] **Step 1: Write failing fake provider tests**

Create `external-connector/tests/test_sidecar_api.py`:

```python
from __future__ import annotations

from external_connector.providers.fake import FakeProvider
from external_connector.state import SidecarStateStore


def test_fake_provider_lists_weixin_and_feishu(tmp_path) -> None:
    provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))

    connectors = provider.connectors()

    assert [item["kind"] for item in connectors] == ["weixin", "feishu"]
    assert connectors[0]["authType"] == "qr"


def test_fake_provider_session_flow(tmp_path) -> None:
    provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))

    session = provider.start_session(
        {
            "kind": "weixin",
            "connectionId": "conn_1",
            "channelId": "weixin-main",
            "displayName": "Weixin Main",
            "callbackBaseUrl": "http://beaver:8080",
            "options": {},
        }
    )
    loaded = provider.get_session(session["sessionId"])

    assert session["status"] == "qr_ready"
    assert session["qrImage"].startswith("data:image/png;base64,")
    assert loaded["sessionId"] == session["sessionId"]


def test_fake_provider_send_returns_idempotent_result(tmp_path) -> None:
    provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
    payload = {
        "requestId": "out_1",
        "connectionId": "conn_1",
        "channelId": "weixin-main",
        "kind": "weixin",
        "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
        "content": "hello",
        "metadata": {},
    }

    first = provider.send(payload)
    second = provider.send(payload)

    assert first == second
    assert first["ok"] is True
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
cd external-connector
uv run pytest tests/test_sidecar_api.py -q
```

Expected: fail with `ModuleNotFoundError: No module named 'external_connector.providers'`.

- [ ] **Step 3: Add Pydantic models**

Create `external-connector/external_connector/models.py`:

```python
from __future__ import annotations

from typing import Any

from pydantic import BaseModel, Field


class ConnectorSessionRequest(BaseModel):
    kind: str
    connection_id: str = Field(alias="connectionId")
    channel_id: str = Field(alias="channelId")
    display_name: str = Field(alias="displayName")
    callback_base_url: str = Field(alias="callbackBaseUrl")
    options: dict[str, Any] = Field(default_factory=dict)


class SendRequest(BaseModel):
    request_id: str = Field(alias="requestId")
    connection_id: str = Field(alias="connectionId")
    channel_id: str = Field(alias="channelId")
    kind: str
    target: dict[str, Any]
    content: str
    metadata: dict[str, Any] = Field(default_factory=dict)
```

- [ ] **Step 4: Add provider contract**

Create `external-connector/external_connector/providers/base.py`:

```python
from __future__ import annotations

from typing import Any, Protocol


class ConnectorProvider(Protocol):
    provider_id: str

    def connectors(self) -> list[dict[str, Any]]:
        ...

    def health(self) -> dict[str, Any]:
        ...

    def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
        ...

    def get_session(self, session_id: str) -> dict[str, Any]:
        ...

    def cancel_session(self, session_id: str) -> None:
        ...

    def logout(self, connection_id: str) -> None:
        ...

    def send(self, payload: dict[str, Any]) -> dict[str, Any]:
        ...
```

- [ ] **Step 5: Add fake provider**

Create `external-connector/external_connector/providers/fake.py`:

```python
from __future__ import annotations

from typing import Any
from uuid import uuid4

from external_connector.state import ConnectorSessionState, SidecarStateStore


def _session_view(session: ConnectorSessionState) -> dict[str, Any]:
    return {
        "sessionId": session.session_id,
        "kind": session.kind,
        "status": session.status,
        "qrCode": session.qr_code,
        "qrImage": session.qr_image,
        "instructions": list(session.instructions),
        "accountId": session.account_id,
        "displayName": session.display_name if session.account_id else None,
        "error": session.error,
        "metadata": dict(session.metadata),
    }


class FakeProvider:
    provider_id = "fake"

    def __init__(self, store: SidecarStateStore) -> None:
        self.store = store

    def connectors(self) -> list[dict[str, Any]]:
        return [
            {
                "kind": "weixin",
                "displayName": "Weixin",
                "authType": "qr",
                "providerId": self.provider_id,
                "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
            },
            {
                "kind": "feishu",
                "displayName": "Feishu/Lark",
                "authType": "plugin_install",
                "providerId": self.provider_id,
                "capabilities": ["receive_text", "send_text", "receive_media", "groups"],
            },
        ]

    def health(self) -> dict[str, Any]:
        return {"ok": True, "providerId": self.provider_id}

    def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
        session = self.store.create_session(
            kind=str(payload["kind"]),
            connection_id=str(payload["connectionId"]),
            channel_id=str(payload["channelId"]),
            display_name=str(payload["displayName"]),
            options=dict(payload.get("options") or {}),
        )
        session = self.store.update_session(
            session.session_id,
            status="qr_ready" if session.kind == "weixin" else "waiting_for_user",
            qr_image="data:image/png;base64,ZmFrZQ==" if session.kind == "weixin" else None,
            instructions=["Run the provider install flow and finish verification"] if session.kind == "feishu" else [],
        )
        return _session_view(session)

    def get_session(self, session_id: str) -> dict[str, Any]:
        return _session_view(self.store.get_session(session_id))

    def cancel_session(self, session_id: str) -> None:
        self.store.update_session(session_id, status="cancelled")

    def logout(self, connection_id: str) -> None:
        return None

    def send(self, payload: dict[str, Any]) -> dict[str, Any]:
        begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
        if not begin.should_send:
            if begin.http_status == 409:
                return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409}
            return {"ok": True, "providerMessageId": begin.provider_message_id}
        provider_message_id = f"fake_{uuid4().hex}"
        self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
        return {"ok": True, "providerMessageId": provider_message_id}
```

- [ ] **Step 6: Run fake provider tests**

Run:

```bash
cd external-connector
uv run pytest tests/test_sidecar_api.py tests/test_state.py -q
```

Expected: all listed tests pass.

- [ ] **Step 7: Commit Task 2**

```bash
git add external-connector
git commit -m "feat: add external connector provider contract"
```

---

### Task 3: FastAPI Sidecar HTTP API

**Files:**
- Create: `external-connector/external_connector/app.py`
- Create: `external-connector/external_connector/main.py`
- Modify: `external-connector/tests/test_sidecar_api.py`

- [ ] **Step 1: Extend HTTP API tests**

Append to `external-connector/tests/test_sidecar_api.py`:

```python
from fastapi.testclient import TestClient

from external_connector.app import create_app


def test_sidecar_http_api_requires_bearer_token(tmp_path) -> None:
    app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")

    with TestClient(app) as client:
        response = client.get("/connectors")

    assert response.status_code == 401


def test_sidecar_http_api_session_and_send(tmp_path) -> None:
    app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
    headers = {"Authorization": "Bearer sidecar-token"}

    with TestClient(app) as client:
        connectors = client.get("/connectors", headers=headers)
        session = client.post(
            "/connector-sessions",
            headers=headers,
            json={
                "kind": "weixin",
                "connectionId": "conn_1",
                "channelId": "weixin-main",
                "displayName": "Weixin Main",
                "callbackBaseUrl": "http://beaver:8080",
                "options": {},
            },
        )
        session_id = session.json()["sessionId"]
        loaded = client.get(f"/connector-sessions/{session_id}", headers=headers)
        sent = client.post(
            "/send",
            headers=headers,
            json={
                "requestId": "out_1",
                "connectionId": "conn_1",
                "channelId": "weixin-main",
                "kind": "weixin",
                "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
                "content": "hello",
                "metadata": {},
            },
        )

    assert connectors.status_code == 200
    assert session.status_code == 200
    assert loaded.json()["sessionId"] == session_id
    assert sent.json()["ok"] is True


def test_sidecar_http_api_returns_conflict_for_processing_send(tmp_path) -> None:
    store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
    store.begin_send(connection_id="conn_1", request_id="out_1")
    app = create_app(provider=FakeProvider(store), api_token="sidecar-token")
    headers = {"Authorization": "Bearer sidecar-token"}

    with TestClient(app) as client:
        response = client.post(
            "/send",
            headers=headers,
            json={
                "requestId": "out_1",
                "connectionId": "conn_1",
                "channelId": "weixin-main",
                "kind": "weixin",
                "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
                "content": "hello",
                "metadata": {},
            },
        )

    assert response.status_code == 409
    assert response.json()["retryAfterSeconds"] == 5
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
cd external-connector
uv run pytest tests/test_sidecar_api.py -q
```

Expected: fail with `ModuleNotFoundError: No module named 'external_connector.app'`.

- [ ] **Step 3: Implement FastAPI app**

Create `external-connector/external_connector/app.py`:

```python
from __future__ import annotations

from typing import Any

from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse

from external_connector.models import ConnectorSessionRequest, SendRequest
from external_connector.providers.base import ConnectorProvider


def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI:
    app = FastAPI(title="External Connector")

    def require_auth(authorization: str | None) -> None:
        if api_token and authorization != f"Bearer {api_token}":
            raise HTTPException(status_code=401, detail="Invalid connector token")

    @app.get("/health")
    def health() -> dict[str, Any]:
        return provider.health()

    @app.get("/connectors")
    def connectors(authorization: str | None = Header(default=None)) -> list[dict[str, Any]]:
        require_auth(authorization)
        return provider.connectors()

    @app.post("/connector-sessions")
    def start_session(payload: ConnectorSessionRequest, authorization: str | None = Header(default=None)) -> dict[str, Any]:
        require_auth(authorization)
        return provider.start_session(payload.model_dump(by_alias=True))

    @app.get("/connector-sessions/{session_id}")
    def get_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
        require_auth(authorization)
        try:
            return provider.get_session(session_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Connector session not found")

    @app.post("/connector-sessions/{session_id}/cancel")
    def cancel_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
        require_auth(authorization)
        provider.cancel_session(session_id)
        return {"ok": True}

    @app.post("/connections/{connection_id}/logout")
    def logout(connection_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
        require_auth(authorization)
        provider.logout(connection_id)
        return {"ok": True}

    @app.post("/send")
    def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> JSONResponse | dict[str, Any]:
        require_auth(authorization)
        result = dict(provider.send(payload.model_dump(by_alias=True)))
        status_code = int(result.pop("httpStatus", 200))
        if status_code != 200:
            return JSONResponse(status_code=status_code, content=result)
        return result

    return app
```

Create `external-connector/external_connector/main.py`:

```python
from __future__ import annotations

import os
from pathlib import Path

import uvicorn

from external_connector.app import create_app
from external_connector.providers.fake import FakeProvider
from external_connector.providers.vendor_cli import VendorCliProvider
from external_connector.state import SidecarStateStore


def build_app():
    home = Path(os.getenv("CONNECTOR_HOME", "/var/lib/external-connector"))
    store = SidecarStateStore(home / "state.json")
    provider_name = os.getenv("CONNECTOR_PROVIDER", "fake")
    if provider_name == "vendor_cli":
        provider = VendorCliProvider(store=store, env=os.environ)
    else:
        provider = FakeProvider(store)
    return create_app(provider=provider, api_token=os.getenv("CONNECTOR_API_TOKEN", ""))


app = build_app()


def main() -> None:
    uvicorn.run("external_connector.main:app", host="0.0.0.0", port=8787)


if __name__ == "__main__":
    main()
```

- [ ] **Step 4: Run API tests**

Run:

```bash
cd external-connector
uv run pytest tests/test_sidecar_api.py -q
```

Expected: all HTTP API tests pass except import of `VendorCliProvider`, which is added in Task 4. If `main.py` import breaks before Task 4, add a minimal `external-connector/external_connector/providers/vendor_cli.py` containing a `VendorCliProvider` class that raises `RuntimeError("VendorCliProvider is not configured")` from each method.

- [ ] **Step 5: Commit Task 3**

```bash
git add external-connector
git commit -m "feat: add external connector sidecar api"
```

---

### Task 4: Vendor CLI Provider

**Files:**
- Create: `external-connector/external_connector/providers/vendor_cli.py`
- Test: `external-connector/tests/test_vendor_cli_provider.py`

- [ ] **Step 1: Write failing vendor provider tests**

Create `external-connector/tests/test_vendor_cli_provider.py`:

```python
from __future__ import annotations

from external_connector.providers.vendor_cli import VendorCliProvider
from external_connector.state import SidecarStateStore


class FakeRunner:
    def __init__(self) -> None:
        self.commands: list[list[str]] = []
        self.cwd: str | None = None
        self.timeout: float | None = None

    def __call__(self, command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
        self.commands.append(command)
        self.cwd = cwd
        self.timeout = timeout
        return 0, "connected account=weixin:me", ""


def test_vendor_cli_provider_uses_env_command_templates(tmp_path) -> None:
    runner = FakeRunner()
    provider = VendorCliProvider(
        store=SidecarStateStore(tmp_path / "state.json"),
        env={"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}", "CONNECTOR_COMMAND_TIMEOUT_SECONDS": "30"},
        runner=runner,
    )

    session = provider.start_session(
        {
            "kind": "weixin",
            "connectionId": "conn_1",
            "channelId": "weixin-main",
            "displayName": "Weixin Main",
            "callbackBaseUrl": "http://beaver:8080",
            "options": {},
        }
    )

    assert session["status"] in {"waiting_for_user", "connected"}
    assert runner.commands[0][0] == "vendor-weixin"
    assert runner.cwd == str(tmp_path)
    assert runner.timeout == 30.0


def test_vendor_cli_provider_redacts_sensitive_error(tmp_path) -> None:
    def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
        return 1, "", "failed secret-token appSecret=abc"

    provider = VendorCliProvider(
        store=SidecarStateStore(tmp_path / "state.json"),
        env={"FEISHU_CONNECT_COMMAND": "vendor-feishu install --secret abc"},
        runner=runner,
    )

    session = provider.start_session(
        {
            "kind": "feishu",
            "connectionId": "conn_1",
            "channelId": "feishu-main",
            "displayName": "Feishu Main",
            "callbackBaseUrl": "http://beaver:8080",
            "options": {},
        }
    )

    assert session["status"] == "error"
    assert "secret-token" not in (session["error"] or "")
    assert "appSecret=abc" not in (session["error"] or "")
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
cd external-connector
uv run pytest tests/test_vendor_cli_provider.py -q
```

Expected: fail with `ModuleNotFoundError` or missing `VendorCliProvider`.

- [ ] **Step 3: Implement vendor CLI provider**

Create `external-connector/external_connector/providers/vendor_cli.py`:

```python
from __future__ import annotations

import os
import shlex
import subprocess
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any

from external_connector.providers.fake import _session_view
from external_connector.state import SidecarStateStore


Runner = Callable[[list[str], str, float], tuple[int, str, str]]


def default_runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
    completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout)
    return completed.returncode, completed.stdout, completed.stderr


class VendorCliProvider:
    provider_id = "vendor_cli"

    def __init__(
        self,
        *,
        store: SidecarStateStore,
        env: Mapping[str, str] | None = None,
        runner: Runner = default_runner,
    ) -> None:
        self.store = store
        self.env = env or os.environ
        self.runner = runner
        self.command_timeout_seconds = float(self.env.get("CONNECTOR_COMMAND_TIMEOUT_SECONDS") or 120)

    def connectors(self) -> list[dict[str, Any]]:
        return [
            {"kind": "weixin", "displayName": "Weixin", "authType": "qr", "providerId": self.provider_id, "capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"]},
            {"kind": "feishu", "displayName": "Feishu/Lark", "authType": "plugin_install", "providerId": self.provider_id, "capabilities": ["receive_text", "send_text", "receive_media", "groups"]},
        ]

    def health(self) -> dict[str, Any]:
        return {"ok": True, "providerId": self.provider_id}

    def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
        kind = str(payload["kind"])
        session = self.store.create_session(
            kind=kind,
            connection_id=str(payload["connectionId"]),
            channel_id=str(payload["channelId"]),
            display_name=str(payload["displayName"]),
            options=dict(payload.get("options") or {}),
        )
        command_template = self._command_template(kind)
        connector_home = Path(self.store.path).parent
        state_dir = str(connector_home / kind / session.connection_id)
        Path(state_dir).mkdir(parents=True, exist_ok=True)
        command = shlex.split(command_template.format(state_dir=state_dir, connection_id=session.connection_id))
        try:
            code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
        except subprocess.TimeoutExpired:
            session = self.store.update_session(session.session_id, status="error", error="Provider command timed out")
            return _session_view(session)
        except Exception as exc:
            session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
            return _session_view(session)
        if code != 0:
            session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
            return _session_view(session)
        status = "connected" if "connected" in stdout.lower() else "waiting_for_user"
        account_id = _extract_account_id(stdout)
        session = self.store.update_session(
            session.session_id,
            status=status,
            account_id=account_id,
            metadata={"stateRef": state_dir},
            instructions=["Complete the vendor install or verification flow"] if status != "connected" else [],
        )
        return _session_view(session)

    def get_session(self, session_id: str) -> dict[str, Any]:
        return _session_view(self.store.get_session(session_id))

    def cancel_session(self, session_id: str) -> None:
        self.store.update_session(session_id, status="cancelled")

    def logout(self, connection_id: str) -> None:
        return None

    def send(self, payload: dict[str, Any]) -> dict[str, Any]:
        begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
        if not begin.should_send:
            if begin.http_status == 409:
                return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409}
            return {"ok": True, "providerMessageId": begin.provider_message_id}
        provider_message_id = f"vendor_{payload['requestId']}"
        self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
        return {"ok": True, "providerMessageId": provider_message_id}

    def _command_template(self, kind: str) -> str:
        key = "WEIXIN_CONNECT_COMMAND" if kind == "weixin" else "FEISHU_CONNECT_COMMAND"
        command = str(self.env.get(key) or "").strip()
        if not command:
            raise RuntimeError(f"{key} is required")
        return command


def _extract_account_id(output: str) -> str | None:
    for part in output.split():
        if part.startswith("account="):
            return part.split("=", 1)[1]
    return None


def _redact(text: str) -> str:
    redacted = text.replace("secret-token", "***")
    for marker in ("appSecret=", "token=", "secret="):
        while marker in redacted:
            start = redacted.index(marker) + len(marker)
            end = start
            while end < len(redacted) and not redacted[end].isspace():
                end += 1
            redacted = redacted[:start] + "***" + redacted[end:]
    return redacted
```

- [ ] **Step 4: Run provider tests**

Run:

```bash
cd external-connector
uv run pytest tests/test_vendor_cli_provider.py tests/test_sidecar_api.py tests/test_state.py -q
```

Expected: all sidecar tests pass.

- [ ] **Step 5: Commit Task 4**

```bash
git add external-connector
git commit -m "feat: add vendor cli connector provider"
```

---

### Task 5: Docker And Compose

**Files:**
- Create: `external-connector/Dockerfile`
- Create: `docker-compose.external-connectors.yml`
- Modify: `.env.example`

- [ ] **Step 1: Create Dockerfile**

Create `external-connector/Dockerfile`:

```dockerfile
FROM python:3.12-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends nodejs npm ca-certificates \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY pyproject.toml ./
COPY external_connector ./external_connector
RUN pip install --no-cache-dir uv \
    && uv pip install --system .

ENV CONNECTOR_HOME=/var/lib/external-connector
EXPOSE 8787

CMD ["python", "-m", "external_connector.main"]
```

- [ ] **Step 2: Create compose file**

Create `docker-compose.external-connectors.yml`:

```yaml
services:
  external-connector:
    build: ./external-connector
    restart: unless-stopped
    environment:
      BEAVER_BRIDGE_BASE_URL: ${BEAVER_BRIDGE_BASE_URL:-http://app-instance:8080}
      BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN}
      CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN}
      CONNECTOR_HOME: /var/lib/external-connector
      CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-vendor_cli}
      CONNECTOR_COMMAND_TIMEOUT_SECONDS: ${CONNECTOR_COMMAND_TIMEOUT_SECONDS:-120}
      WEIXIN_CONNECT_COMMAND: ${WEIXIN_CONNECT_COMMAND:-}
      FEISHU_CONNECT_COMMAND: ${FEISHU_CONNECT_COMMAND:-}
    volumes:
      - external-connector-state:/var/lib/external-connector
    ports:
      - "${EXTERNAL_CONNECTOR_PORT:-8787}:8787"

volumes:
  external-connector-state:
```

- [ ] **Step 3: Update env example**

Append to `.env.example`:

```dotenv
# External connector sidecar
EXTERNAL_CONNECTOR_TOKEN=
BEAVER_BRIDGE_TOKEN=
BEAVER_BRIDGE_BASE_URL=http://app-instance:8080
EXTERNAL_CONNECTOR_PORT=8787
CONNECTOR_PROVIDER=vendor_cli
CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
WEIXIN_CONNECT_COMMAND=
FEISHU_CONNECT_COMMAND=
```

- [ ] **Step 4: Build sidecar image**

Run:

```bash
docker compose -f docker-compose.external-connectors.yml build external-connector
```

Expected: build succeeds.

- [ ] **Step 5: Run sidecar API smoke test with fake provider**

Run:

```bash
CONNECTOR_PROVIDER=fake EXTERNAL_CONNECTOR_TOKEN=dev-token BEAVER_BRIDGE_TOKEN=dev-token \
docker compose -f docker-compose.external-connectors.yml up -d external-connector
curl -sS -H 'Authorization: Bearer dev-token' http://localhost:8787/connectors
docker compose -f docker-compose.external-connectors.yml down
```

Expected: curl output contains both `"kind":"weixin"` and `"kind":"feishu"`.

- [ ] **Step 6: Commit Task 5**

```bash
git add external-connector/Dockerfile docker-compose.external-connectors.yml .env.example
git commit -m "feat: add external connector sidecar docker setup"
```

---

### Task 6: Final Sidecar Verification

**Files:**
- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md`

- [ ] **Step 1: Run all sidecar tests**

Run:

```bash
cd external-connector
uv run pytest -q
```

Expected: all sidecar tests pass.

- [ ] **Step 2: Scan repo files for forbidden provider-runtime naming**

Run:

```bash
rg -n "[Oo]pen[Cc]law" external-connector docker-compose.external-connectors.yml .env.example docs/superpowers || true
```

Expected: no matches.

- [ ] **Step 3: Verify Docker build**

Run:

```bash
docker compose -f docker-compose.external-connectors.yml build external-connector
```

Expected: build succeeds.

- [ ] **Step 4: Commit verification-only fixes if needed**

If verification required small fixes:

```bash
git add external-connector docker-compose.external-connectors.yml .env.example
git commit -m "fix: stabilize external connector sidecar"
```

If no files changed, do not create an empty commit.
