diff --git a/app-instance/README.md b/app-instance/README.md index 6034e77..36c844d 100644 --- a/app-instance/README.md +++ b/app-instance/README.md @@ -148,6 +148,12 @@ BEAVER_WORKSPACE=/root/.beaver/workspace - 实例容器的宿主机端口默认只绑定 `127.0.0.1` - 外部访问应统一走 `router-proxy` - 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0` +- 使用共享 `external-connector` sidecar 时,每个实例容器都必须带自己的内部回调地址: + `EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://:8080` +- 通过 `create-instance.sh --network ` 创建实例时,脚本会默认使用 + `http://:8080` 作为回调地址;生产部署也可以用 + `--external-connector-callback-base-url ` 显式覆盖 +- `BEAVER_BRIDGE_BASE_URL` 只作为 sidecar 的旧连接或兜底地址;多实例部署不能依赖它路由所有入站事件 下一步可以继续接: diff --git a/app-instance/backend/beaver/coordinator/registry/store.py b/app-instance/backend/beaver/coordinator/registry/store.py index 7e151db..f51ca76 100644 --- a/app-instance/backend/beaver/coordinator/registry/store.py +++ b/app-instance/backend/beaver/coordinator/registry/store.py @@ -15,7 +15,9 @@ class AgentRegistry: self.path = self.workspace / "agents" / "registry.json" self.path.parent.mkdir(parents=True, exist_ok=True) 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]: agents = self._read_agents() @@ -125,72 +127,14 @@ class AgentRegistry: 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") + 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]: normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text) 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", - ), - ] diff --git a/app-instance/backend/beaver/interfaces/channels/connections/external.py b/app-instance/backend/beaver/interfaces/channels/connections/external.py index 2a68f15..327cd4a 100644 --- a/app-instance/backend/beaver/interfaces/channels/connections/external.py +++ b/app-instance/backend/beaver/interfaces/channels/connections/external.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import Any from .models import ChannelRuntimeSpec, ValidationResult @@ -37,6 +38,7 @@ class ExternalConnectorBase: self.credential_store = credential_store self.sidecar_client = sidecar_client self.sidecar_base_url = sidecar_base_url + self.callback_base_url = _callback_base_url() async def start_session( self, @@ -63,7 +65,7 @@ class ExternalConnectorBase: "connectionId": connection.connection_id, "channelId": connection.channel_id, "displayName": connection.display_name, - "callbackBaseUrl": "", + "callbackBaseUrl": self.callback_base_url, "options": dict(options), } 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 +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]: if isinstance(value, str): raw_items = value.replace("\n", ",").split(",") diff --git a/app-instance/backend/tests/unit/test_agent_registry_resolver.py b/app-instance/backend/tests/unit/test_agent_registry_resolver.py index ae2c368..df06c4f 100644 --- a/app-instance/backend/tests/unit/test_agent_registry_resolver.py +++ b/app-instance/backend/tests/unit/test_agent_registry_resolver.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode from beaver.coordinator.registry import AgentRegistry, RegisteredAgent, TargetResolver 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) - assert {agent.agent_id for agent in registry.list_active_agents()} >= { - "researcher", - "implementer", - "reviewer", - "tester", - "documenter", - } + assert registry.list_agents() == [] + registry.upsert_agent( + RegisteredAgent( + agent_id="tester", + name="tester", + display_name="Tester", + role="testing", + description="Runs checks.", + system_prompt="test", + ) + ) registry.disable_agent("tester") 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: registry = AgentRegistry(tmp_path) 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.metadata["resolution"] == "fallback_ephemeral" assert reports[0].fallback_used is True - diff --git a/app-instance/backend/tests/unit/test_external_sidecar_connectors.py b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py index 40b4248..5f3bc9e 100644 --- a/app-instance/backend/tests/unit/test_external_sidecar_connectors.py +++ b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py @@ -59,8 +59,9 @@ class ImmediateConnectedSidecarClient(FakeSidecarClient): 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: + monkeypatch.setenv("EXTERNAL_CONNECTOR_CALLBACK_BASE_URL", "http://app-instance-jaychen:8080") connection_store = ChannelConnectionStore(tmp_path / "connections.json") credential_store = CredentialStore(tmp_path / "credentials.json") client = FakeSidecarClient() @@ -77,6 +78,7 @@ def test_weixin_connector_starts_connector_session(tmp_path) -> None: assert view["connectionId"].startswith("conn_") assert client.started[0]["kind"] == "weixin" 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].status == "pairing" diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index 0bba41c..9ec5c99 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -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_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}" BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}" +EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL:-}" BACKEND_ID="" CLIENT_ID="" CLIENT_SECRET="" @@ -81,6 +82,9 @@ Optional: External connector sidecar URL. Default: http://external-connector:8787 --external-connector-token Service token used for Beaver-to-sidecar requests. + --external-connector-callback-base-url + Internal URL the sidecar should call back for inbound events. + Default: http://:8080 when a Docker network is used. --bridge-token Service token accepted from the connector bridge. --backend-id Pre-assigned backend id. --client-id Pre-assigned AuthZ client id. @@ -565,6 +569,10 @@ while [[ $# -gt 0 ]]; do EXTERNAL_CONNECTOR_TOKEN="${2:-}" shift 2 ;; + --external-connector-callback-base-url) + EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${2:-}" + shift 2 + ;; --bridge-token) BEAVER_BRIDGE_TOKEN="${2:-}" shift 2 @@ -772,6 +780,10 @@ RUN_ARGS=( --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 RUN_ARGS+=( -v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro" @@ -786,6 +798,9 @@ fi if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}") 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 RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}") fi diff --git a/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md b/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md index 27fbb7d..f7431e5 100644 --- a/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md +++ b/docs/superpowers/plans/2026-06-03-external-connector-frontend-deploy.md @@ -698,9 +698,11 @@ For `terminaltest`, ensure the app container has: EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787 EXTERNAL_CONNECTOR_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. +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** diff --git a/external-connector/external_connector/providers/feishu_bot.py b/external-connector/external_connector/providers/feishu_bot.py index 40b713f..0534d8b 100644 --- a/external-connector/external_connector/providers/feishu_bot.py +++ b/external-connector/external_connector/providers/feishu_bot.py @@ -83,6 +83,7 @@ class FeishuBotProvider: metadata = { "eventCallbackPath": "/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_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip() @@ -212,7 +213,7 @@ class FeishuBotProvider: if bridge_event is None: return {"ok": True, "ignored": "empty_or_oversized"} 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, {"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_MAX_MESSAGES": str(_positive_int(metadata.get("textBatchMaxMessages"), default=10)), "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, } ) 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: message = dict(event.get("message") or {}) sender = dict(event.get("sender") or {}) diff --git a/external-connector/external_connector/providers/weixin_ilink.py b/external-connector/external_connector/providers/weixin_ilink.py index 5cf1a54..4a22220 100644 --- a/external-connector/external_connector/providers/weixin_ilink.py +++ b/external-connector/external_connector/providers/weixin_ilink.py @@ -246,7 +246,7 @@ class WeixinIlinkProvider: flush=True, ) 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, {"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('/')}" +def _bridge_base_url(session: ConnectorSessionState, fallback: str) -> str: + return str(session.metadata.get("bridgeBaseUrl") or fallback).rstrip("/") + + def _base_info() -> dict[str, str]: return {"channel_version": "2.4.3", "bot_agent": "Beaver/1.0"} diff --git a/external-connector/tests/test_feishu_bot_provider.py b/external-connector/tests/test_feishu_bot_provider.py index c1255bd..07b85d8 100644 --- a/external-connector/tests/test_feishu_bot_provider.py +++ b/external-connector/tests/test_feishu_bot_provider.py @@ -384,6 +384,44 @@ def test_feishu_event_route_forwards_message_to_bridge(tmp_path) -> None: 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: bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = [] provider = _provider(tmp_path, bridge_posts=bridge_posts) diff --git a/external-connector/tests/test_weixin_ilink_provider.py b/external-connector/tests/test_weixin_ilink_provider.py index 5f010d8..f58c790 100644 --- a/external-connector/tests/test_weixin_ilink_provider.py +++ b/external-connector/tests/test_weixin_ilink_provider.py @@ -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]["content"] == "hello" 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" diff --git a/部署指南.md b/部署指南.md index a7ecee3..7bad63f 100644 --- a/部署指南.md +++ b/部署指南.md @@ -6,6 +6,7 @@ - `authz-service` - `deploy-control` - `router-proxy` +- 可选的 `external-connector` sidecar - 自动创建出来的 `app-instance` 目标结果: @@ -35,6 +36,7 @@ ```bash docker --version +docker compose version docker ps python3 --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_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_OUTLOOK_MCP_URL` | 可选 Outlook MCP HTTP 地址 | | `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 变量改成正式服务即可: @@ -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/deploy-control:latest deploy-control 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 ``` -## 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://:8080 +``` + +sidecar 会按每个连接 session 保存这个回调地址,入站消息才能回到正确的用户实例。 + +## 8. 启动 MinIO MinIO 是用户文件系统的后端实现细节。用户和前端不会看到 bucket、access key 或 prefix;Beaver 只通过 `/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 对超大文件的读取/处理能力仍需要按具体任务另行验证。 -## 8. 启动 authz-service +## 9. 启动 authz-service ```bash 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_)=' ``` -## 9. 启动 deploy-control +## 10. 启动 deploy-control `deploy-control` 会挂载 Docker socket,再创建新的 `app-instance` 容器。这里最容易错的是路径挂载: @@ -353,8 +407,10 @@ docker run -d \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \ -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 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_NETWORK_NAME="$BEAVER_NET" \ -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_OUTLOOK_MCP_SERVER_ID="$BEAVER_OUTLOOK_MCP_SERVER_ID" \ -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_BASE_DOMAIN="$BEAVER_BASE_DOMAIN" \ -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。它不会传给前端。 -## 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 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)=' ``` -## 11. 健康检查 +## 12. 健康检查 ```bash curl http://127.0.0.1:19090/healthz curl http://127.0.0.1:8090/healthz curl -I http://127.0.0.1:3081 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 logs --tail=50 beaver-router-proxy ``` @@ -419,8 +484,9 @@ docker logs --tail=50 beaver-router-proxy - `beaver-deploy-control` - `beaver-auth-portal` - `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" ``` -## 13. 确认实例已创建 +## 14. 确认实例已创建 ```bash cd "$PROJECT_ROOT/app-instance" @@ -590,7 +656,22 @@ docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance - `public_url` - `instance_host` -## 14. 只看 auth-portal 页面 +确认新实例拿到了连接器环境变量: + +```bash +INSTANCE_CONTAINER='' + +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 页面,不跑全链路: @@ -608,7 +689,7 @@ http://127.0.0.1:3081 注意:这只能看页面。注册、登录、创建实例仍依赖 `authz-service` 和 `deploy-control`。 -## 15. 常用排错命令 +## 16. 常用排错命令 ```bash 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-auth-portal 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:8090/healthz 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}}' \ | 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`。 -## 16. 常见问题 +## 17. 常见问题 ### 注册页报 URL 缺少协议 @@ -727,23 +814,60 @@ getent hosts alice.localhost - `8088` - `8090` - `19090` +- `8787`(如果启用了 `external-connector`) 检查: ```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 docker rm -f \ beaver-auth-portal \ beaver-authz-service \ beaver-deploy-control \ - beaver-router-proxy 2>/dev/null || true + beaver-router-proxy \ + external-connector 2>/dev/null || true ``` 这不会自动删除实例数据。如果你还需要旧账号、旧实例或模型配置,不要删除 `runtime/` 目录。