docs: tighten external connector contract

This commit is contained in:
2026-06-03 09:12:30 +08:00
parent cf35edb4ca
commit feeaccc0e3

View File

@ -15,6 +15,8 @@ This design intentionally fixes four architecture constraints before implementat
- The sidecar is generic. Beaver depends on a connector HTTP contract, not on one vendor runtime. - The sidecar is generic. Beaver depends on a connector HTTP contract, not on one vendor runtime.
- Pairing is modeled as a broader `ConnectorSession`, because Feishu/Lark install/link flows are not only QR pairing. - Pairing is modeled as a broader `ConnectorSession`, because Feishu/Lark install/link flows are not only QR pairing.
- Bridge events include `eventId`, `timestamp`, and `deliveryAttempt`, and Beaver dedupes bridge events before they can trigger duplicate agent replies. - Bridge events include `eventId`, `timestamp`, and `deliveryAttempt`, and Beaver dedupes bridge events before they can trigger duplicate agent replies.
- Bridge authentication is service-level in the first version. The shared connector token lives in environment variables, not per-connection credentials.
- Outbound sidecar sends include a required `requestId` so sidecar retries are idempotent.
- Connected sessions dynamically register runtime channels. A successful Weixin or Feishu/Lark connection must not require a Beaver restart. - Connected sessions dynamically register runtime channels. A successful Weixin or Feishu/Lark connection must not require a Beaver restart.
## Scope ## Scope
@ -60,7 +62,7 @@ Beaver owns:
- connection state in `ChannelConnectionStore` - connection state in `ChannelConnectionStore`
- credential references in `CredentialStore` - credential references in `CredentialStore`
- connector session state exposed to the web UI - connector session state exposed to the web UI
- bridge endpoint authentication - service-level connector authentication
- bridge event dedupe - bridge event dedupe
- normalized runtime message admission - normalized runtime message admission
- runtime channel lifecycle - runtime channel lifecycle
@ -161,12 +163,14 @@ ChannelConfig(
"connectionId": "conn_...", "connectionId": "conn_...",
"sidecarBaseUrl": "http://external-connector:8787", "sidecarBaseUrl": "http://external-connector:8787",
}, },
secrets={"bridgeToken": "..."}, secrets={},
) )
``` ```
The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic. The original `ChannelConnection.kind` remains `weixin` or `feishu`; only the runtime transport kind is generic.
`ExternalConnectorChannel` authenticates outbound calls with the service-level connector token configured in Beaver's process environment, not with a per-channel secret. The same first-version deployment may use one shared token value for both directions, exposed as `EXTERNAL_CONNECTOR_TOKEN` to Beaver and `BEAVER_BRIDGE_TOKEN` to the sidecar.
## Dynamic Runtime Activation ## Dynamic Runtime Activation
A connected connector session must activate without restarting Beaver. A connected connector session must activate without restarting Beaver.
@ -181,6 +185,15 @@ async def remove_channel(self, channel_id: str) -> None:
... ...
``` ```
`add_channel()` must run under a runtime lifecycle lock and has deterministic duplicate semantics:
- Same `channel_id` and same effective `ChannelConfig`: no-op.
- Same `channel_id` and changed effective `ChannelConfig`: build and start the replacement adapter before swapping it into the manager; after the swap succeeds, stop the old adapter.
- Replacement start failure: keep the old adapter registered and running, and return the failure to the caller.
- First registration after runtime start: build the adapter, register it, then start only that adapter.
`remove_channel()` must also run under the lifecycle lock. Missing channel ids are no-op; existing channels are stopped and unregistered.
When a connector session reaches `connected`: When a connector session reaches `connected`:
```text ```text
@ -216,6 +229,7 @@ services:
environment: environment:
BEAVER_BRIDGE_BASE_URL: http://app-instance:8080 BEAVER_BRIDGE_BASE_URL: http://app-instance:8080
BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN} BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN}
CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN}
CONNECTOR_HOME: /var/lib/external-connector CONNECTOR_HOME: /var/lib/external-connector
CONNECTOR_PROVIDER: vendor_cli CONNECTOR_PROVIDER: vendor_cli
volumes: volumes:
@ -227,6 +241,7 @@ For the current `create-instance.sh`-style deployment, the implementation adds:
- `docker-compose.external-connectors.yml` for local/development sidecar tests. - `docker-compose.external-connectors.yml` for local/development sidecar tests.
- documentation for attaching `external-connector` to the same Docker network as the target app instance. - documentation for attaching `external-connector` to the same Docker network as the target app instance.
- instance environment `EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787`. - instance environment `EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787`.
- instance environment `EXTERNAL_CONNECTOR_TOKEN=<service-level shared secret>`.
The implementation must not depend on Beaver mounting `/var/run/docker.sock`. The implementation must not depend on Beaver mounting `/var/run/docker.sock`.
@ -274,11 +289,12 @@ POST /send
"channelId": "weixin-main", "channelId": "weixin-main",
"displayName": "Weixin Main", "displayName": "Weixin Main",
"callbackBaseUrl": "http://app-instance:8080", "callbackBaseUrl": "http://app-instance:8080",
"bridgeToken": "...",
"options": {} "options": {}
} }
``` ```
The sidecar authenticates the connector-session request with `Authorization: Bearer <EXTERNAL_CONNECTOR_TOKEN>`. It already has `BEAVER_BRIDGE_TOKEN` from its environment, so Beaver does not send bridge tokens in connector-session bodies.
For Feishu/Lark, `kind` is `feishu` and `options` may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the official plugin installer to create a bot, the sidecar starts that installer flow and reports QR, instruction, or action status back to Beaver. For Feishu/Lark, `kind` is `feishu` and `options` may include `domain`, `mode`, and optional app credentials when linking an existing bot. If using the official plugin installer to create a bot, the sidecar starts that installer flow and reports QR, instruction, or action status back to Beaver.
`GET /connector-sessions/{session_id}` response: `GET /connector-sessions/{session_id}` response:
@ -315,6 +331,7 @@ Allowed connector session statuses:
```json ```json
{ {
"requestId": "out_...",
"connectionId": "conn_...", "connectionId": "conn_...",
"channelId": "weixin-main", "channelId": "weixin-main",
"kind": "weixin", "kind": "weixin",
@ -330,6 +347,8 @@ Allowed connector session statuses:
} }
``` ```
`requestId` is required. Beaver must generate a stable request id for each outbound delivery attempt from the outbound message identity, and must reuse the same `requestId` if the same outbound delivery is retried. The sidecar dedupes `connectionId + requestId`; duplicate requests return the original send result and must not send a second platform message.
## Beaver Bridge API ## Beaver Bridge API
Add a backend bridge endpoint for sidecar inbound messages: Add a backend bridge endpoint for sidecar inbound messages:
@ -338,7 +357,7 @@ Add a backend bridge endpoint for sidecar inbound messages:
POST /api/channel-connector-bridge/events POST /api/channel-connector-bridge/events
``` ```
The sidecar must authenticate every bridge request using a bearer token scoped to the connector service. Beaver rejects missing or invalid bridge tokens. The sidecar must authenticate every bridge request using the service-level bearer token from `BEAVER_BRIDGE_TOKEN`. Beaver rejects missing or invalid bridge tokens. Bridge tokens are deployment secrets, not connection records.
Bridge event body: Bridge event body:
@ -405,7 +424,12 @@ class ConnectorMessageDedupeRecord:
- `completed` - `completed`
- `failed` - `failed`
If a duplicate bridge event arrives while the record is `processing` or `completed`, Beaver returns an idempotent success response and does not call `ChannelRuntime.accept_inbound()` again. Duplicate handling:
- `completed`: return idempotent success and do not call `ChannelRuntime.accept_inbound()` again.
- `processing` updated less than 60 seconds ago: return `409 Conflict` with `{"retryAfterSeconds": 5}` so the sidecar retries later.
- `processing` updated 60 seconds or more ago: treat the record as stale, increment `delivery_attempts`, update `updated_at`, and reprocess the event.
- `failed`: allow reprocessing on the next delivery attempt, increment `delivery_attempts`, and clear `last_error` before calling runtime.
This store is separate from runtime session dedupe. Runtime dedupe still protects platform message identity, while bridge dedupe protects connector retries. This store is separate from runtime session dedupe. Runtime dedupe still protects platform message identity, while bridge dedupe protects connector retries.
@ -419,7 +443,7 @@ Responsibilities:
- start Weixin connector session through sidecar `/connector-sessions` - start Weixin connector session through sidecar `/connector-sessions`
- poll sidecar connector session status - poll sidecar connector session status
- create or update `ChannelConnection` - create or update `ChannelConnection`
- store bridge token and sidecar connection state reference in `CredentialStore` - store sidecar connection state reference in `CredentialStore` when the provider returns one
- validate by checking sidecar connection status - validate by checking sidecar connection status
- materialize runtime config for `ExternalConnectorChannel` - materialize runtime config for `ExternalConnectorChannel`
- activate runtime via `ChannelRuntime.add_channel()` when connected - activate runtime via `ChannelRuntime.add_channel()` when connected
@ -470,15 +494,18 @@ The old `/api/channels` static config editor may remain for advanced runtime con
- QR expired: status `expired`, user can start a new connector session. - QR expired: status `expired`, user can start a new connector session.
- Bridge token invalid: reject with `401`, record event without platform secret values. - Bridge token invalid: reject with `401`, record event without platform secret values.
- Unknown connection id in bridge event: reject with `404`. - Unknown connection id in bridge event: reject with `404`.
- Duplicate bridge event: return idempotent success and do not call runtime again. - Duplicate completed bridge event: return idempotent success and do not call runtime again.
- Duplicate in-flight bridge event: return `409 Conflict` until the 60-second processing TTL expires, then allow one reprocess.
- Outbound send failure: mark outbound delivery failed and record connector error. - Outbound send failure: mark outbound delivery failed and record connector error.
- Duplicate outbound send `requestId`: sidecar returns the original send result and does not send a second platform message.
- Sidecar restart: persisted provider state should survive through sidecar volume. - Sidecar restart: persisted provider state should survive through sidecar volume.
## Security ## Security
- Beaver never logs raw tokens, app secrets, bridge tokens, or sidecar connection tokens. - Beaver never logs raw tokens, app secrets, bridge tokens, or sidecar connection tokens.
- Bridge token is generated by Beaver and stored behind `credentials_ref`. - Bridge authentication uses a service-level token from environment variables. It is not stored per connection and is never returned by APIs.
- Sidecar can only call bridge endpoints with its bridge token. - Sidecar can only call bridge endpoints with the service-level bridge token.
- Beaver can only call sidecar control and send endpoints with the service-level connector token.
- Sidecar state volume contains login state and must be treated as sensitive. - Sidecar state volume contains login state and must be treated as sensitive.
- Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed. - Feishu user-identity mode has stronger privacy risk than bot-identity mode; UI must label it clearly if exposed.
@ -489,11 +516,14 @@ Backend unit tests:
- sidecar client fake for Weixin connector session start/status/logout/send - sidecar client fake for Weixin connector session start/status/logout/send
- sidecar client fake for Feishu connector session start/status/logout/send - sidecar client fake for Feishu connector session start/status/logout/send
- `ExternalConnectorChannel.send()` target mapping - `ExternalConnectorChannel.send()` target mapping
- `ExternalConnectorChannel.send()` includes stable `requestId` and connector bearer auth
- `ChannelRuntime.add_channel()` dynamically starts and registers a channel - `ChannelRuntime.add_channel()` dynamically starts and registers a channel
- `ChannelRuntime.add_channel()` no-ops for identical config, replaces changed config, and keeps the old channel if replacement start fails
- `ChannelRuntime.remove_channel()` stops and unregisters a channel - `ChannelRuntime.remove_channel()` stops and unregisters a channel
- bridge endpoint accepts valid events - bridge endpoint accepts valid events
- bridge endpoint rejects invalid token and unknown connection id - bridge endpoint rejects invalid token and unknown connection id
- bridge endpoint dedupes repeated `eventId` and calls runtime once - bridge endpoint dedupes repeated `eventId` and calls runtime once
- bridge endpoint returns `409 Conflict` for non-stale `processing` duplicates and reprocesses stale records
- registry lists `telegram`, `weixin`, and `feishu` - registry lists `telegram`, `weixin`, and `feishu`
- materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory - materialized sidecar connections produce `ChannelConfig(kind="external_connector", mode="http")` compatible with runtime factory
@ -502,6 +532,7 @@ Sidecar tests:
- HTTP API shape for health/connectors/connector-sessions/send - HTTP API shape for health/connectors/connector-sessions/send
- fake provider status transitions - fake provider status transitions
- provider command runner error redaction - provider command runner error redaction
- send idempotency for duplicate `connectionId + requestId`
Frontend tests: Frontend tests: