#!/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:-beaver/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_INTERNAL_TOKEN="" AUTHZ_OUTLOOK_MCP_URL="" OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}" OUTLOOK_MCP_CALL_TIMEOUT_SECONDS="${OUTLOOK_MCP_CALL_TIMEOUT_SECONDS:-60}" 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="" BACKEND_NAME="" MODEL="openai/gpt-5" PROVIDER="openai" API_KEY="${API_KEY:-}" API_BASE="${API_BASE:-}" SKIP_PROVIDER_CONFIG=0 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}" INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}" INITIAL_SKILLS_EXCLUDE="${INITIAL_SKILLS_EXCLUDE:-officebench-mcp}" SEED_INITIAL_SKILLS=1 FORCE_BUILD=0 REPLACE=0 usage() { cat <<'EOF' Usage: ./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 [options] Required: --instance-id Unique instance id. --auth-username Initial web login username. --auth-password Initial web login password. Optional: --image Docker image tag. Default: beaver/app-instance:latest --container-name Docker container name. Default: app-instance- --host-port Host port to publish. Default: auto-pick from 20000-29999. --public-url Public URL exposed to users. Default: http://127.0.0.1: --provider Provider key in config.json. Default: openai --api-base Optional custom provider base URL. --api-key Provider API key for Boardware Genius. --model Model name. Default: openai/gpt-5 --skip-provider-config Create the instance without model/provider/API key settings. --authz-base-url AuthZ service base URL. --authz-internal-token AuthZ internal token for backend-only user file storage settings lookup. --authz-outlook-mcp-url Managed Outlook MCP URL for AuthZ mode. --outlook-mcp-server-id Default Outlook MCP server id. Default: outlook_mcp --outlook-mcp-call-timeout-seconds Backend wait timeout for Outlook MCP calls. Default: 60 --user-files-max-upload-bytes Optional max upload size for the user file system. --external-connector-base-url 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. --client-secret Pre-assigned AuthZ client secret. --backend-name Display name in backend identity. --auth-portal-url Shared auth portal URL used when building the image. --auth-portal-port Fallback auth portal port. Default: 3081 --username Registry username owner. Default: auth username --email Registry email owner. --instance-host Public host used by reverse proxy, for registry only. --instances-root Instance data root. Default: ./runtime/instances --registry Registry JSON path. Default: ./runtime/registry/instances.json --network Optional docker network name. --host-bind-ip Host bind IP for published port. Default: 127.0.0.1 --initial-skills-dir Directory copied into workspace/skills on first create. Default: ../skills --skip-initial-skills Do not seed initial workspace skills. --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" \ SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \ AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \ AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \ 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" skip_provider_config = os.environ["SKIP_PROVIDER_CONFIG"].strip() == "1" providers = {} agent_defaults = { "workspace": "/root/.beaver/workspace", "maxToolIterations": 100, } if not skip_provider_config: provider_cfg = {"apiKey": os.environ["API_KEY"]} api_base = os.environ["API_BASE"].strip() if api_base: provider_cfg["apiBase"] = api_base providers[provider] = provider_cfg agent_defaults["provider"] = provider agent_defaults["model"] = os.environ["MODEL"] 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": agent_defaults }, "providers": providers, "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(), }, "channels": { "telegram-main": { "enabled": False, "kind": "telegram", "mode": "polling", "accountId": "bot-main", "displayName": "Telegram Main", "secrets": { "botToken": "", }, "config": { "requireMentionInGroups": True, "maxMessageChars": 4096, }, }, "feishu-main": { "enabled": False, "kind": "feishu", "mode": "websocket", "accountId": "tenant-main", "displayName": "Feishu Main", "secrets": { "appId": "", "appSecret": "", }, "config": { "domain": "feishu", "connectionMode": "websocket", "requireMentionInGroups": True, }, }, "qqbot-main": { "enabled": False, "kind": "qqbot", "mode": "websocket", "accountId": "qqbot-main", "displayName": "QQ Bot Main", "secrets": { "appId": "", "clientSecret": "", }, "config": { "dmPolicy": "open", "groupPolicy": "allowlist", "markdownSupport": False, }, }, "weixin-main": { "enabled": False, "kind": "weixin", "mode": "polling", "accountId": "wx-main", "displayName": "Weixin Main", "secrets": { "token": "", }, "config": { "dmPolicy": "open", "groupPolicy": "disabled", "textBatchDelaySeconds": 0.5, }, }, "terminal-dev": { "enabled": True, "kind": "terminal", "mode": "websocket", "accountId": "local", "displayName": "Terminal Dev", "config": {}, "secrets": {}, }, }, } 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 } seed_initial_skills() { local workspace_path="$1" local initial_skills_dir="$2" local target_dir="${workspace_path}/skills" if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then return fi if [[ ! -d "$initial_skills_dir" ]]; then log "initial skills directory not found, skipping: ${initial_skills_dir}" return fi mkdir -p "$target_dir" INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$INITIAL_SKILLS_EXCLUDE" python3 - <<'PY' import json import shutil import os from pathlib import Path initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve() target = Path(os.environ["TARGET_DIR"]).resolve() excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()} for child in sorted(initial.iterdir()): if child.name.startswith("."): continue if child.name in excluded: continue destination = target / child.name if destination.exists(): continue if child.is_dir(): shutil.copytree(child, destination) elif child.is_file(): shutil.copy2(child, destination) for index_name in ("published", "disabled"): initial_index = initial / "_index" / f"{index_name}.json" target_index = target / "_index" / f"{index_name}.json" if not initial_index.exists(): continue try: initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", []) except json.JSONDecodeError: initial_items = [] if target_index.exists(): try: target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", []) except json.JSONDecodeError: target_items = [] else: target_items = [] merged = [] for item in [*target_items, *initial_items]: text = str(item).strip() if text in excluded: continue if text and text not in merged: merged.append(text) target_index.parent.mkdir(parents=True, exist_ok=True) target_index.write_text(json.dumps({"items": merged}, 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_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \ 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_INTERNAL_TOKEN": os.environ["AUTHZ_INTERNAL_TOKEN"].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_INTERNAL_TOKEN", "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 ;; --skip-provider-config) SKIP_PROVIDER_CONFIG=1 shift ;; --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-internal-token) AUTHZ_INTERNAL_TOKEN="${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 ;; --outlook-mcp-call-timeout-seconds) OUTLOOK_MCP_CALL_TIMEOUT_SECONDS="${2:-}" shift 2 ;; --user-files-max-upload-bytes) USER_FILES_MAX_UPLOAD_BYTES="${2:-}" shift 2 ;; --external-connector-base-url) EXTERNAL_CONNECTOR_BASE_URL="${2:-}" shift 2 ;; --external-connector-token) 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 ;; --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 ;; --initial-skills-dir) INITIAL_SKILLS_DIR="${2:-}" shift 2 ;; --skip-initial-skills) SEED_INITIAL_SKILLS=0 shift ;; --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" if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then [[ -n "$API_KEY" ]] || die "--api-key is required unless --skip-provider-config is set" fi 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 if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then case "$KNOWN_PROVIDERS" in *" ${PROVIDER} "*) ;; *) die "unsupported provider '${PROVIDER}'" ;; esac fi if [[ -n "$INITIAL_SKILLS_DIR" ]]; then INITIAL_SKILLS_DIR="$(INITIAL_SKILLS_DIR="$INITIAL_SKILLS_DIR" python3 - <<'PY' import os from pathlib import Path print(Path(os.environ["INITIAL_SKILLS_DIR"]).expanduser().resolve()) PY )" fi 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" seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR" 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_ENABLE_SELF_RESTART=1" -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" -e "BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS=${OUTLOOK_MCP_CALL_TIMEOUT_SECONDS}" -e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}" --label "beaver.instance.id=${INSTANCE_ID}" --label "beaver.instance.slug=${INSTANCE_SLUG}" --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" -e "BEAVER_INITIAL_SKILLS_DIR=/opt/app/initial-skills" -e "BEAVER_INITIAL_SKILLS_EXCLUDE=${INITIAL_SKILLS_EXCLUDE}" ) fi if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}") 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 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 <