chore: update external connector deployment flow

This commit is contained in:
2026-06-05 17:27:05 +08:00
parent 2c5205b06e
commit e0bc6c55b0
12 changed files with 318 additions and 93 deletions

View File

@ -148,6 +148,12 @@ BEAVER_WORKSPACE=/root/.beaver/workspace
- 实例容器的宿主机端口默认只绑定 `127.0.0.1` - 实例容器的宿主机端口默认只绑定 `127.0.0.1`
- 外部访问应统一走 `router-proxy` - 外部访问应统一走 `router-proxy`
- 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0` - 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0`
- 使用共享 `external-connector` sidecar 时,每个实例容器都必须带自己的内部回调地址:
`EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://<app-instance-container-name>:8080`
- 通过 `create-instance.sh --network <docker-network>` 创建实例时,脚本会默认使用
`http://<container-name>:8080` 作为回调地址;生产部署也可以用
`--external-connector-callback-base-url <url>` 显式覆盖
- `BEAVER_BRIDGE_BASE_URL` 只作为 sidecar 的旧连接或兜底地址;多实例部署不能依赖它路由所有入站事件
下一步可以继续接: 下一步可以继续接:

View File

@ -15,7 +15,9 @@ class AgentRegistry:
self.path = self.workspace / "agents" / "registry.json" self.path = self.workspace / "agents" / "registry.json"
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists(): if not self.path.exists():
self._write_agents(_builtin_agents()) self._write_agents([])
else:
self._drop_legacy_builtin_agents()
def list_agents(self, *, include_disabled: bool = True) -> list[RegisteredAgent]: def list_agents(self, *, include_disabled: bool = True) -> list[RegisteredAgent]:
agents = self._read_agents() agents = self._read_agents()
@ -125,72 +127,14 @@ class AgentRegistry:
payload = {"version": 1, "agents": [agent.to_dict() for agent in agents]} payload = {"version": 1, "agents": [agent.to_dict() for agent in agents]}
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _drop_legacy_builtin_agents(self) -> None:
agents = self._read_agents()
migrated = [agent for agent in agents if agent.source != "builtin"]
if len(migrated) != len(agents):
self._write_agents(migrated)
def _terms(text: str) -> set[str]: def _terms(text: str) -> set[str]:
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text) normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
return {part for part in normalized.split() if part} return {part for part in normalized.split() if part}
def _builtin_agents() -> list[RegisteredAgent]:
return [
RegisteredAgent(
agent_id="researcher",
name="researcher",
display_name="Researcher",
role="research",
description="Finds facts, references, constraints, and implementation options.",
system_prompt="You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
capabilities=["research", "analysis", "source review", "requirements"],
tags=["planning", "research"],
priority=50,
source="builtin",
),
RegisteredAgent(
agent_id="implementer",
name="implementer",
display_name="Implementer",
role="implementation",
description="Builds scoped implementation slices and proposes concrete changes.",
system_prompt="You are an implementation specialist. Produce practical, scoped implementation output.",
capabilities=["implementation", "coding", "refactor", "integration"],
tags=["coding", "build"],
priority=45,
source="builtin",
),
RegisteredAgent(
agent_id="reviewer",
name="reviewer",
display_name="Reviewer",
role="review",
description="Reviews plans, code, outputs, and risks before final synthesis.",
system_prompt="You are a review specialist. Focus on defects, missing requirements, and risks.",
capabilities=["review", "quality", "risk", "verification"],
tags=["review", "quality"],
priority=45,
source="builtin",
),
RegisteredAgent(
agent_id="tester",
name="tester",
display_name="Tester",
role="testing",
description="Designs and executes verification checks for task outputs.",
system_prompt="You are a testing specialist. Identify focused checks and report pass/fail evidence.",
capabilities=["testing", "verification", "regression", "qa"],
tags=["test", "quality"],
priority=40,
source="builtin",
),
RegisteredAgent(
agent_id="documenter",
name="documenter",
display_name="Documenter",
role="documentation",
description="Writes and reconciles user-facing and internal documentation updates.",
system_prompt="You are a documentation specialist. Produce concise docs aligned with the implementation.",
capabilities=["documentation", "explanation", "migration notes", "release notes"],
tags=["docs", "communication"],
priority=35,
source="builtin",
),
]

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from .models import ChannelRuntimeSpec, ValidationResult from .models import ChannelRuntimeSpec, ValidationResult
@ -37,6 +38,7 @@ class ExternalConnectorBase:
self.credential_store = credential_store self.credential_store = credential_store
self.sidecar_client = sidecar_client self.sidecar_client = sidecar_client
self.sidecar_base_url = sidecar_base_url self.sidecar_base_url = sidecar_base_url
self.callback_base_url = _callback_base_url()
async def start_session( async def start_session(
self, self,
@ -63,7 +65,7 @@ class ExternalConnectorBase:
"connectionId": connection.connection_id, "connectionId": connection.connection_id,
"channelId": connection.channel_id, "channelId": connection.channel_id,
"displayName": connection.display_name, "displayName": connection.display_name,
"callbackBaseUrl": "", "callbackBaseUrl": self.callback_base_url,
"options": dict(options), "options": dict(options),
} }
view = dict(await self.sidecar_client.start_session(payload)) view = dict(await self.sidecar_client.start_session(payload))
@ -176,6 +178,14 @@ def _policy_runtime_config(options: dict[str, Any]) -> dict[str, Any]:
return result return result
def _callback_base_url() -> str:
for name in ("EXTERNAL_CONNECTOR_CALLBACK_BASE_URL", "BEAVER_CONNECTOR_CALLBACK_BASE_URL"):
value = os.environ.get(name, "").strip()
if value:
return value.rstrip("/")
return ""
def _string_list(value: Any) -> list[str]: def _string_list(value: Any) -> list[str]:
if isinstance(value, str): if isinstance(value, str):
raw_items = value.replace("\n", ",").split(",") raw_items = value.replace("\n", ",").split(",")

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
from beaver.coordinator.registry import AgentRegistry, RegisteredAgent, TargetResolver from beaver.coordinator.registry import AgentRegistry, RegisteredAgent, TargetResolver
from beaver.tasks import TaskRecord from beaver.tasks import TaskRecord
@ -20,22 +22,64 @@ def _task() -> TaskRecord:
) )
def test_registry_seeds_builtin_agents_and_filters_disabled(tmp_path) -> None: def test_registry_starts_empty_and_filters_disabled(tmp_path) -> None:
registry = AgentRegistry(tmp_path) registry = AgentRegistry(tmp_path)
assert {agent.agent_id for agent in registry.list_active_agents()} >= { assert registry.list_agents() == []
"researcher",
"implementer",
"reviewer",
"tester",
"documenter",
}
registry.upsert_agent(
RegisteredAgent(
agent_id="tester",
name="tester",
display_name="Tester",
role="testing",
description="Runs checks.",
system_prompt="test",
)
)
registry.disable_agent("tester") registry.disable_agent("tester")
assert "tester" not in {agent.agent_id for agent in registry.list_active_agents()} assert "tester" not in {agent.agent_id for agent in registry.list_active_agents()}
def test_registry_drops_legacy_builtin_agents(tmp_path) -> None:
registry_path = tmp_path / "agents" / "registry.json"
registry_path.parent.mkdir(parents=True)
registry_path.write_text(
json.dumps(
{
"version": 1,
"agents": [
{
"agent_id": "researcher",
"name": "researcher",
"display_name": "Researcher",
"role": "research",
"description": "legacy builtin",
"system_prompt": "research",
"source": "builtin",
},
{
"agent_id": "workspace-agent",
"name": "workspace-agent",
"display_name": "Workspace Agent",
"role": "workspace",
"description": "user configured",
"system_prompt": "work",
"source": "workspace",
},
],
}
)
+ "\n",
encoding="utf-8",
)
registry = AgentRegistry(tmp_path)
assert [agent.agent_id for agent in registry.list_agents()] == ["workspace-agent"]
def test_resolver_selects_registered_agent_by_role_and_capabilities(tmp_path) -> None: def test_resolver_selects_registered_agent_by_role_and_capabilities(tmp_path) -> None:
registry = AgentRegistry(tmp_path) registry = AgentRegistry(tmp_path)
registry.upsert_agent( registry.upsert_agent(
@ -88,4 +132,3 @@ def test_resolver_falls_back_to_ephemeral_agent_when_no_match(tmp_path) -> None:
assert resolved.nodes[0].agent.name == "rare" assert resolved.nodes[0].agent.name == "rare"
assert resolved.nodes[0].agent.metadata["resolution"] == "fallback_ephemeral" assert resolved.nodes[0].agent.metadata["resolution"] == "fallback_ephemeral"
assert reports[0].fallback_used is True assert reports[0].fallback_used is True

View File

@ -59,8 +59,9 @@ class ImmediateConnectedSidecarClient(FakeSidecarClient):
return session return session
def test_weixin_connector_starts_connector_session(tmp_path) -> None: def test_weixin_connector_starts_connector_session(tmp_path, monkeypatch) -> None:
async def run() -> None: async def run() -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_CALLBACK_BASE_URL", "http://app-instance-jaychen:8080")
connection_store = ChannelConnectionStore(tmp_path / "connections.json") connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json") credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient() client = FakeSidecarClient()
@ -77,6 +78,7 @@ def test_weixin_connector_starts_connector_session(tmp_path) -> None:
assert view["connectionId"].startswith("conn_") assert view["connectionId"].startswith("conn_")
assert client.started[0]["kind"] == "weixin" assert client.started[0]["kind"] == "weixin"
assert client.started[0]["connectionId"].startswith("conn_") assert client.started[0]["connectionId"].startswith("conn_")
assert client.started[0]["callbackBaseUrl"] == "http://app-instance-jaychen:8080"
assert connection_store.list()[0].kind == "weixin" assert connection_store.list()[0].kind == "weixin"
assert connection_store.list()[0].status == "pairing" assert connection_store.list()[0].status == "pairing"

View File

@ -22,6 +22,7 @@ USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}" EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}"
EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}" EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}"
BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}" BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}"
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL:-}"
BACKEND_ID="" BACKEND_ID=""
CLIENT_ID="" CLIENT_ID=""
CLIENT_SECRET="" CLIENT_SECRET=""
@ -81,6 +82,9 @@ Optional:
External connector sidecar URL. Default: http://external-connector:8787 External connector sidecar URL. Default: http://external-connector:8787
--external-connector-token <token> --external-connector-token <token>
Service token used for Beaver-to-sidecar requests. Service token used for Beaver-to-sidecar requests.
--external-connector-callback-base-url <url>
Internal URL the sidecar should call back for inbound events.
Default: http://<container-name>:8080 when a Docker network is used.
--bridge-token <token> Service token accepted from the connector bridge. --bridge-token <token> Service token accepted from the connector bridge.
--backend-id <id> Pre-assigned backend id. --backend-id <id> Pre-assigned backend id.
--client-id <id> Pre-assigned AuthZ client id. --client-id <id> Pre-assigned AuthZ client id.
@ -565,6 +569,10 @@ while [[ $# -gt 0 ]]; do
EXTERNAL_CONNECTOR_TOKEN="${2:-}" EXTERNAL_CONNECTOR_TOKEN="${2:-}"
shift 2 shift 2
;; ;;
--external-connector-callback-base-url)
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${2:-}"
shift 2
;;
--bridge-token) --bridge-token)
BEAVER_BRIDGE_TOKEN="${2:-}" BEAVER_BRIDGE_TOKEN="${2:-}"
shift 2 shift 2
@ -772,6 +780,10 @@ RUN_ARGS=(
--label "beaver.instance.public_url=${PUBLIC_URL}" --label "beaver.instance.public_url=${PUBLIC_URL}"
) )
if [[ -z "$EXTERNAL_CONNECTOR_CALLBACK_BASE_URL" && -n "$NETWORK_NAME" ]]; then
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="http://${CONTAINER_NAME}:8080"
fi
if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then
RUN_ARGS+=( RUN_ARGS+=(
-v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro" -v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro"
@ -786,6 +798,9 @@ fi
if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}") RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}")
fi fi
if [[ -n "$EXTERNAL_CONNECTOR_CALLBACK_BASE_URL" ]]; then
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL}")
fi
if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then
RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}") RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}")
fi fi

View File

@ -698,9 +698,11 @@ For `terminaltest`, ensure the app container has:
EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787 EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
EXTERNAL_CONNECTOR_TOKEN=dev-token EXTERNAL_CONNECTOR_TOKEN=dev-token
BEAVER_BRIDGE_TOKEN=dev-token BEAVER_BRIDGE_TOKEN=dev-token
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://app-instance-terminaltest:8080
``` ```
Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver. Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver.
For multi-instance deployments, this callback URL must point at the specific app-instance container that owns the connection; the shared sidecar stores it per connector session and uses it for inbound events.
- [ ] **Step 6: Manual fake-provider onboarding** - [ ] **Step 6: Manual fake-provider onboarding**

View File

@ -83,6 +83,7 @@ class FeishuBotProvider:
metadata = { metadata = {
"eventCallbackPath": "/feishu/events", "eventCallbackPath": "/feishu/events",
"eventCallbackUrl": f"{self.public_base_url}/feishu/events", "eventCallbackUrl": f"{self.public_base_url}/feishu/events",
"bridgeBaseUrl": str(payload.get("callbackBaseUrl") or self.bridge_base_url),
} }
app_id = str(options.get("appId") or options.get("app_id") or "").strip() app_id = str(options.get("appId") or options.get("app_id") or "").strip()
app_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip() app_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip()
@ -212,7 +213,7 @@ class FeishuBotProvider:
if bridge_event is None: if bridge_event is None:
return {"ok": True, "ignored": "empty_or_oversized"} return {"ok": True, "ignored": "empty_or_oversized"}
self.bridge_post( self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events", f"{_bridge_base_url(session, self.bridge_base_url)}/api/channel-connector-bridge/events",
bridge_event, bridge_event,
{"Authorization": f"Bearer {self.bridge_token}"}, {"Authorization": f"Bearer {self.bridge_token}"},
) )
@ -452,13 +453,17 @@ class FeishuBotProvider:
"FEISHU_TEXT_BATCH_DELAY_MS": str(_positive_int(metadata.get("textBatchDelayMs"), default=0)), "FEISHU_TEXT_BATCH_DELAY_MS": str(_positive_int(metadata.get("textBatchDelayMs"), default=0)),
"FEISHU_TEXT_BATCH_MAX_MESSAGES": str(_positive_int(metadata.get("textBatchMaxMessages"), default=10)), "FEISHU_TEXT_BATCH_MAX_MESSAGES": str(_positive_int(metadata.get("textBatchMaxMessages"), default=10)),
"FEISHU_TEXT_BATCH_MAX_CHARS": str(_positive_int(metadata.get("textBatchMaxChars"), default=20000)), "FEISHU_TEXT_BATCH_MAX_CHARS": str(_positive_int(metadata.get("textBatchMaxChars"), default=20000)),
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url, "BEAVER_BRIDGE_BASE_URL": _bridge_base_url(session, self.bridge_base_url),
"BEAVER_BRIDGE_TOKEN": self.bridge_token, "BEAVER_BRIDGE_TOKEN": self.bridge_token,
} }
) )
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent)) return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
def _bridge_base_url(session: ConnectorSessionState, fallback: str) -> str:
return str(session.metadata.get("bridgeBaseUrl") or fallback).rstrip("/")
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any] | None: def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any] | None:
message = dict(event.get("message") or {}) message = dict(event.get("message") or {})
sender = dict(event.get("sender") or {}) sender = dict(event.get("sender") or {})

View File

@ -246,7 +246,7 @@ class WeixinIlinkProvider:
flush=True, flush=True,
) )
self.bridge_post( self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events", f"{_bridge_base_url(session, self.bridge_base_url)}/api/channel-connector-bridge/events",
event, event,
{"Authorization": f"Bearer {self.bridge_token}"}, {"Authorization": f"Bearer {self.bridge_token}"},
) )
@ -384,6 +384,10 @@ def _url(base_url: str, endpoint: str) -> str:
return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}" return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
def _bridge_base_url(session: ConnectorSessionState, fallback: str) -> str:
return str(session.metadata.get("bridgeBaseUrl") or fallback).rstrip("/")
def _base_info() -> dict[str, str]: def _base_info() -> dict[str, str]:
return {"channel_version": "2.4.3", "bot_agent": "Beaver/1.0"} return {"channel_version": "2.4.3", "bot_agent": "Beaver/1.0"}

View File

@ -384,6 +384,44 @@ def test_feishu_event_route_forwards_message_to_bridge(tmp_path) -> None:
assert bridge_posts[0][1]["peerId"] == "ou_user" assert bridge_posts[0][1]["peerId"] == "ou_user"
def test_feishu_event_route_uses_session_callback_base_url(tmp_path) -> None:
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
provider = _provider(tmp_path, bridge_posts=bridge_posts)
provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://app-instance-jaychen:8080",
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
}
)
app = create_app(provider=provider, api_token="sidecar-token")
with TestClient(app) as client:
response = client.post(
"/feishu/events",
json={
"schema": "2.0",
"header": {"event_id": "evt_1", "token": "verify-token", "app_id": "cli_xxx"},
"event": {
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello feishu\"}",
},
},
},
)
assert response.status_code == 200
assert bridge_posts[0][0] == "http://app-instance-jaychen:8080/api/channel-connector-bridge/events"
def test_feishu_event_route_ignores_bot_sender_and_platform_commands(tmp_path) -> None: def test_feishu_event_route_ignores_bot_sender_and_platform_commands(tmp_path) -> None:
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = [] bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
provider = _provider(tmp_path, bridge_posts=bridge_posts) provider = _provider(tmp_path, bridge_posts=bridge_posts)

View File

@ -438,3 +438,35 @@ def test_weixin_ilink_provider_poll_once_forwards_bridge_event(tmp_path) -> None
assert bridge_posts[0][1]["eventId"] == "weixin-main:42" assert bridge_posts[0][1]["eventId"] == "weixin-main:42"
assert bridge_posts[0][1]["content"] == "hello" assert bridge_posts[0][1]["content"] == "hello"
assert bridge_posts[0][1]["peerId"] == "wx-user" assert bridge_posts[0][1]["peerId"] == "wx-user"
def test_weixin_ilink_provider_poll_once_uses_session_callback_base_url(tmp_path) -> None:
http = FakeHttpClient()
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
bridge_posts.append((url, payload, headers))
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://global-beaver:8080",
bridge_token="bridge-token",
bridge_post=bridge_post,
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://app-instance-jaychen:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.poll_once("conn_1")
assert bridge_posts[0][0] == "http://app-instance-jaychen:8080/api/channel-connector-bridge/events"

View File

@ -6,6 +6,7 @@
- `authz-service` - `authz-service`
- `deploy-control` - `deploy-control`
- `router-proxy` - `router-proxy`
- 可选的 `external-connector` sidecar
- 自动创建出来的 `app-instance` - 自动创建出来的 `app-instance`
目标结果: 目标结果:
@ -35,6 +36,7 @@
```bash ```bash
docker --version docker --version
docker compose version
docker ps docker ps
python3 --version python3 --version
openssl version openssl version
@ -96,6 +98,20 @@ export BEAVER_USER_FILES_MAX_UPLOAD_BYTES=$((5 * 1024 * 1024 * 1024))
export BEAVER_OUTLOOK_MCP_URL='' export BEAVER_OUTLOOK_MCP_URL=''
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp' export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
export EXTERNAL_CONNECTOR_BASE_URL='http://external-connector:8787'
export EXTERNAL_CONNECTOR_TOKEN="$(openssl rand -hex 32)"
export BEAVER_BRIDGE_TOKEN="$(openssl rand -hex 32)"
export EXTERNAL_CONNECTOR_PORT=8787
export CONNECTOR_PUBLIC_BASE_URL='http://127.0.0.1:8787'
export CONNECTOR_PROVIDER=fake
export CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
export WEIXIN_CONNECT_COMMAND=''
export WEIXIN_STATUS_COMMAND=''
export WEIXIN_SEND_COMMAND=''
export FEISHU_CONNECT_COMMAND=''
export FEISHU_STATUS_COMMAND=''
export FEISHU_SEND_COMMAND=''
``` ```
变量说明: 变量说明:
@ -115,6 +131,13 @@ export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
| `BEAVER_USER_FILES_MAX_UPLOAD_BYTES` | 用户文件系统上传上限,默认 5GB聊天附件和 workspace 上传仍保留当前小文件限制 | | `BEAVER_USER_FILES_MAX_UPLOAD_BYTES` | 用户文件系统上传上限,默认 5GB聊天附件和 workspace 上传仍保留当前小文件限制 |
| `BEAVER_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 | | `BEAVER_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 |
| `BEAVER_OUTLOOK_MCP_SERVER_ID` | Outlook MCP server id默认 `outlook_mcp` | | `BEAVER_OUTLOOK_MCP_SERVER_ID` | Outlook MCP server id默认 `outlook_mcp` |
| `EXTERNAL_CONNECTOR_BASE_URL` | app-instance 容器访问外部连接器 sidecar 的地址 |
| `EXTERNAL_CONNECTOR_TOKEN` | app-instance 调用 sidecar 管理 API 的 bearer token |
| `BEAVER_BRIDGE_TOKEN` | sidecar 回调 app-instance bridge API 的 bearer token |
| `EXTERNAL_CONNECTOR_PORT` | sidecar 映射到宿主机的调试端口,默认 `8787` |
| `CONNECTOR_PUBLIC_BASE_URL` | sidecar 对外展示自身回调或资源地址时使用的 URL |
| `CONNECTOR_PROVIDER` | sidecar provider本机连通性测试用 `fake`,真实接入再改成 `weixin_ilink``feishu_bot``vendor_cli` |
| `WEIXIN_*_COMMAND` / `FEISHU_*_COMMAND` | `vendor_cli` 模式下调用厂商脚本的命令;`fake` 模式留空 |
如果接入外部正式 MinIO不需要启动本地 `beaver-minio`。把上面的 MinIO 变量改成正式服务即可: 如果接入外部正式 MinIO不需要启动本地 `beaver-minio`。把上面的 MinIO 变量改成正式服务即可:
@ -212,6 +235,7 @@ docker build -t beaver/app-instance:latest app-instance
docker build -t beaver/authz-service:latest authz-service docker build -t beaver/authz-service:latest authz-service
docker build -t beaver/deploy-control:latest deploy-control docker build -t beaver/deploy-control:latest deploy-control
docker build -t beaver/auth-portal:latest auth-portal/src docker build -t beaver/auth-portal:latest auth-portal/src
docker compose -f docker-compose.external-connectors.yml build external-connector
``` ```
如果某个镜像构建失败,先修构建错误,不要继续往下跑。 如果某个镜像构建失败,先修构建错误,不要继续往下跑。
@ -258,7 +282,37 @@ http://alice.localhost:8088
http://alice.apps.example.com:8088 http://alice.apps.example.com:8088
``` ```
## 7. 启动 MinIO ## 7. 启动 external-connector sidecar可选
`external-connector` 用于微信、飞书/Lark 这类需要独立进程或厂商 SDK 的连接器。当前部署可以先用 `fake` provider 验证 sidecar、token、网络和 app-instance 回调链路;正式接入时再把 `CONNECTOR_PROVIDER` 和对应命令换成真实配置。
如果暂时不需要微信或飞书连接器,可以跳过本节。但建议至少在测试环境跑一次,确认部署变量没有断。
```bash
cd "$PROJECT_ROOT"
docker compose -f docker-compose.external-connectors.yml up -d external-connector
```
检查:
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep external-connector
curl -sS -H "Authorization: Bearer $EXTERNAL_CONNECTOR_TOKEN" \
"http://127.0.0.1:${EXTERNAL_CONNECTOR_PORT}/connectors"
```
预期 `/connectors` 返回连接器列表,至少包含 `weixin``feishu`。如果报 `401`,检查 `EXTERNAL_CONNECTOR_TOKEN` 是否和容器环境变量一致。
多实例部署时不要依赖 `BEAVER_BRIDGE_BASE_URL=http://app-instance:8080` 这种固定兜底地址。`deploy-control` 通过 `create-instance.sh --network "$BEAVER_NET"` 创建实例时,会让每个 app-instance 默认带自己的回调地址:
```text
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://<app-instance-container-name>:8080
```
sidecar 会按每个连接 session 保存这个回调地址,入站消息才能回到正确的用户实例。
## 8. 启动 MinIO
MinIO 是用户文件系统的后端实现细节。用户和前端不会看到 bucket、access key 或 prefixBeaver 只通过 `/api/user-files/*` 暴露个人智能体文件系统。 MinIO 是用户文件系统的后端实现细节。用户和前端不会看到 bucket、access key 或 prefixBeaver 只通过 `/api/user-files/*` 暴露个人智能体文件系统。
@ -293,7 +347,7 @@ example object: users/alice/uploads/report.pdf
用户文件上传由 Beaver 后端代理到 MinIO不暴露 bucket、prefix 或凭据。当前默认允许最大 5GB 的用户文件上传,业务上限由 app-instance 后端环境变量 `BEAVER_USER_FILES_MAX_UPLOAD_BYTES` 控制;反向代理默认 `client_max_body_size` 已提高到 5GB。MinIO 本身支持大对象和 multipart 上传,但 agent 对超大文件的读取/处理能力仍需要按具体任务另行验证。 用户文件上传由 Beaver 后端代理到 MinIO不暴露 bucket、prefix 或凭据。当前默认允许最大 5GB 的用户文件上传,业务上限由 app-instance 后端环境变量 `BEAVER_USER_FILES_MAX_UPLOAD_BYTES` 控制;反向代理默认 `client_max_body_size` 已提高到 5GB。MinIO 本身支持大对象和 multipart 上传,但 agent 对超大文件的读取/处理能力仍需要按具体任务另行验证。
## 8. 启动 authz-service ## 9. 启动 authz-service
```bash ```bash
docker rm -f beaver-authz-service >/dev/null 2>&1 || true docker rm -f beaver-authz-service >/dev/null 2>&1 || true
@ -332,7 +386,7 @@ docker inspect beaver-authz-service --format '{{range .Config.Env}}{{println .}}
| egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL|USER_FILES_MINIO_)=' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL|USER_FILES_MINIO_)='
``` ```
## 9. 启动 deploy-control ## 10. 启动 deploy-control
`deploy-control` 会挂载 Docker socket再创建新的 `app-instance` 容器。这里最容易错的是路径挂载: `deploy-control` 会挂载 Docker socket再创建新的 `app-instance` 容器。这里最容易错的是路径挂载:
@ -353,8 +407,10 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \ -v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \ -v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
-v "$PROJECT_ROOT/skills:$PROJECT_ROOT/skills:ro" \
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \ -e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \ -e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
-e DEFAULT_INITIAL_SKILLS_DIR="$PROJECT_ROOT/skills" \
-e PROXY_CONTAINER_NAME="$BEAVER_PROXY_CONTAINER_NAME" \ -e PROXY_CONTAINER_NAME="$BEAVER_PROXY_CONTAINER_NAME" \
-e PROXY_NETWORK_NAME="$BEAVER_NET" \ -e PROXY_NETWORK_NAME="$BEAVER_NET" \
-e DEPLOY_CONTROL_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \ -e DEPLOY_CONTROL_API_TOKEN="$BEAVER_DEPLOY_TOKEN" \
@ -365,6 +421,9 @@ docker run -d \
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$BEAVER_OUTLOOK_MCP_URL" \ -e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$BEAVER_OUTLOOK_MCP_URL" \
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \ -e DEFAULT_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \
-e DEFAULT_USER_FILES_MAX_UPLOAD_BYTES="$BEAVER_USER_FILES_MAX_UPLOAD_BYTES" \ -e DEFAULT_USER_FILES_MAX_UPLOAD_BYTES="$BEAVER_USER_FILES_MAX_UPLOAD_BYTES" \
-e DEFAULT_EXTERNAL_CONNECTOR_BASE_URL="$EXTERNAL_CONNECTOR_BASE_URL" \
-e DEFAULT_EXTERNAL_CONNECTOR_TOKEN="$EXTERNAL_CONNECTOR_TOKEN" \
-e DEFAULT_BEAVER_BRIDGE_TOKEN="$BEAVER_BRIDGE_TOKEN" \
-e DEPLOY_PUBLIC_SCHEME="http" \ -e DEPLOY_PUBLIC_SCHEME="http" \
-e DEPLOY_PUBLIC_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \ -e DEPLOY_PUBLIC_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \
-e DEPLOY_PUBLIC_PORT="8088" \ -e DEPLOY_PUBLIC_PORT="8088" \
@ -378,7 +437,11 @@ docker run -d \
`DEFAULT_AUTHZ_INTERNAL_TOKEN` 会写入新建 app-instance 的后端 runtime env用于 app-instance 后端读取自己的 internal MinIO settings。它不会传给前端。 `DEFAULT_AUTHZ_INTERNAL_TOKEN` 会写入新建 app-instance 的后端 runtime env用于 app-instance 后端读取自己的 internal MinIO settings。它不会传给前端。
## 10. 启动 auth-portal `DEFAULT_EXTERNAL_CONNECTOR_*` 会写入之后新创建的 app-instance 容器环境变量。改动这些变量后,要重启 `beaver-deploy-control` 并重新创建实例,或手工重建已有实例容器;仅重启 sidecar 不会更新已存在 app-instance 的环境变量。
`DEFAULT_INITIAL_SKILLS_DIR` 需要和 `skills/` 的只读挂载路径一致。否则新实例能启动,但 workspace 里不会自动种入初始 published skills。
## 11. 启动 auth-portal
```bash ```bash
docker rm -f beaver-auth-portal >/dev/null 2>&1 || true docker rm -f beaver-auth-portal >/dev/null 2>&1 || true
@ -401,13 +464,15 @@ docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{
| egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)=' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
``` ```
## 11. 健康检查 ## 12. 健康检查
```bash ```bash
curl http://127.0.0.1:19090/healthz curl http://127.0.0.1:19090/healthz
curl http://127.0.0.1:8090/healthz curl http://127.0.0.1:8090/healthz
curl -I http://127.0.0.1:3081 curl -I http://127.0.0.1:3081
curl -I http://127.0.0.1:9001 curl -I http://127.0.0.1:9001
curl -sS -H "Authorization: Bearer $EXTERNAL_CONNECTOR_TOKEN" \
"http://127.0.0.1:${EXTERNAL_CONNECTOR_PORT}/connectors"
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker logs --tail=50 beaver-router-proxy docker logs --tail=50 beaver-router-proxy
``` ```
@ -419,8 +484,9 @@ docker logs --tail=50 beaver-router-proxy
- `beaver-deploy-control` - `beaver-deploy-control`
- `beaver-auth-portal` - `beaver-auth-portal`
- `beaver-router-proxy` - `beaver-router-proxy`
- `external-connector`(如果启用了连接器 sidecar
## 12. 浏览器首次测试 ## 13. 浏览器首次测试
打开: 打开:
@ -572,7 +638,7 @@ docker run --rm --network "$BEAVER_NET" --entrypoint /bin/sh minio/mc:latest -lc
curl -X DELETE "http://127.0.0.1:19090/backends/$BACKEND_ID/settings/minio" curl -X DELETE "http://127.0.0.1:19090/backends/$BACKEND_ID/settings/minio"
``` ```
## 13. 确认实例已创建 ## 14. 确认实例已创建
```bash ```bash
cd "$PROJECT_ROOT/app-instance" cd "$PROJECT_ROOT/app-instance"
@ -590,7 +656,22 @@ docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
- `public_url` - `public_url`
- `instance_host` - `instance_host`
## 14. 只看 auth-portal 页面 确认新实例拿到了连接器环境变量:
```bash
INSTANCE_CONTAINER='<app-instance-container-name>'
docker inspect "$INSTANCE_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| egrep '^(EXTERNAL_CONNECTOR_BASE_URL|EXTERNAL_CONNECTOR_TOKEN|EXTERNAL_CONNECTOR_CALLBACK_BASE_URL|BEAVER_BRIDGE_TOKEN)='
```
其中 `EXTERNAL_CONNECTOR_CALLBACK_BASE_URL` 应该指向这个实例自己的容器名,例如:
```text
http://app-instance-alice:8080
```
## 15. 只看 auth-portal 页面
如果只想看 Portal 页面,不跑全链路: 如果只想看 Portal 页面,不跑全链路:
@ -608,7 +689,7 @@ http://127.0.0.1:3081
注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service``deploy-control` 注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service``deploy-control`
## 15. 常用排错命令 ## 16. 常用排错命令
```bash ```bash
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
@ -617,10 +698,13 @@ docker logs --tail=100 beaver-authz-service
docker logs --tail=100 beaver-deploy-control docker logs --tail=100 beaver-deploy-control
docker logs --tail=100 beaver-auth-portal docker logs --tail=100 beaver-auth-portal
docker logs --tail=100 beaver-router-proxy docker logs --tail=100 beaver-router-proxy
docker logs --tail=100 external-connector
curl http://127.0.0.1:19090/healthz curl http://127.0.0.1:19090/healthz
curl http://127.0.0.1:8090/healthz curl http://127.0.0.1:8090/healthz
curl -I http://127.0.0.1:3081 curl -I http://127.0.0.1:3081
curl -sS -H "Authorization: Bearer $EXTERNAL_CONNECTOR_TOKEN" \
"http://127.0.0.1:${EXTERNAL_CONNECTOR_PORT}/connectors"
``` ```
实例创建失败时再看: 实例创建失败时再看:
@ -639,11 +723,14 @@ docker inspect beaver-authz-service --format '{{range .Config.Env}}{{println .}}
docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' \ docker inspect beaver-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' \
| egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)=' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)='
docker inspect beaver-deploy-control --format '{{range .Config.Env}}{{println .}}{{end}}' \
| egrep '^(DEFAULT_EXTERNAL_CONNECTOR_BASE_URL|DEFAULT_EXTERNAL_CONNECTOR_TOKEN|DEFAULT_BEAVER_BRIDGE_TOKEN|DEFAULT_INITIAL_SKILLS_DIR)='
``` ```
它们都必须是完整 URL不能是空字符串也不能是裸 `host:port` 它们都必须是完整 URL不能是空字符串也不能是裸 `host:port`
## 16. 常见问题 ## 17. 常见问题
### 注册页报 URL 缺少协议 ### 注册页报 URL 缺少协议
@ -727,23 +814,60 @@ getent hosts alice.localhost
- `8088` - `8088`
- `8090` - `8090`
- `19090` - `19090`
- `8787`(如果启用了 `external-connector`
检查: 检查:
```bash ```bash
ss -ltnp | grep -E '3081|8088|8090|19090' ss -ltnp | grep -E '3081|8088|8090|19090|8787'
``` ```
## 17. 重新部署基础容器 ### 连接器 sidecar 返回 401
只重建基础四个容器 检查 `docker-compose.external-connectors.yml` 里 sidecar 使用的是 `CONNECTOR_API_TOKEN`,主部署变量名是 `EXTERNAL_CONNECTOR_TOKEN`
```bash
docker inspect external-connector --format '{{range .Config.Env}}{{println .}}{{end}}' \
| egrep '^(CONNECTOR_API_TOKEN|BEAVER_BRIDGE_TOKEN|CONNECTOR_PROVIDER)='
```
请求 sidecar 管理 API 时必须使用:
```bash
curl -H "Authorization: Bearer $EXTERNAL_CONNECTOR_TOKEN" \
"http://127.0.0.1:${EXTERNAL_CONNECTOR_PORT}/connectors"
```
如果改过 token需要重启 `external-connector``beaver-deploy-control`,并重新创建或重建目标 app-instance。
### 微信或飞书连接成功但消息回不到实例
优先检查目标 app-instance 的回调地址:
```bash
docker inspect "$INSTANCE_CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep '^EXTERNAL_CONNECTOR_CALLBACK_BASE_URL='
```
多实例部署里它必须指向当前实例自己的容器名,例如:
```text
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://app-instance-alice:8080
```
如果它为空,通常是实例创建时没有传 `--network "$BEAVER_NET"`,或者旧实例是在连接器变量加入前创建的。重新创建实例,或用同样的实例数据目录手工重建容器。
## 18. 重新部署基础容器
只重建基础容器和可选 sidecar
```bash ```bash
docker rm -f \ docker rm -f \
beaver-auth-portal \ beaver-auth-portal \
beaver-authz-service \ beaver-authz-service \
beaver-deploy-control \ beaver-deploy-control \
beaver-router-proxy 2>/dev/null || true beaver-router-proxy \
external-connector 2>/dev/null || true
``` ```
这不会自动删除实例数据。如果你还需要旧账号、旧实例或模型配置,不要删除 `runtime/` 目录。 这不会自动删除实例数据。如果你还需要旧账号、旧实例或模型配置,不要删除 `runtime/` 目录。