Add one-command installer for the MCP fork

Bootstrap the fork through run.py, build Docker from source, and prepare standalone with a checksum-pinned native runtime plus the MCP backend and Python dependencies. Preserve existing installations and data, propagate Docker build failures, and update README/setup guides. Validation: 347 unit tests and a real standalone-rootfs MCP smoke test passed in Docker.
This commit is contained in:
av0idd4rk 2026-10-23 10:11:37 +03:00
parent 863422f202
commit f214cecf9f
8 changed files with 503 additions and 69 deletions

View File

@ -20,50 +20,62 @@ for token creation and connecting an agent on your laptop to Firegex on the vuln
The Russian [agent guide](inst.md) covers setup, every tool, and common workflows. The Russian [agent guide](inst.md) covers setup, every tool, and common workflows.
### Docker Mode (Recommended) ### Docker Mode (Recommended)
What you need is a linux machine and docker ( + docker-compose ) You need Linux, a current Python 3.10+, git, curl, and Docker with Compose.
```bash ```bash
# One-command installer (works for both Docker and standalone modes) # One-command installer (works for both Docker and standalone modes)
sh <(curl -sLf https://pwnzer0tt1.it/firegex.sh) sh <(curl -sLf https://raw.githubusercontent.com/Av0idD4rk/firegex/main/firegex.sh)
``` ```
With this command you will download firegex.py, and run it, it will require you the password to use for firegex and start it with docker-compose Run this command from Bash or Zsh. It clones this MCP-enabled fork into `./firegex`
and starts it through `run.py`. Choose the UI password when prompted. Docker mode
builds the image from this checkout, including MCP. Existing directories are never
overwritten; set `FIREGEX_INSTALL_DIR` to choose a different location.
Or, you can start in a similar way firegex, cloning this repository and executing this command For a POSIX shell, download the script first:
```sh
curl -sLf https://raw.githubusercontent.com/Av0idD4rk/firegex/main/firegex.sh -o firegex-install.sh && sh firegex-install.sh
```
Or clone this repository yourself and run:
```bash ```bash
python3 run.py start --prebuilt python3 run.py start
``` ```
Without the `--prebuilt` flag, it will build the docker image from source, which may take longer. Do not use `--prebuilt` for this fork: the upstream image does not contain MCP.
After installation, run management commands from the installed `firegex` directory.
### Standalone Mode ### Standalone Mode
If Docker is not available or you're running it in a rootless environment, Firegex can run in standalone mode. The one-command installer above also works for standalone mode and will automatically detect and use standalone mode when Docker is unavailable or in rootless mode. If Docker is not available or you're running it in a rootless environment, Firegex can run in standalone mode. The one-command installer above also works for standalone mode and will automatically detect and use standalone mode when Docker is unavailable or in rootless mode.
```bash ```bash
sh <(curl -sLf https://pwnzer0tt1.it/firegex.sh) sh <(curl -sLf https://raw.githubusercontent.com/Av0idD4rk/firegex/main/firegex.sh)
# Or manually force standalone mode:
python3 run.py start --standalone
# Or directly using the one-command installer: # Or directly using the one-command installer:
sh <(curl -sLf https://pwnzer0tt1.it/firegex.sh) --standalone sh <(curl -sLf https://raw.githubusercontent.com/Av0idD4rk/firegex/main/firegex.sh) --standalone
# Or manually force standalone mode from a checkout:
sudo python3 run.py start --standalone
# Check status # Check status
python3 run.py status [--standalone] sudo python3 run.py status --standalone
# Stop standalone mode # Stop standalone mode
python3 run.py stop [--standalone] sudo python3 run.py stop --standalone
``` ```
Standalone mode automatically: Standalone mode automatically:
- Downloads pre-built rootfs from GitHub releases - Downloads the pinned upstream 5.0.1 native runtime and checks its SHA-256
- Detects your architecture (amd64/arm64) - Detects your architecture (amd64/arm64)
- Installs this fork's backend and Python dependencies, including MCP, into that runtime
- Sets up chroot environment with necessary bind mounts - Sets up chroot environment with necessary bind mounts
- Runs as a background daemon process - Runs as a background daemon process
- Manages PID files for process control - Manages PID files for process control
If the server is restarted, docker mode will automatically restart the service, while standalone mode will require you to run the start command again manually. If the server is restarted, docker mode will automatically restart the service, while standalone mode will require you to run the start command again manually.
Cloning the repository run.py will automatically build the docker image of firegex from source, and start it. Standalone installation needs root (the installer uses sudo when required) and
Image building of firegex will require more time, so it's recommended to use the version just builded and available in the github packages. network access to GitHub and PyPI. Its native engines, frontend and filter library
This is default behaviour if run.py is not in the firegex source root directory. come from the matching upstream release; the MCP backend comes from this checkout.
Configuration and MCP tokens stay in the installation's existing data directory.
By default firegex will start in a multithread configuration using the number of threads available in your system. By default firegex will start in a multithread configuration using the number of threads available in your system.
The default port of firegex is 4444. At the startup you will choose a password, that is essential for your security. The default port of firegex is 4444. At the startup you will choose a password, that is essential for your security.

View File

@ -20,6 +20,16 @@ Use `admin` instead of `operator` if the agent needs instance resets or UI auth
administration. `observer` can only read configuration, statistics and logs, and administration. `observer` can only read configuration, statistics and logs, and
test regex samples. Python checks execute submitted code and require `operator`. test regex samples. Python checks execute submitted code and require `operator`.
For a standalone installation, run the same CLI inside its rootfs from the checkout:
```sh
sudo chroot ./firegexfs /bin/sh -c 'cd /execute && python3 -m mcp_api.tokens create --name laptop --role operator --hours 168'
```
The [one-command installer](../README.md#docker-mode-recommended) downloads this
fork. Docker builds it from source; standalone installs its backend and dependencies
over the pinned, checksum-verified 5.0.1 native runtime. Both expose the same MCP API.
An SSH tunnel is a convenient encrypted connection from the laptop: An SSH tunnel is a convenient encrypted connection from the laptop:
```sh ```sh
@ -187,3 +197,10 @@ Debian Linux kernel under QEMU inside an unprivileged container. It does not cha
the host kernel. Emulation is slower. The harness needs x86-64 Docker images. the host kernel. Emulation is slower. The harness needs x86-64 Docker images.
For the full suite under emulation, allow a longer test budget, for example For the full suite under emulation, allow a longer test budget, for example
`python tests/docker_mcp.py --vm --full --timeout 10800`. `python tests/docker_mcp.py --vm --full --timeout 10800`.
Installer regressions are included in the unit suite. The opt-in
`FIREGEX_TEST_INSTALLER=1 ./run_tests.sh standalone/test_installer_rootfs.py`
additionally downloads/prepares a real standalone runtime and checks its MCP API.
Run it as root in a disposable Linux namespace with `NET_ADMIN` and `SYS_ADMIN`;
`FIREGEX_STANDALONE_ARCHIVE` can point to a cached rootfs archive, which is still
verified against the pinned checksum.

46
firegex.sh Executable file
View File

@ -0,0 +1,46 @@
#!/bin/sh
# Bootstrap this fork; run.py remains the deployment entry point.
set -eu
fail() { printf '%s\n' "Firegex installer: $*" >&2; exit 1; }
[ "$(uname -s)" = Linux ] || fail 'Linux is required.'
command -v python3 >/dev/null 2>&1 || fail 'Install Python 3.10 or newer first.'
command -v git >/dev/null 2>&1 || fail 'Install git first.'
python3 -c 'import sys, tarfile; sys.exit(not (sys.version_info >= (3, 10) and hasattr(tarfile, "tar_filter")))' \
|| fail 'Update Python: version 3.10+ with tarfile extraction filters is required.'
standalone=0
for argument in "$@"; do
case "$argument" in
--standalone) standalone=1 ;;
--prebuilt) fail 'Upstream prebuilt images do not include MCP; omit --prebuilt.' ;;
esac
done
# Keep rootless Docker on the standalone path even after privilege elevation.
docker_info=$(docker info --format '{{json .SecurityOptions}}' 2>/dev/null) || docker_info=
case "$docker_info" in
*rootless*) standalone=1; set -- "$@" --standalone ;;
esac
use_sudo=0
if [ "$(id -u)" != 0 ] && { [ "$standalone" = 1 ] || [ -z "$docker_info" ]; }; then
command -v sudo >/dev/null 2>&1 || fail 'Root privileges are required; run as root or install sudo.'
use_sudo=1
fi
install_dir=${FIREGEX_INSTALL_DIR:-"$PWD/firegex"}
case "$install_dir" in /*) ;; *) install_dir=$PWD/$install_dir ;; esac
if [ -e "$install_dir" ] || [ -L "$install_dir" ]; then
fail "$install_dir already exists. Use its run.py, or select a new FIREGEX_INSTALL_DIR."
fi
printf '%s\n' "Downloading Av0idD4rk/firegex into $install_dir"
git clone --depth 1 --branch main https://github.com/Av0idD4rk/firegex.git "$install_dir"
printf '%s\n' "Manage this installation with: $install_dir/run.py"
cd "$install_dir"
if [ "$use_sudo" = 1 ]; then
exec sudo python3 ./run.py start "$@"
fi
exec python3 ./run.py start "$@"

18
inst.md
View File

@ -9,7 +9,17 @@ Firegex работает на vulnbox, агент и MCP-клиент — на
### На vulnbox ### На vulnbox
Собери именно этот форк: upstream-образ Firegex не содержит расширение MCP. Установи именно этот форк: upstream-образ Firegex не содержит расширение MCP.
Установка одной командой из Bash/Zsh (исходники появятся в `./firegex`):
```bash
sh <(curl -sLf https://raw.githubusercontent.com/Av0idD4rk/firegex/main/firegex.sh)
```
Для принудительного standalone добавь `--standalone`. Docker-режим собирает образ
из исходников; standalone устанавливает backend этого форка и MCP-зависимости
поверх проверенного runtime 5.0.1. Уже существующий каталог установщик не заменяет.
Альтернатива — ручная установка:
```sh ```sh
git clone git@github.com:Av0idD4rk/firegex.git git clone git@github.com:Av0idD4rk/firegex.git
@ -33,6 +43,12 @@ docker exec --user nobody -w /execute <container> python3 -m mcp_api.tokens list
docker exec --user nobody -w /execute <container> python3 -m mcp_api.tokens revoke <id> docker exec --user nobody -w /execute <container> python3 -m mcp_api.tokens revoke <id>
``` ```
В standalone вместо `docker exec` запускай CLI внутри rootfs из каталога установки:
```sh
sudo chroot ./firegexfs /bin/sh -c 'cd /execute && python3 -m mcp_api.tokens create --name laptop-agent --role operator --hours 168'
```
| Роль | Возможности | | Роль | Возможности |
|---|---| |---|---|
| `observer` | Читать конфигурацию, статистику, журналы и историю; тестировать regex | | `observer` | Читать конфигурацию, статистику, журналы и историю; тестировать regex |

168
run.py
View File

@ -13,6 +13,15 @@ import hashlib
import secrets import secrets
import shlex import shlex
# Native engines, frontend and filter library are unchanged from this release.
# Standalone overlays this fork's backend and installs its Python dependencies.
# Update the base (and checksums) when changing those compiled components.
STANDALONE_BASE_RELEASE = "5.0.1"
STANDALONE_BASE_SHA256 = {
"amd64": "4cfeeedb230f66bba527403c0d743a4e37b4a1bc62ff971d7744def05b4c25cd",
"arm64": "4104054abb7d6189aec8ea30c1514c54dff6c628c76088046087805329217de9",
}
pref = "\033[" pref = "\033["
reset = f"{pref}0m" reset = f"{pref}0m"
class g: class g:
@ -874,69 +883,118 @@ def download_file(url, filename):
return False return False
def setup_standalone_rootfs(): def setup_standalone_rootfs():
"""Set up the standalone rootfs""" """Download a complete runtime before making it the installed rootfs."""
import tempfile
from pathlib import Path
puts("Setting up standalone mode...", color=colors.green) puts("Setting up standalone mode...", color=colors.green)
rootfs = Path(g.rootfs_path).absolute()
# Remove and recreate rootfs directory if rootfs.exists():
if os.path.exists(g.rootfs_path): if not (rootfs / "execute/app.py").is_file():
puts("Existing rootfs is incomplete; select a clean installation directory.", color=colors.red)
return False
puts("Rootfs already exists, skipping download...", color=colors.yellow) puts("Rootfs already exists, skipping download...", color=colors.yellow)
# Clean up any existing mounts
cleanup_standalone_mounts() cleanup_standalone_mounts()
return True return True
puts("Creating rootfs directory...", color=colors.green) source_backend = Path(__file__).resolve().parent / "backend/mcp_api"
try: release_tag = STANDALONE_BASE_RELEASE if source_backend.is_dir() else get_latest_release_tag()
os.makedirs(g.rootfs_path, exist_ok=True)
except Exception as e:
puts(f"Failed to create rootfs directory: {e}", color=colors.red)
return False
# Get latest release tag
release_tag = get_latest_release_tag()
if not release_tag: if not release_tag:
puts("Failed to get latest release tag", color=colors.red)
return False return False
# Get current architecture
arch = get_architecture() arch = get_architecture()
if not arch: if not arch:
return False return False
# Download rootfs from GitHub releases
puts(f"Downloading rootfs for {arch} architecture from GitHub releases...", color=colors.green)
# Construct download URL
rootfs_filename = f"firegex-rootfs-{arch}.tar.gz" rootfs_filename = f"firegex-rootfs-{arch}.tar.gz"
download_url = f"https://github.com/Pwnzer0tt1/firegex/releases/download/{release_tag}/{rootfs_filename}" download_url = f"https://github.com/Pwnzer0tt1/firegex/releases/download/{release_tag}/{rootfs_filename}"
tar_path = os.path.join(g.rootfs_path, rootfs_filename)
# Download the rootfs archive
if not download_file(download_url, tar_path):
return False
try: try:
# Extract tar.gz file with tempfile.TemporaryDirectory(prefix=".firegex-download-", dir=rootfs.parent) as tmp:
puts("Extracting rootfs...", color=colors.green) archive = Path(tmp) / rootfs_filename
with tarfile.open(tar_path, 'r:gz') as tar: if not download_file(download_url, str(archive)):
# Extract all files with tar filter (allows safe symbolic links) return False
tar.extractall(path=g.rootfs_path, filter='tar') if source_backend.is_dir():
checksum = hashlib.sha256()
# Remove tar.gz file with archive.open("rb") as stream:
os.remove(tar_path) for chunk in iter(lambda: stream.read(1024 * 1024), b""):
checksum.update(chunk)
# Create necessary directories if checksum.hexdigest() != STANDALONE_BASE_SHA256[arch]:
os.makedirs(os.path.join(g.rootfs_path, "dev"), exist_ok=True) raise ValueError("Standalone runtime checksum mismatch")
os.makedirs(os.path.join(g.rootfs_path, "proc"), exist_ok=True) staged = Path(tmp) / "rootfs"
os.makedirs(os.path.join(g.rootfs_path, "sys_host"), exist_ok=True) staged.mkdir()
puts("Extracting rootfs...", color=colors.green)
with tarfile.open(archive, 'r:gz') as tar:
tar.extractall(path=staged, filter='tar')
if not (staged / "execute/app.py").is_file():
raise ValueError("Standalone runtime does not contain execute/app.py")
for directory in ("dev", "proc", "sys_host"):
(staged / directory).mkdir(exist_ok=True)
(staged / ".firegex-native-base").write_text(release_tag)
staged.rename(rootfs)
puts("Rootfs setup completed", color=colors.green) puts("Rootfs setup completed", color=colors.green)
return True return True
except Exception as e: except Exception as e:
puts(f"Failed to extract rootfs: {e}", color=colors.red) puts(f"Failed to prepare rootfs: {e}", color=colors.red)
# Clean up partial extraction return False
if os.path.exists(tar_path):
os.remove(tar_path)
def prepare_standalone_backend():
"""Install the fork's backend into the matching native runtime, preserving DBs."""
import shutil
from pathlib import Path
backend = Path(__file__).resolve().parent / "backend"
if not (backend / "mcp_api").is_dir():
return True
rootfs = Path(g.rootfs_path).resolve()
execute = rootfs / "execute"
try:
base_marker = rootfs / ".firegex-native-base"
if base_marker.exists():
version = base_marker.read_text().strip()
else:
result = subprocess.run(["chroot", str(rootfs), "/bin/python3", "-c",
"import sys; sys.path.insert(0,'/execute'); import utils; print(utils.API_VERSION)"],
check=True, capture_output=True, text=True)
version = result.stdout.strip()
if version != STANDALONE_BASE_RELEASE:
raise ValueError(f"This fork requires standalone runtime {STANDALONE_BASE_RELEASE}, found {version}")
base_marker.write_text(version)
# Only application files; never copy local DBs, filter uploads, caches or
# compiled engines over the runtime's data and native executables.
for folder, dirs, files in os.walk(backend):
dirs[:] = [d for d in dirs if d not in {"db", "__pycache__", "binsrc", "proxysrc", ".git", ".venv"}]
for name in files:
if not (name.endswith((".py", ".sh")) or name == "pyproject.toml"):
continue
source = Path(folder) / name
target = execute / source.relative_to(backend)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
dependencies = hashlib.sha256((backend / "pyproject.toml").read_bytes()).hexdigest()
marker = execute / ".firegex-backend-deps"
if not marker.exists() or marker.read_text() != dependencies:
# Docker's exported resolv.conf can be empty. Do not follow an absolute
# symlink from the chroot while installing its resolver configuration.
resolver = rootfs / "etc/resolv.conf"
if resolver.is_symlink():
resolver.unlink()
shutil.copyfile("/etc/resolv.conf", resolver)
puts("Installing this fork's standalone Python dependencies...", color=colors.green)
install = (
"import subprocess,sys,tomllib; "
"deps=tomllib.load(open('/execute/pyproject.toml','rb'))['project']['dependencies']; "
"subprocess.run([sys.executable,'-m','pip','install','--no-cache-dir',"
"'--disable-pip-version-check',*deps],check=True)"
)
subprocess.run(["chroot", str(rootfs), "/bin/python3", "-c", install], check=True)
subprocess.run(["chroot", str(rootfs), "/bin/python3", "-c",
"from mcp.server import MCPServer"], check=True)
marker.write_text(dependencies)
return True
except Exception as error:
puts(f"Failed to prepare standalone backend: {error}", color=colors.red)
return False return False
def setup_standalone_mounts(): def setup_standalone_mounts():
@ -1001,6 +1059,10 @@ def run_standalone():
puts(f"Process PID: {pid}", color=colors.cyan) puts(f"Process PID: {pid}", color=colors.cyan)
return return
if not prepare_standalone_backend():
cleanup_standalone_mounts()
raise SystemExit(1)
warn_if_auth_disabled() warn_if_auth_disabled()
# Set up environment variables # Set up environment variables
@ -1419,13 +1481,13 @@ def main():
sudo = "sudo " if g.docker_sudo else "" sudo = "sudo " if g.docker_sudo else ""
if not cmd_check(f"{sudo}docker --version"): if not cmd_check(f"{sudo}docker --version"):
puts("Docker not found! please install docker and docker compose!", color=colors.red) puts("Docker not found! please install docker and docker compose!", color=colors.red)
exit() exit(1)
elif not cmd_check(f"{sudo}docker-compose --version") and not cmd_check(f"{sudo}docker compose --version"): elif not cmd_check(f"{sudo}docker-compose --version") and not cmd_check(f"{sudo}docker compose --version"):
puts("Docker compose not found! please install docker compose!", color=colors.red) puts("Docker compose not found! please install docker compose!", color=colors.red)
exit() exit(1)
if not cmd_check(f"{sudo}docker ps"): if not cmd_check(f"{sudo}docker ps"):
puts("Cannot use docker, the user hasn't the permission or docker isn't running", color=colors.red) puts("Cannot use docker, the user hasn't the permission or docker isn't running", color=colors.red)
exit() exit(1)
if not is_linux(): if not is_linux():
sep() sep()
@ -1453,7 +1515,9 @@ def main():
puts(f"Downloading docker image from github packages '{sudo}docker pull ghcr.io/pwnzer0tt1/firegex'", color=colors.green) puts(f"Downloading docker image from github packages '{sudo}docker pull ghcr.io/pwnzer0tt1/firegex'", color=colors.green)
cmd_check(f"{sudo}docker pull ghcr.io/pwnzer0tt1/firegex:{args.version}", print_output=True) cmd_check(f"{sudo}docker pull ghcr.io/pwnzer0tt1/firegex:{args.version}", print_output=True)
puts("Running 'docker compose up -d --build'\n", color=colors.green) puts("Running 'docker compose up -d --build'\n", color=colors.green)
composecmd("up -d --build", g.composefile) result = composecmd("up -d --build", g.composefile)
if result is None or result.returncode:
raise SystemExit(result.returncode if result is not None else 1)
case "compose": case "compose":
write_compose() write_compose()
compose_cmd = " ".join(args.compose_args) compose_cmd = " ".join(args.compose_args)

View File

@ -11,5 +11,6 @@ RUN python3 -m venv /test/tests/.venv && \
/test/tests/.venv/bin/pip install -e /test/fgex-lib /test/tests/.venv/bin/pip install -e /test/fgex-lib
COPY tests /test/tests COPY tests /test/tests
COPY backend /test/backend COPY backend /test/backend
COPY run.py firegex.sh /test/
ENV FIREGEX_MCP_TEST_DB=/execute/db/mcp/control.sqlite3 ENV FIREGEX_MCP_TEST_DB=/execute/db/mcp/control.sqlite3
ENV FIREGEX_TEST_DEPS_READY=1 ENV FIREGEX_TEST_DEPS_READY=1

View File

@ -0,0 +1,91 @@
"""Opt-in packaging smoke: a real upstream rootfs must start this fork's MCP.
Run in a disposable Linux namespace with NET_ADMIN and SYS_ADMIN:
FIREGEX_TEST_INSTALLER=1 ./run_tests.sh standalone/test_installer_rootfs.py
FIREGEX_STANDALONE_ARCHIVE may name a cached archive; its checksum is still checked.
"""
import asyncio
import importlib.util
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import time
import pytest
import requests
from mcp import Client
from mcp.client.streamable_http import streamable_http_client, create_mcp_http_client
from helpers.net import free_port
ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.root
def test_standalone_runtime_exposes_fork_mcp(tmp_path, monkeypatch):
if os.getenv("FIREGEX_TEST_INSTALLER") != "1":
pytest.skip("Opt in with FIREGEX_TEST_INSTALLER=1 in a disposable namespace")
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(sys, "argv", [str(ROOT / "run.py"), "status"])
spec = importlib.util.spec_from_file_location("standalone_installer", ROOT / "run.py")
manager = importlib.util.module_from_spec(spec)
spec.loader.exec_module(manager)
os.chdir(tmp_path)
manager.g.rootfs_path = str(tmp_path / "rootfs")
manager.g.pid_file = str(tmp_path / "backend.pid")
manager.g.configfile = str(tmp_path / "config.json")
manager.g.standalone_mode = True
port = free_port()
manager.args = manager.gen_args(["start", "--standalone", "--host", "127.0.0.1",
"--port", str(port), "--threads", "2", "--psw-on-web"])
cached = os.getenv("FIREGEX_STANDALONE_ARCHIVE")
if cached:
monkeypatch.setattr(manager, "download_file", lambda url, path: bool(shutil.copyfile(cached, path)))
assert manager.setup_standalone_rootfs()
rootfs = Path(manager.g.rootfs_path)
pid = None
try:
assert manager.setup_standalone_mounts()
manager.run_standalone()
pid = manager.read_pid_file()
address = f"http://127.0.0.1:{port}"
for _ in range(60):
try:
if requests.get(address + "/api/status", timeout=1).status_code == 200:
break
except requests.RequestException:
pass
time.sleep(0.5)
else:
pytest.fail(Path("/tmp/app.log").read_text()[-8000:])
token = json.loads(subprocess.check_output([
"chroot", str(rootfs), "/bin/sh", "-c",
"cd /execute && python3 -m mcp_api.tokens create --name installer-test --role operator --hours 1",
], text=True))
assert requests.post(address + "/mcp/", json={}, timeout=5).status_code == 401
async def scenario():
async with create_mcp_http_client(headers={"Authorization": "Bearer " + token["token"]}) as http:
async with Client(streamable_http_client(address + "/mcp/", http_client=http)) as client:
catalog = await client.list_tools()
assert len(catalog.tools) == 28
overview = await client.call_tool("fgx_overview", {})
assert overview.structured_content["ok"], overview
checked = await client.call_tool("fgx_python_check", {"code": "pass"})
assert checked.structured_content["data"]["ok"], checked
asyncio.run(scenario())
assert (rootfs / "execute/.firegex-backend-deps").is_file()
assert (rootfs / "execute/db/mcp/control.sqlite3").is_file()
finally:
pid = pid or manager.read_pid_file()
if pid:
try:
os.killpg(pid, signal.SIGTERM)
os.waitpid(pid, 0)
except ProcessLookupError:
pass
manager.cleanup_standalone_mounts()
manager.remove_pid_file()

View File

@ -0,0 +1,187 @@
"""Installation must select the fork, preserve data and propagate failures."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[2]
def executable(path, source):
path.write_text(f"#!{sys.executable}\n" + source)
path.chmod(0o755)
@pytest.fixture
def installer_env(tmp_path):
binaries = tmp_path / "bin"
binaries.mkdir()
executable(binaries / "git", """
import os,sys
from pathlib import Path
assert 'https://github.com/Av0idD4rk/firegex.git' in sys.argv
if os.environ.get('CLONE_FAIL'): sys.exit(17)
target=Path(sys.argv[-1]); target.mkdir()
(target/'run.py').write_text('import os,sys,json; from pathlib import Path; '
'Path(os.environ["RESULT"]).write_text(json.dumps(sys.argv[1:])); '
'sys.exit(int(os.environ.get("RUN_EXIT", "0")))')
""")
executable(binaries / "docker", """
import os,sys
info=os.environ.get('DOCKER_INFO','')
print(info)
sys.exit(0 if info else 1)
""")
executable(binaries / "id", "import os; print(os.environ['TEST_UID'])")
executable(binaries / "sudo", """
import os,sys
from pathlib import Path
Path(os.environ['SUDO_USED']).touch()
os.execvp(sys.argv[1],sys.argv[1:])
""")
env = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ["PATH"],
"FIREGEX_INSTALL_DIR": str(tmp_path / "installation with spaces"),
"RESULT": str(tmp_path / "result.json"), "SUDO_USED": str(tmp_path / "sudo"),
"TEST_UID": "1000", "DOCKER_INFO": '["name=seccomp"]'}
return env
@pytest.mark.parametrize("info,uid,arguments,sudo,forced", [
('["name=seccomp"]', "1000", [], False, False),
('["name=seccomp"]', "1000", ["--standalone"], True, True),
("", "1000", [], True, False),
('["name=rootless"]', "1000", [], True, True),
("", "0", [], False, False),
])
def test_bootstrap_preserves_arguments_and_selects_privileges(installer_env, info, uid, arguments, sudo, forced):
env = {**installer_env, "DOCKER_INFO": info, "TEST_UID": uid}
literal = "EXAMPLE=spaces; $(not-a-command)"
result = subprocess.run(["sh", str(ROOT / "firegex.sh"), *arguments, "--env", literal],
env=env, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
received = json.loads(Path(env["RESULT"]).read_text())
assert received[0] == "start" and literal in received
assert ("--standalone" in received) == forced
assert Path(env["SUDO_USED"]).exists() == sudo
def test_existing_installation_is_untouched(installer_env):
target = Path(installer_env["FIREGEX_INSTALL_DIR"])
target.mkdir()
(target / "important.db").write_bytes(b"keep me")
result = subprocess.run(["sh", str(ROOT / "firegex.sh")], env=installer_env, capture_output=True)
assert result.returncode != 0 and b"already exists" in result.stderr
assert (target / "important.db").read_bytes() == b"keep me"
assert not Path(installer_env["RESULT"]).exists()
@pytest.mark.parametrize("extra,arguments,code", [
({"CLONE_FAIL": "1"}, [], 17),
({"RUN_EXIT": "42"}, [], 42),
({}, ["--prebuilt"], 1),
])
def test_installer_failure_is_reported(installer_env, extra, arguments, code):
result = subprocess.run(["sh", str(ROOT / "firegex.sh"), *arguments],
env={**installer_env, **extra}, capture_output=True)
assert result.returncode == code
@pytest.fixture
def manager(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(sys, "argv", [str(ROOT / "run.py"), "status"])
spec = importlib.util.spec_from_file_location("installer_manager", ROOT / "run.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
os.chdir(tmp_path)
source = tmp_path / "source"
(source / "backend/mcp_api").mkdir(parents=True)
(source / "backend/mcp_api/server.py").write_text("# new MCP backend\n")
(source / "backend/pyproject.toml").write_text('[project]\ndependencies=["mcp==2.3.0"]\n')
module.__file__ = str(source / "run.py")
module.g.rootfs_path = str(tmp_path / "rootfs")
monkeypatch.setattr(module, "cleanup_standalone_mounts", lambda: None)
return module
def make_runtime(manager):
rootfs = Path(manager.g.rootfs_path)
for folder in ("execute/db/mcp", "execute/modules", "etc"):
(rootfs / folder).mkdir(parents=True, exist_ok=True)
(rootfs / "execute/app.py").write_text("# existing backend\n")
(rootfs / ".firegex-native-base").write_text(manager.STANDALONE_BASE_RELEASE)
return rootfs
def test_backend_install_preserves_databases_and_native_engine(manager, monkeypatch):
rootfs = make_runtime(manager)
db = rootfs / "execute/db/mcp/control.sqlite3"
engine = rootfs / "execute/modules/fgex-proxy"
db.write_bytes(b"credentials and changes")
engine.write_bytes(b"native engine")
source = Path(manager.__file__).parent / "backend"
(source / "db").mkdir()
(source / "db/unrelated.py").write_text("must not be copied")
calls = []
monkeypatch.setattr(manager.subprocess, "run", lambda *a, **kw: calls.append(a) or SimpleNamespace(returncode=0))
assert manager.prepare_standalone_backend()
assert (rootfs / "execute/mcp_api/server.py").read_text() == "# new MCP backend\n"
assert db.read_bytes() == b"credentials and changes" and engine.read_bytes() == b"native engine"
assert not (rootfs / "execute/db/unrelated.py").exists()
assert len(calls) == 2
assert manager.prepare_standalone_backend()
assert len(calls) == 2 # unchanged requirements do not need a network install
def test_incompatible_runtime_is_refused_before_copy(manager):
rootfs = make_runtime(manager)
(rootfs / ".firegex-native-base").write_text("4.0.0")
assert not manager.prepare_standalone_backend()
assert not (rootfs / "execute/mcp_api").exists()
def test_failed_dependencies_are_not_marked_installed(manager, monkeypatch):
rootfs = make_runtime(manager)
def fail(*a, **kw):
raise subprocess.CalledProcessError(1, "pip")
monkeypatch.setattr(manager.subprocess, "run", fail)
assert not manager.prepare_standalone_backend()
assert not (rootfs / "execute/.firegex-backend-deps").exists()
@pytest.mark.parametrize("download_ok", [False, True])
def test_failed_download_or_checksum_leaves_no_rootfs(manager, monkeypatch, download_ok):
monkeypatch.setattr(manager, "get_architecture", lambda: "amd64")
def download(url, filename):
assert "/5.0.1/" in url
Path(filename).write_bytes(b"truncated or corrupt archive")
return download_ok
monkeypatch.setattr(manager, "download_file", download)
assert not manager.setup_standalone_rootfs()
assert not Path(manager.g.rootfs_path).exists()
assert not list(Path(manager.g.rootfs_path).parent.glob(".firegex-download-*"))
def test_partial_old_runtime_is_not_treated_as_installed(manager):
Path(manager.g.rootfs_path).mkdir()
assert not manager.setup_standalone_rootfs()
@pytest.mark.parametrize("result,code", [(SimpleNamespace(returncode=17), 17), (None, 1)])
def test_docker_build_failure_reaches_the_installer(manager, monkeypatch, result, code):
manager.args = manager.gen_args(["start", "--psw-on-web"])
manager.g.build = True
for name, value in {"should_use_standalone": False, "is_linux": True,
"nfqueue_exists": True, "check_already_running": False}.items():
monkeypatch.setattr(manager, name, lambda value=value: value)
monkeypatch.setattr(manager, "cmd_check", lambda *a, **kw: True)
monkeypatch.setattr(manager, "write_compose", lambda *a, **kw: None)
monkeypatch.setattr(manager, "composecmd", lambda *a, **kw: result)
with pytest.raises(SystemExit) as error:
manager.main()
assert error.value.code == code