#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: ./scripts/start-stack.sh --mode local|docker [--no-build] [--no-node-build]
--mode local read .env, optionally install/build, and use data/envs
--mode docker use the prebuilt image environments and container checks
--no-build skip install/build work (implicit in docker mode)
--no-node-build skip only the Node install/build; still provision the Python
service environments, whose editable installs record absolute
paths and cannot be prepared elsewhere
Environment:
SCIENCE_DISCOVERY_HEALTH_TIMEOUT_SECONDS
seconds to wait for each service's health endpoint, replacing
the mode's default (10s local, 60s docker). Raise it on a slow
or emulated host.
EOF
}
mode=""
mode_seen=0
no_build=0
no_node_build=0
while [[ "$#" -gt 0 ]]; do
case "$1" in
--mode)
if [[ "$#" -lt 2 ]]; then
echo "--mode requires local or docker." >&2
exit 2
fi
if [[ "$mode_seen" -eq 1 ]]; then
echo "--mode may only be specified once." >&2
exit 2
fi
mode="$2"
mode_seen=1
shift 2
;;
--mode=*)
if [[ "$mode_seen" -eq 1 ]]; then
echo "--mode may only be specified once." >&2
exit 2
fi
mode="${1#--mode=}"
mode_seen=1
shift
;;
--no-node-build)
no_node_build=1
shift
;;
--no-build)
no_build=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "$mode" != "local" && "$mode" != "docker" ]]; then
echo "--mode must be local or docker." >&2
usage >&2
exit 2
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repository_root="$(cd -- "$script_dir/.." && pwd)"
cd "$repository_root"
pids=()
health_attempts=50
gateway_python=""
memory_graph_python=""
evolve_python=""
runner_url=""
data_dir=""
runner_command=()
api_command=()
api_foreground=0
cleanup() {
trap - EXIT INT TERM
for pid in "${pids[@]:-}"; do kill "$pid" 2>/dev/null || true; done
for pid in "${pids[@]:-}"; do wait "$pid" 2>/dev/null || true; done
}
wait_healthy() {
local attempts="$health_attempts"
if [[ -n "${SCIENCE_DISCOVERY_HEALTH_TIMEOUT_SECONDS:-}" ]]; then
attempts=$((SCIENCE_DISCOVERY_HEALTH_TIMEOUT_SECONDS * 5))
fi
local attempt
for ((attempt = 1; attempt <= attempts; attempt++)); do
if curl --silent --fail "$2" >/dev/null 2>&1; then return 0; fi
sleep 0.2
done
echo "$1 did not become healthy at $2 after $((attempts / 5))s." >&2
return 1
}
dependency_problems=()
note_dependency_problem() {
local entry="$1"
shift
local line
for line in "$@"; do entry+=$'\n'" $line"; done
dependency_problems+=("$entry")
}
require_command() {
command -v "$1" >/dev/null && return 0
shift
note_dependency_problem "$@"
}
report_dependency_problems() {
[[ "${#dependency_problems[@]}" -eq 0 ]] && return 0
{
echo "ScienceDiscovery cannot start from source: ${#dependency_problems[@]} host dependency problem(s)."
echo
local problem
for problem in "${dependency_problems[@]}"; do
echo " - $problem"
echo
done
echo " Requirements table: README.md#requirements"
echo " Local source mode: docs/en/how-to/deployment.md#local-mode-host-processes"
} >&2
exit 1
}
migrate_legacy_data_dir() {
local target="$1"
local legacy="$repository_root/data"
[[ -n "${SCIENCE_DISCOVERY_DATA_DIR:-}" ]] && return 0
[[ -e "$legacy" ]] || return 0
if [[ ! -d "$legacy" ]]; then
echo "[compat] Skipped moving legacy data directory $legacy to $target: source is not a directory." >&2
return 0
fi
if [[ -e "$target" ]]; then
echo "[compat] Skipped moving legacy data directory $legacy to $target: target already exists." >&2
return 0
fi
mv "$legacy" "$target"
echo "[compat] Moved legacy data directory $legacy to $target." >&2
}
absolute_from_repository() {
if [[ "$1" == /* ]]; then
printf '%s\n' "$1"
else
printf '%s/%s\n' "$repository_root" "$1"
fi
}
configure_endpoints() {
local runner_host="${SCIENCE_AGENT_RUNNER_HOST:-127.0.0.1}"
local runner_port="${SCIENCE_AGENT_RUNNER_PORT:-4311}"
runner_url="${SCIENCE_AGENT_RUNNER_URL:-http://$runner_host:$runner_port}"
}
uv_sync_project() {
local project_dir="$1" env_dir="$2" locked="$3"
if [[ -n "${SCIENCE_AGENT_PYPI_INDEX:-}" ]]; then
cp "$project_dir/uv.lock" "$project_dir/uv.lock.stack-backup"
local sync_failed=0
(cd "$project_dir" && UV_PROJECT_ENVIRONMENT="$env_dir" uv sync) || sync_failed=1
mv "$project_dir/uv.lock.stack-backup" "$project_dir/uv.lock"
[[ "$sync_failed" -eq 0 ]]
elif [[ "$locked" -eq 1 ]]; then
(cd "$project_dir" && UV_PROJECT_ENVIRONMENT="$env_dir" uv sync --locked)
else
(cd "$project_dir" && UV_PROJECT_ENVIRONMENT="$env_dir" uv sync)
fi
}
prepare_local() {
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
if [[ -n "${SCIENCE_DISCOVERY_DATA_DIR:-}" ]]; then
if [[ -n "${SCIENCE_AGENT_DATA_DIR:-}" ]]; then
echo "[compat] Both SCIENCE_DISCOVERY_DATA_DIR and SCIENCE_AGENT_DATA_DIR are set; SCIENCE_DISCOVERY_DATA_DIR takes precedence." >&2
fi
SCIENCE_AGENT_DATA_DIR="$SCIENCE_DISCOVERY_DATA_DIR"
export SCIENCE_AGENT_DATA_DIR
elif [[ -n "${SCIENCE_AGENT_DATA_DIR:-}" ]]; then
echo "[compat] SCIENCE_AGENT_DATA_DIR is deprecated; using its value as SCIENCE_DISCOVERY_DATA_DIR." >&2
SCIENCE_DISCOVERY_DATA_DIR="$SCIENCE_AGENT_DATA_DIR"
export SCIENCE_DISCOVERY_DATA_DIR
fi
require_command node \
"Node.js 22.19+ is required for the control API and the runner, but no 'node' is on PATH." \
"Install it with a version manager (nvm install 22) or your distribution's nodejs package."
if command -v node >/dev/null && ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)'; then
note_dependency_problem \
"Node.js 22.19+ is required; found $(node --version)." \
"Upgrade it, for example: nvm install 22 && nvm use 22"
fi
require_command pnpm \
"pnpm 11.1.2 is required to install and build the workspace, but no 'pnpm' is on PATH." \
"Enable it through the bundled corepack: corepack enable && corepack prepare pnpm@11.1.2 --activate" \
"Or install it globally: npm install -g pnpm@11.1.2"
require_command python3 \
"Python 3 is required for workspace analysis, but no 'python3' is on PATH." \
"Install your distribution's python3 package (Debian/Ubuntu: sudo apt-get install -y python3)."
require_command uv \
"uv 0.9+ is required to build the Python service environments, but no 'uv' is on PATH." \
"Install it: curl -LsSf https://astral.sh/uv/install.sh | sh" \
"It is needed only for source mode; the prepackaged binary fetches its own."
require_command curl \
"curl is required for the local service startup checks, but no 'curl' is on PATH." \
"Install your distribution's curl package (Debian/Ubuntu: sudo apt-get install -y curl)."
export SCIENCE_AGENT_PYTHON_PATH="${SCIENCE_AGENT_PYTHON_PATH:-$(command -v python3)}"
if [[ "$(uname -s)" == "Darwin" ]]; then
export SCIENCE_AGENT_SHELL_PATH="${SCIENCE_AGENT_SHELL_PATH:-/bin/bash}"
else
export SCIENCE_AGENT_SHELL_PATH="${SCIENCE_AGENT_SHELL_PATH:-/usr/bin/bash}"
fi
local sandbox_provider="${SCIENCE_AGENT_SANDBOX_PROVIDER:-auto}"
if [[ "$sandbox_provider" == "auto" ]]; then
case "$(uname -s)" in
Darwin) sandbox_provider="seatbelt" ;;
Linux) sandbox_provider="bubblewrap" ;;
*) echo "Native sandbox execution is unsupported on $(uname -s)." >&2; exit 1 ;;
esac
fi
case "$sandbox_provider" in
bubblewrap)
require_command "${SCIENCE_AGENT_BWRAP_PATH:-bwrap}" \
"bubblewrap is required for isolated Linux execution, but its 'bwrap' executable is not on PATH." \
"Install the bubblewrap package — the binary it provides is named bwrap:" \
" Debian / Ubuntu sudo apt-get install -y bubblewrap" \
" Fedora / RHEL sudo dnf install -y bubblewrap" \
" openEuler sudo dnf install -y bubblewrap" \
" Arch sudo pacman -S bubblewrap" \
" Alpine sudo apk add bubblewrap" \
"Already installed elsewhere? Point SCIENCE_AGENT_BWRAP_PATH at it."
;;
seatbelt)
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Seatbelt sandbox is available only on macOS." >&2
exit 1
fi
require_command "${SCIENCE_AGENT_SEATBELT_PATH:-/usr/bin/sandbox-exec}" \
"macOS Seatbelt is required for isolated execution, but sandbox-exec was not found." \
"sandbox-exec ships with macOS, so this usually means the current terminal or a" \
"parent sandbox is blocking it rather than something to install." \
"See docs/en/how-to/deployment.md#local-mode-host-processes"
;;
*) echo "SCIENCE_AGENT_SANDBOX_PROVIDER must be auto, bubblewrap, or seatbelt." >&2; exit 1 ;;
esac
report_dependency_problems
data_dir="$(absolute_from_repository "${SCIENCE_DISCOVERY_DATA_DIR:-.sciencediscovery-data}")"
migrate_legacy_data_dir "$data_dir"
local envs_dir="$data_dir/envs"
if [[ "$no_build" -eq 0 ]]; then
local pnpm_registry_args=()
if [[ -n "${SCIENCE_AGENT_NPM_REGISTRY:-}" ]]; then
pnpm_registry_args=(--registry "$SCIENCE_AGENT_NPM_REGISTRY")
fi
if [[ -n "${SCIENCE_AGENT_PYPI_INDEX:-}" ]]; then
export UV_DEFAULT_INDEX="$SCIENCE_AGENT_PYPI_INDEX"
fi
if [[ "$no_node_build" -eq 0 ]]; then
if [[ "${#pnpm_registry_args[@]}" -gt 0 ]]; then
pnpm install --frozen-lockfile --ignore-scripts "${pnpm_registry_args[@]}"
else
pnpm install --frozen-lockfile --ignore-scripts
fi
fi
uv_sync_project services/paper "$envs_dir/paper" 1
uv_sync_project services/gateway "$envs_dir/gateway" 0
if [[ "$no_node_build" -eq 0 ]]; then
pnpm build
if [[ "$(uname -s)" == Linux ]]; then pnpm runner:binary; fi
fi
fi
gateway_python="$envs_dir/gateway/bin/python"
if [[ ! -x "$gateway_python" && -x "$repository_root/services/gateway/.venv/bin/python" ]]; then
gateway_python="$repository_root/services/gateway/.venv/bin/python"
fi
if [[ ! -x "$gateway_python" ]]; then
echo "$envs_dir/gateway is missing (needed for the bundled Python MCP servers). Run without --no-build once to provision it." >&2
exit 1
fi
memory_graph_python="$envs_dir/memory-graph/bin/python"
if [[ ! -x "$memory_graph_python" ]]; then
echo "Provisioning the memory-graph Python environment..." >&2
(cd services/memory-graph && UV_PROJECT_ENVIRONMENT="$envs_dir/memory-graph" uv sync)
fi
evolve_python="$envs_dir/evolve/bin/python"
if [[ ! -x "$evolve_python" ]]; then
echo "Provisioning the evolve Python environment..." >&2
if [[ "${SCIENCE_AGENT_EVOLVE_STUB_ONLY:-0}" == "1" ]]; then
(cd services/evolve && UV_PROJECT_ENVIRONMENT="$envs_dir/evolve" uv sync)
else
(cd services/evolve && UV_PROJECT_ENVIRONMENT="$envs_dir/evolve" uv sync --extra candidates)
fi
fi
local runner_environment=(
"SCIENCE_AGENT_BWRAP_PATH=${SCIENCE_AGENT_BWRAP_PATH:-bwrap}"
"SCIENCE_AGENT_SANDBOX_PROVIDER=$sandbox_provider"
"SCIENCE_AGENT_SEATBELT_PATH=${SCIENCE_AGENT_SEATBELT_PATH:-/usr/bin/sandbox-exec}"
"SCIENCE_AGENT_PYTHON_PATH=$SCIENCE_AGENT_PYTHON_PATH"
"SCIENCE_AGENT_DATA_DIR=$data_dir"
"SCIENCE_AGENT_RUNNER_HOST=${SCIENCE_AGENT_RUNNER_HOST:-127.0.0.1}"
"SCIENCE_AGENT_RUNNER_PORT=${SCIENCE_AGENT_RUNNER_PORT:-4311}"
"SCIENCE_AGENT_RUNNER_TOKEN=${SCIENCE_AGENT_RUNNER_TOKEN:-sciencediscovery-runner-local}"
"SCIENTIFIC_ENVS=${SCIENTIFIC_ENVS:-1}"
)
local passthrough value
for passthrough in SCIENCE_AGENT_PROVISIONER_PATH \
SCIENCE_AGENT_PACKAGE_CACHE_DIR SCIENCE_AGENT_SCIENTIFIC_CHANNELS \
SCIENCE_AGENT_KERNEL_IDLE_MS SCIENCE_AGENT_EXEC_TIMEOUT_MS \
SCIENCE_AGENT_NPU_BROKER SCIENCE_AGENT_NPU_WORKLOAD_CONFIG \
SCIENCE_AGENT_NPU_PYTHON \
SCIENCE_AGENT_NPU_SMOKE_SCRIPT SCIENCE_AGENT_NPU_PROTENIX_SCRIPT; do
if [[ -n "${!passthrough:-}" ]]; then
value="${!passthrough}"
if [[ "$passthrough" == *_PATH || "$passthrough" == *_DIR ]]; then
value="$(absolute_from_repository "$value")"
fi
runner_environment+=("$passthrough=$value")
fi
done
export SCIENCE_AGENT_EXTENSIONS_CONFIG_PATH="$(absolute_from_repository "${SCIENCE_AGENT_EXTENSIONS_CONFIG_PATH:-extensions_config.json}")"
export SCIENCE_AGENT_GATEWAY_PYTHON_PATH="${SCIENCE_AGENT_GATEWAY_PYTHON_PATH:-$gateway_python}"
runner_command=(env "${runner_environment[@]}" node services/runner/dist/server.js)
api_command=(pnpm api)
api_foreground=1
health_attempts=50
}
prepare_docker() {
local envs_root="${SCIENCE_AGENT_ENVS_ROOT:-/opt/sciencediscovery/envs}"
gateway_python="${SCIENCE_AGENT_GATEWAY_PYTHON_PATH:-$envs_root/gateway/bin/python}"
data_dir="${SCIENCE_AGENT_DATA_DIR:-/app/data}"
data_dir="$(absolute_from_repository "$data_dir")"
export SCIENCE_AGENT_DATA_DIR="$data_dir"
mkdir -p "$data_dir" 2>/dev/null || true
if [[ ! -w "$data_dir" ]]; then
cat >&2 <<EOF
The data directory $data_dir is not writable by uid $(id -u), gid $(id -g).
It is bind-mounted from the host (./data by default). Either chown the host
directory to those ids, or set SCIENCE_AGENT_UID / SCIENCE_AGENT_GID in .env to
your own ids (id -u / id -g) and recreate the container.
EOF
exit 1
fi
"$script_dir/seed-managed-micromamba.sh" "$data_dir"
local package_cache_dir="${SCIENCE_AGENT_PACKAGE_CACHE_DIR:-}"
if [[ -n "$package_cache_dir" ]]; then
package_cache_dir="$(absolute_from_repository "$package_cache_dir")"
if ! mkdir -p "$package_cache_dir"; then
echo "The scientific package cache directory $package_cache_dir could not be created." >&2
exit 1
fi
export SCIENCE_AGENT_PACKAGE_CACHE_DIR="$package_cache_dir"
fi
if [[ -z "${HOME:-}" || ! -w "${HOME:-/}" ]]; then
HOME="$data_dir/.home"
mkdir -p "$HOME"
export HOME
fi
if [[ ! -x "$gateway_python" ]]; then
echo "The Python MCP server environment is missing at $gateway_python. Rebuild the image." >&2
exit 1
fi
if ! "${SCIENCE_AGENT_BWRAP_PATH:-bwrap}" \
--unshare-all --unshare-user --die-with-parent \
--ro-bind /usr /usr \
--symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 \
/usr/bin/true >/dev/null 2>&1; then
cat >&2 <<'EOF'
WARNING: bubblewrap cannot create a sandbox in this container. run_python /
run_shell will fail; the API and UI are unaffected. Check, in this order:
1. The Compose service still sets all three security_opt entries:
seccomp=unconfined, apparmor=unconfined, systempaths=unconfined.
2. The host AppArmor configuration. Ubuntu 24.04+ restricts unprivileged user
namespaces per profile, so a host can allow them without changing a sysctl.
3. Only then the kernel switches, which need root and do not persist:
sysctl kernel.unprivileged_userns_clone
sysctl kernel.apparmor_restrict_unprivileged_userns
EOF
fi
runner_command=(node services/runner/dist/server.js)
api_command=(node services/api/dist/server.js)
api_foreground=0
health_attempts=300
}
start_stack() {
configure_endpoints
trap cleanup EXIT INT TERM
echo "Starting the sandbox runner daemon..." >&2
"${runner_command[@]}" &
pids+=("$!")
wait_healthy "runner" "$runner_url/health"
if [[ -x "$memory_graph_python" ]]; then
echo "Starting the memory-graph service..." >&2
SCIENCE_AGENT_DATA_DIR="$data_dir" \
SCIENCE_AGENT_MEMORY_GRAPH_NEO4J_HTTP="${SCIENCE_AGENT_MEMORY_GRAPH_NEO4J_HTTP:-http://127.0.0.1:7474}" \
SCIENCE_AGENT_MEMORY_GRAPH_INTERNAL_TOKEN="${SCIENCE_AGENT_MEMORY_GRAPH_INTERNAL_TOKEN:-sciencediscovery-memory-graph-local}" \
"$memory_graph_python" -m sciencediscovery_memory_graph.server &
pids+=("$!")
wait_healthy "memory-graph" "http://127.0.0.1:${SCIENCE_AGENT_MEMORY_GRAPH_PORT:-17674}/health"
fi
if [[ -x "$evolve_python" ]]; then
echo "Starting the evolve service..." >&2
SCIENCE_AGENT_DATA_DIR="$data_dir" \
SCIENCE_AGENT_EVOLVE_INTERNAL_TOKEN="${SCIENCE_AGENT_EVOLVE_INTERNAL_TOKEN:-sciencediscovery-evolve-local}" \
"$evolve_python" -m sciencediscovery_evolve.server &
pids+=("$!")
wait_healthy "evolve" "http://127.0.0.1:${SCIENCE_AGENT_EVOLVE_PORT:-4313}/health"
fi
echo "Starting the control API..." >&2
if [[ "$api_foreground" -eq 1 ]]; then
"${api_command[@]}"
return
fi
"${api_command[@]}" &
pids+=("$!")
set +e
wait -n
local status=$?
set -e
echo "A ScienceDiscovery process exited with status $status; stopping the stack." >&2
return "$status"
}
case "$mode" in
local) prepare_local ;;
docker) prepare_docker ;;
esac
start_stack