Files
beaver_project/app-instance/create-instance.sh
steven_li ebfa242862 feat(outlook): 添加Outlook集成功能支持
添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理,
支持邮箱连接、断开、状态检查和数据同步等功能。

fix(config): 统一配置文件路径从.nanobot到.beaver

将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义,
确保所有组件使用一致的配置目录结构。

feat(agent): 添加代理删除功能和助手身份提示

为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示,
确保AI助手在交互中保持一致的身份认知。

feat(engine): 增强引擎循环并添加意图决策快照

扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录
决策快照,便于后续分析和调试。

feat(authz): 扩展认证客户端功能

为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统
的认证和授权能力。
2026-05-14 16:01:46 +08:00

604 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
IMAGE_NAME="${IMAGE_NAME:-nano/app-instance:latest}"
INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances"
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine "
INSTANCE_ID=""
INSTANCE_SLUG=""
CONTAINER_NAME=""
HOST_PORT=""
PUBLIC_URL=""
AUTHZ_BASE_URL=""
AUTHZ_OUTLOOK_MCP_URL=""
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
BACKEND_ID=""
CLIENT_ID=""
CLIENT_SECRET=""
BACKEND_NAME=""
MODEL="openai/gpt-5"
PROVIDER="openai"
API_KEY="${API_KEY:-}"
API_BASE="${API_BASE:-}"
AUTH_USERNAME=""
AUTH_PASSWORD=""
USERNAME=""
EMAIL=""
INSTANCE_HOST=""
AUTH_PORTAL_URL="${AUTH_PORTAL_URL:-}"
AUTH_PORTAL_PORT="${AUTH_PORTAL_PORT:-3081}"
INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
NETWORK_NAME="${NETWORK_NAME:-}"
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
FORCE_BUILD=0
REPLACE=0
usage() {
cat <<'EOF'
Usage:
./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 --api-key sk-xxx [options]
Required:
--instance-id <id> Unique instance id.
--auth-username <name> Initial web login username.
--auth-password <password> Initial web login password.
--api-key <key> Provider API key for Boardware Genius.
Optional:
--image <name> Docker image tag. Default: nano/app-instance:latest
--container-name <name> Docker container name. Default: app-instance-<slug>
--host-port <port> Host port to publish. Default: auto-pick from 20000-29999.
--public-url <url> Public URL exposed to users. Default: http://127.0.0.1:<host-port>
--provider <name> Provider key in config.json. Default: openai
--api-base <url> Optional custom provider base URL.
--model <name> Model name. Default: openai/gpt-5
--authz-base-url <url> AuthZ service base URL.
--authz-outlook-mcp-url <url>
Managed Outlook MCP URL for AuthZ mode.
--outlook-mcp-server-id <id>
Default Outlook MCP server id. Default: outlook_mcp
--backend-id <id> Pre-assigned backend id.
--client-id <id> Pre-assigned AuthZ client id.
--client-secret <secret> Pre-assigned AuthZ client secret.
--backend-name <name> Display name in backend identity.
--auth-portal-url <url> Shared auth portal URL used when building the image.
--auth-portal-port <port> Fallback auth portal port. Default: 3081
--username <name> Registry username owner. Default: auth username
--email <value> Registry email owner.
--instance-host <host> Public host used by reverse proxy, for registry only.
--instances-root <path> Instance data root. Default: ./runtime/instances
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
--network <name> Optional docker network name.
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
--build Force rebuild image before running.
--replace Remove existing container with same name before running.
--help Show this help.
EOF
}
log() {
printf '[create-instance] %s\n' "$*"
}
die() {
printf '[create-instance] %s\n' "$*" >&2
exit 1
}
slugify() {
local input="$1"
local output
output="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')"
if [[ -z "$output" ]]; then
die "instance id produced an empty slug"
fi
printf '%s' "$output"
}
pick_free_port() {
local args=(
--registry "$REGISTRY_PATH"
next-port
--start 20000
--end 29999
)
if [[ -n "$INSTANCE_ID" ]]; then
args+=(--exclude-instance-id "$INSTANCE_ID")
fi
"$REGISTRY_TOOL" "${args[@]}"
}
extract_url_host() {
local input_url="$1"
INPUT_URL="$input_url" python3 - <<'PY'
import os
from urllib.parse import urlsplit
value = os.environ["INPUT_URL"].strip()
parts = urlsplit(value)
print(parts.hostname or "")
PY
}
render_config_json() {
local target_path="$1"
TARGET_PATH="$target_path" \
MODEL="$MODEL" \
PROVIDER="$PROVIDER" \
API_KEY="$API_KEY" \
API_BASE="$API_BASE" \
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
BACKEND_ID="$BACKEND_ID" \
CLIENT_ID="$CLIENT_ID" \
CLIENT_SECRET="$CLIENT_SECRET" \
BACKEND_NAME="$BACKEND_NAME" \
PUBLIC_URL="$PUBLIC_URL" \
python3 - <<'PY'
import json
import os
from pathlib import Path
target = Path(os.environ["TARGET_PATH"])
provider = os.environ["PROVIDER"]
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
provider_cfg = {"apiKey": os.environ["API_KEY"]}
api_base = os.environ["API_BASE"].strip()
if api_base:
provider_cfg["apiBase"] = api_base
outlook_tool_names = [
"auth_status",
"mail_list_folders",
"mail_list_messages",
"mail_search_messages",
"mail_get_message",
"mail_send_email",
"mail_reply_to_message",
"mail_forward_message",
"mail_move_message",
"mail_delta_sync",
"calendar_list_events",
"calendar_create_event",
"calendar_update_event",
"calendar_get_schedule",
"calendar_find_meeting_times",
"calendar_delta_sync",
]
default_mcp_servers = {}
if outlook_mcp_url:
default_mcp_servers[outlook_server_id] = {
"command": "",
"args": [],
"env": {},
"url": outlook_mcp_url,
"headers": {},
"authMode": "oauth_backend_token",
"authAudience": f"mcp:{outlook_server_id}",
"authScopes": ["list_tools", *[f"tool:{name}" for name in outlook_tool_names]],
"toolTimeout": 60,
"sensitive": True,
}
data = {
"agents": {
"defaults": {
"workspace": "/root/.beaver/workspace",
"model": os.environ["MODEL"],
}
},
"providers": {
provider: provider_cfg,
},
"tools": {
"restrictToWorkspace": True,
"mcpServers": default_mcp_servers,
},
"authz": {
"enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()),
"baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090",
"requestTimeoutSeconds": 10,
"outlookMcpUrl": outlook_mcp_url,
},
"backend_identity": {
"backendId": os.environ["BACKEND_ID"].strip(),
"clientId": os.environ["CLIENT_ID"].strip(),
"clientSecret": os.environ["CLIENT_SECRET"].strip(),
"name": os.environ["BACKEND_NAME"].strip(),
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
},
}
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
}
render_auth_users_json() {
local target_path="$1"
TARGET_PATH="$target_path" \
AUTH_USERNAME="$AUTH_USERNAME" \
AUTH_PASSWORD="$AUTH_PASSWORD" \
python3 - <<'PY'
import json
import os
from pathlib import Path
target = Path(os.environ["TARGET_PATH"])
data = {
"users": [
{
"username": os.environ["AUTH_USERNAME"],
"password": os.environ["AUTH_PASSWORD"],
}
]
}
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
}
render_runtime_env_file() {
local target_path="$1"
TARGET_PATH="$target_path" \
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
BACKEND_ID="$BACKEND_ID" \
CLIENT_ID="$CLIENT_ID" \
CLIENT_SECRET="$CLIENT_SECRET" \
BACKEND_NAME="$BACKEND_NAME" \
PUBLIC_URL="$PUBLIC_URL" \
python3 - <<'PY'
import os
import shlex
from pathlib import Path
target = Path(os.environ["TARGET_PATH"])
values = {
"BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
"BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
"BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
"BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
"BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(),
"BEAVER_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(),
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(),
}
ordered_keys = [
"BEAVER_AUTHZ__ENABLED",
"BEAVER_AUTHZ__BASE_URL",
"BEAVER_AUTHZ__OUTLOOK_MCP_URL",
"BEAVER_BACKEND_IDENTITY__BACKEND_ID",
"BEAVER_BACKEND_IDENTITY__CLIENT_ID",
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET",
"BEAVER_BACKEND_IDENTITY__NAME",
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL",
]
lines: list[str] = []
for key in ordered_keys:
value = values.get(key, "")
if value:
lines.append(f"export {key}={shlex.quote(value)}")
continue
if key == "BEAVER_AUTHZ__ENABLED":
lines.append("export BEAVER_AUTHZ__ENABLED=0")
else:
lines.append(f"unset {key}")
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
}
image_exists() {
docker image inspect "$IMAGE_NAME" >/dev/null 2>&1
}
container_exists() {
docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--instance-id)
INSTANCE_ID="${2:-}"
shift 2
;;
--image)
IMAGE_NAME="${2:-}"
shift 2
;;
--container-name)
CONTAINER_NAME="${2:-}"
shift 2
;;
--host-port)
HOST_PORT="${2:-}"
shift 2
;;
--public-url)
PUBLIC_URL="${2:-}"
shift 2
;;
--provider)
PROVIDER="${2:-}"
shift 2
;;
--api-key)
API_KEY="${2:-}"
shift 2
;;
--api-base)
API_BASE="${2:-}"
shift 2
;;
--model)
MODEL="${2:-}"
shift 2
;;
--auth-username)
AUTH_USERNAME="${2:-}"
shift 2
;;
--auth-password)
AUTH_PASSWORD="${2:-}"
shift 2
;;
--username)
USERNAME="${2:-}"
shift 2
;;
--email)
EMAIL="${2:-}"
shift 2
;;
--instance-host)
INSTANCE_HOST="${2:-}"
shift 2
;;
--authz-base-url)
AUTHZ_BASE_URL="${2:-}"
shift 2
;;
--authz-outlook-mcp-url)
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
shift 2
;;
--outlook-mcp-server-id)
OUTLOOK_MCP_SERVER_ID="${2:-}"
shift 2
;;
--backend-id)
BACKEND_ID="${2:-}"
shift 2
;;
--client-id)
CLIENT_ID="${2:-}"
shift 2
;;
--client-secret)
CLIENT_SECRET="${2:-}"
shift 2
;;
--backend-name)
BACKEND_NAME="${2:-}"
shift 2
;;
--auth-portal-url)
AUTH_PORTAL_URL="${2:-}"
shift 2
;;
--auth-portal-port)
AUTH_PORTAL_PORT="${2:-}"
shift 2
;;
--instances-root)
INSTANCES_ROOT="${2:-}"
shift 2
;;
--registry)
REGISTRY_PATH="${2:-}"
shift 2
;;
--network)
NETWORK_NAME="${2:-}"
shift 2
;;
--host-bind-ip)
HOST_BIND_IP="${2:-}"
shift 2
;;
--build)
FORCE_BUILD=1
shift
;;
--replace)
REPLACE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required"
[[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required"
[[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required"
[[ -n "$API_KEY" ]] || die "--api-key is required"
INSTANCE_SLUG="$(slugify "$INSTANCE_ID")"
USERNAME="${USERNAME:-$AUTH_USERNAME}"
EXISTING_RECORD_JSON=""
EXISTING_CONTAINER_NAME=""
EXISTING_HOST_PORT=""
if EXISTING_RECORD_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --instance-id "$INSTANCE_ID" 2>/dev/null)"; then
EXISTING_CONTAINER_NAME="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["container_name"])')"
EXISTING_HOST_PORT="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["host_port"])')"
if [[ "$REPLACE" -ne 1 ]]; then
die "instance already exists in registry: ${INSTANCE_ID} (use --replace to recreate)"
fi
fi
CONTAINER_NAME="${CONTAINER_NAME:-${EXISTING_CONTAINER_NAME:-app-instance-${INSTANCE_SLUG}}}"
BACKEND_NAME="${BACKEND_NAME:-${INSTANCE_ID}}"
if [[ -z "$HOST_PORT" ]]; then
HOST_PORT="${EXISTING_HOST_PORT:-$(pick_free_port)}"
fi
if [[ -z "$PUBLIC_URL" ]]; then
PUBLIC_URL="http://127.0.0.1:${HOST_PORT}"
fi
if [[ -z "$INSTANCE_HOST" ]]; then
INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")"
fi
case "$KNOWN_PROVIDERS" in
*" ${PROVIDER} "*) ;;
*) die "unsupported provider '${PROVIDER}'" ;;
esac
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
fi
if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --container-name "$CONTAINER_NAME" 2>/dev/null)"; then
REGISTERED_ID="$(printf '%s' "$PORT_HOLDER_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_id"])')"
if [[ "$REGISTERED_ID" != "$INSTANCE_ID" && "$REPLACE" -ne 1 ]]; then
die "container name already registered for another instance: ${CONTAINER_NAME}"
fi
fi
if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" list --json)"; then
PORT_CONFLICT_ID="$(PORT_HOLDER_JSON="$PORT_HOLDER_JSON" python3 - "$HOST_PORT" "$INSTANCE_ID" <<'PY'
import json
import os
import sys
port = int(sys.argv[1])
instance_id = sys.argv[2]
for item in json.loads(os.environ["PORT_HOLDER_JSON"]).get("instances", []):
if int(item.get("host_port", 0) or 0) == port and item.get("instance_id") != instance_id:
print(item.get("instance_id", ""))
break
PY
)"
if [[ -n "$PORT_CONFLICT_ID" ]]; then
die "host port already registered by another instance: ${HOST_PORT} (${PORT_CONFLICT_ID})"
fi
fi
INSTANCE_ROOT="${INSTANCES_ROOT}/${INSTANCE_SLUG}"
BEAVER_HOME="${INSTANCE_ROOT}/beaver-home"
CONFIG_PATH="${BEAVER_HOME}/config.json"
AUTH_USERS_PATH="${BEAVER_HOME}/web_auth_users.json"
RUNTIME_ENV_PATH="${BEAVER_HOME}/runtime.env"
WORKSPACE_PATH="${BEAVER_HOME}/workspace"
mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
render_config_json "$CONFIG_PATH"
render_auth_users_json "$AUTH_USERS_PATH"
render_runtime_env_file "$RUNTIME_ENV_PATH"
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
log "building image ${IMAGE_NAME}"
docker build \
--build-arg "NEXT_PUBLIC_AUTH_PORTAL_URL=${AUTH_PORTAL_URL}" \
--build-arg "NEXT_PUBLIC_AUTH_PORTAL_PORT=${AUTH_PORTAL_PORT}" \
-t "$IMAGE_NAME" \
"$SCRIPT_DIR"
fi
if container_exists; then
if [[ "$REPLACE" -eq 1 ]]; then
log "removing existing container ${CONTAINER_NAME}"
docker rm -f "$CONTAINER_NAME" >/dev/null
else
die "container already exists: ${CONTAINER_NAME} (use --replace to recreate)"
fi
fi
RUN_ARGS=(
-d
--name "$CONTAINER_NAME"
--restart unless-stopped
-p "${HOST_BIND_IP}:${HOST_PORT}:8080"
-v "${BEAVER_HOME}:/root/.beaver"
-e "BEAVER_HOME=/root/.beaver"
-e "BEAVER_CONFIG_PATH=/root/.beaver/config.json"
-e "BEAVER_WORKSPACE=/root/.beaver/workspace"
-e "BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json"
-e "BEAVER_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}"
-e "APP_PUBLIC_PORT=8080"
-e "APP_FRONTEND_PORT=3000"
-e "APP_BACKEND_PORT=18080"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
--label "nano.instance.id=${INSTANCE_ID}"
--label "nano.instance.slug=${INSTANCE_SLUG}"
--label "nano.instance.public_url=${PUBLIC_URL}"
)
if [[ -n "$NETWORK_NAME" ]]; then
RUN_ARGS+=(--network "$NETWORK_NAME")
fi
log "starting container ${CONTAINER_NAME}"
docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null
"$REGISTRY_TOOL" --registry "$REGISTRY_PATH" upsert \
--instance-id "$INSTANCE_ID" \
--instance-slug "$INSTANCE_SLUG" \
--container-name "$CONTAINER_NAME" \
--image-name "$IMAGE_NAME" \
--host-port "$HOST_PORT" \
--public-url "$PUBLIC_URL" \
--instance-root "$INSTANCE_ROOT" \
--beaver-home "$BEAVER_HOME" \
--config-path "$CONFIG_PATH" \
--auth-users-path "$AUTH_USERS_PATH" \
--network-name "$NETWORK_NAME" \
--backend-id "$BACKEND_ID" \
--backend-name "$BACKEND_NAME" \
--authz-base-url "$AUTHZ_BASE_URL" \
--username "$USERNAME" \
--email "$EMAIL" \
--instance-host "$INSTANCE_HOST" \
--frontend-base-url "$PUBLIC_URL" \
--api-base-url "$PUBLIC_URL" \
--created-at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >/dev/null
cat <<EOF
instance_id=${INSTANCE_ID}
instance_slug=${INSTANCE_SLUG}
container_name=${CONTAINER_NAME}
image_name=${IMAGE_NAME}
host_port=${HOST_PORT}
public_url=${PUBLIC_URL}
instance_root=${INSTANCE_ROOT}
beaver_home=${BEAVER_HOME}
config_path=${CONFIG_PATH}
auth_users_path=${AUTH_USERS_PATH}
runtime_env_path=${RUNTIME_ENV_PATH}
username=${USERNAME}
email=${EMAIL}
instance_host=${INSTANCE_HOST}
host_bind_ip=${HOST_BIND_IP}
EOF