diff --git a/Dockerfile b/Dockerfile index 51e8d7f6..7a91b7a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,9 @@ ENV PYTHONUNBUFFERED=1 \ # Customer-side cloud session and entitlement display cache. Keep it on /data rather # than the container's ephemeral home so reconnects do not lose rotated credentials. # License issuance, trial state, leases, and revocations remain private services. - ENGRAPHIS_STATE_DIR=/data/.engraphis + ENGRAPHIS_STATE_DIR=/data/.engraphis \ + # Dashboard-managed non-secret settings must survive a Railway redeploy with the volume. + ENGRAPHIS_ENV_FILE=/data/.engraphis/config.env WORKDIR /app @@ -33,6 +35,8 @@ RUN apt-get update \ COPY pyproject.toml README.md LICENSE NOTICE ./ COPY engraphis ./engraphis COPY scripts ./scripts +# The declared distribution license assets are part of the package build metadata. +COPY deploy ./deploy # Railway runs CPU workloads. Install the CPU-only PyTorch wheel before the embedding # stack so pip cannot select PyPI's multi-gigabyte CUDA dependency chain. The public diff --git a/deploy/railway-template.json b/deploy/railway-template.json index 077721b7..a6f89f9e 100644 --- a/deploy/railway-template.json +++ b/deploy/railway-template.json @@ -15,6 +15,11 @@ } }, "variables": { + "ENGRAPHIS_HOST": { + "value": "0.0.0.0", + "prompt": "Bind the public Railway service on all IPv4 interfaces so the platform PORT and health probe are reachable.", + "required": true + }, "ENGRAPHIS_SERVICE_MODE": { "value": "customer", "required": true @@ -27,6 +32,10 @@ "value": "/data/.engraphis", "required": true }, + "ENGRAPHIS_ENV_FILE": { + "value": "/data/.engraphis/config.env", + "required": true + }, "ENGRAPHIS_API_TOKEN": { "value": "${{ secret(48) }}", "secret": true, diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2116c7e1..52265dd9 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,11 +6,12 @@ # create /data/engraphis.db or customer state under /data/.engraphis and crashes at # startup with `sqlite3.OperationalError: unable to open database file`. # -# We therefore start the container as root, chown the mounted volume to `engraphis`, and -# exec the real command as `engraphis` via gosu — keeping the deliberate non-root runtime -# while making the volume writable. When not running as root (e.g. a local `docker run` -# that already dropped privileges) this is a no-op passthrough. +# We therefore start the container as root, repair ownership once, and exec the real command +# as `engraphis` via gosu — keeping the deliberate non-root runtime while making the volume +# writable. A marker avoids repeating ownership writes on a correctly owned volume. +# A non-root launch initializes private settings in its already-writable state volume. set -e +umask 077 # Default bind host, decided at runtime (not baked into the image). Uvicorn's `::` # listener is IPv6-only on some container kernels, so plain Docker port forwarding cannot @@ -26,13 +27,270 @@ if [ -z "${ENGRAPHIS_HOST:-}" ]; then export ENGRAPHIS_HOST fi +# Validate every existing component without resolving through a symlink. The trusted +# config path is operator-configured and may be outside /data, so checking only its +# leaf or final parent would let an app-writable intermediate directory redirect root's +# chmod/chown into the image. Reject dot-dot paths rather than guessing their target. +reject_linked_path() { + path=$1 + case "$path" in + /*) ;; + *) return 1 ;; + esac + remainder=${path#/} + current= + while [ -n "$remainder" ]; do + case "$remainder" in + */*) + component=${remainder%%/*} + remainder=${remainder#*/} + ;; + *) + component=$remainder + remainder= + ;; + esac + case "$component" in + ""|.) continue ;; + ..) return 1 ;; + esac + if [ -n "$current" ]; then + current="$current/$component" + else + current="/$component" + fi + if [ -L "$current" ]; then + return 1 + fi + done + return 0 +} + +state_directory_is_owned() { + # Only /data is an ownership-repair target. External state paths must be + # provisioned for the app beforehand, including on the first boot. + case "$1" in + /data|/data/*) return 0 ;; + esac + [ -d "$1" ] && [ "$(stat -c '%u' "$1" 2>/dev/null)" = "$2" ] +} + +create_private_runtime_directory() { + if ! reject_linked_path "$1"; then + return 1 + fi + create_remaining=${1#/} + create_current= + while [ -n "$create_remaining" ]; do + case "$create_remaining" in + */*) + create_component=${create_remaining%%/*} + create_remaining=${create_remaining#*/} + ;; + *) + create_component=$create_remaining + create_remaining= + ;; + esac + case "$create_component" in ""|.) continue ;; esac + create_current="$create_current/$create_component" + if [ ! -e "$create_current" ]; then + # Never chown an existing ancestor. Each new directory must become + # traversable by the app before another private child is created. + if ! mkdir "$create_current" || ! reject_linked_path "$create_current" \ + || [ ! -d "$create_current" ] \ + || ! chown engraphis:engraphis "$create_current"; then + return 1 + fi + elif [ ! -d "$create_current" ] || [ -L "$create_current" ]; then + return 1 + fi + done +} + +repair_volume_descendants() { + # Restores can preserve the marker while resetting file ownership. Scan for + # a mismatch before trusting it; do not follow links outside the volume. + unowned_entry=$(find "$1" ! -uid "$2" -print -quit) || return 1 + if [ -n "$unowned_entry" ]; then + chown -R -h engraphis:engraphis "$1" || return 1 + fi +} + if [ "$(id -u)" = "0" ]; then - # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis; ensure both it and the volume root - # exist and are owned by the app user. `|| true` so a transient FS hiccup never blocks - # startup — the app surfaces any real write failure itself. - mkdir -p "${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" 2>/dev/null || true - chown -R engraphis:engraphis /data 2>/dev/null || true + # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on + # first boot or ownership drift; restarts scan without rewriting correct ownership. + state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" + # Keep the marker on the volume it describes; external state may outlive a + # replaced /data volume that still needs its first ownership repair. + ownership_marker="/data/.volume-ownership" + config_file="${ENGRAPHIS_ENV_FILE:-}" + app_owner=$(id -u engraphis) + if ! reject_linked_path "$state_dir"; then + printf '%s\n' "[engraphis] refusing linked or unnormalized state path: $state_dir" >&2 + exit 1 + fi + # The state directory is app-writable after first boot. Reject a planted link or + # non-directory before mkdir/chown can follow it into a root-owned image path. + if [ -L "$state_dir" ]; then + printf '%s\n' "[engraphis] refusing symlinked state directory: $state_dir" >&2 + exit 1 + elif [ -e "$state_dir" ] && [ ! -d "$state_dir" ]; then + printf '%s\n' "[engraphis] refusing non-directory state path: $state_dir" >&2 + exit 1 + fi + if ! state_directory_is_owned "$state_dir" "$app_owner"; then + printf '%s\n' "[engraphis] external state directory must already be owned by engraphis: $state_dir" >&2 + exit 1 + fi + if ! create_private_runtime_directory "$state_dir"; then + printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2 + exit 1 + fi + if ! reject_linked_path "$state_dir" || [ ! -d "$state_dir" ] || ! chmod 700 "$state_dir"; then + printf '%s\n' "[engraphis] unable to restrict state directory: $state_dir" >&2 + exit 1 + fi + if [ -n "$config_file" ]; then + if ! reject_linked_path "$config_file"; then + printf '%s\n' "[engraphis] refusing linked or unnormalized trusted config path: $config_file" >&2 + exit 1 + fi + config_parent=$(dirname "$config_file") + if [ -L "$config_parent" ]; then + printf '%s\n' "[engraphis] refusing symlinked trusted config directory: $config_parent" >&2 + exit 1 + fi + if [ -e "$config_parent" ] && [ ! -d "$config_parent" ]; then + printf '%s\n' "[engraphis] refusing non-directory trusted config parent: $config_parent" >&2 + exit 1 + fi + if ! create_private_runtime_directory "$config_parent"; then + printf '%s\n' "[engraphis] unable to create config directory: $config_parent" >&2 + exit 1 + fi + # Check external existing parents before creating or changing any file. + # Parents under /data receive the volume's first-boot ownership repair. + case "$config_parent" in + /data|/data/*) ;; + *) + config_owner=$(stat -c '%u' "$config_parent" 2>/dev/null || true) + if [ "$config_owner" != "$app_owner" ]; then + printf '%s\n' "[engraphis] trusted config directory must be owned by engraphis: $config_parent" >&2 + exit 1 + fi + ;; + esac + if ! reject_linked_path "$config_file"; then + printf '%s\n' "[engraphis] refusing symlinked trusted config file: $config_file" >&2 + exit 1 + fi + if [ ! -e "$config_file" ] && ! : > "$config_file"; then + printf '%s\n' "[engraphis] unable to create trusted config file: $config_file" >&2 + exit 1 + fi + if ! reject_linked_path "$config_file" || [ ! -f "$config_file" ] \ + || [ "$(stat -c '%h' "$config_file" 2>/dev/null)" != "1" ]; then + printf '%s\n' "[engraphis] refusing changed, hard-linked, or non-regular trusted config file: $config_file" >&2 + exit 1 + fi + if ! chmod 600 "$config_file"; then + printf '%s\n' "[engraphis] unable to restrict trusted config file: $config_file" >&2 + exit 1 + fi + fi + # The app user owns the persistent marker after first boot. Fail closed if it has + # replaced that trusted root-startup input with a symlink or a non-regular path: + # chown follows symlinks by default and would otherwise let the marker redirect + # root's ownership change to an arbitrary target on the mounted volume. + if [ -L "$ownership_marker" ] || ! reject_linked_path "$ownership_marker"; then + printf '%s\n' "[engraphis] refusing symlinked volume ownership marker: $ownership_marker" >&2 + exit 1 + fi + if [ ! -e "$ownership_marker" ]; then + if ! chown -R -h engraphis:engraphis /data; then + printf '%s\n' "[engraphis] unable to repair /data ownership" >&2 + exit 1 + fi + if ! : > "$ownership_marker"; then + printf '%s\n' "[engraphis] unable to create volume ownership marker" >&2 + exit 1 + fi + if ! chown engraphis:engraphis "$ownership_marker"; then + printf '%s\n' "[engraphis] unable to own volume ownership marker" >&2 + exit 1 + fi + elif [ ! -f "$ownership_marker" ]; then + printf '%s\n' "[engraphis] refusing non-regular volume ownership marker: $ownership_marker" >&2 + exit 1 + elif [ "$(stat -c '%h' "$ownership_marker" 2>/dev/null)" != "1" ]; then + printf '%s\n' "[engraphis] refusing hard-linked volume ownership marker: $ownership_marker" >&2 + exit 1 + elif ! repair_volume_descendants /data "$app_owner" \ + || ! chown engraphis:engraphis /data "$state_dir" "$ownership_marker"; then + printf '%s\n' "[engraphis] unable to verify /data ownership" >&2 + exit 1 + fi + if [ -n "$config_file" ]; then + # A pre-existing config directory may be a separate root-owned mount. Do not + # chown an arbitrary existing host path; fail closed if it is unusable instead + # of starting a dashboard whose settings silently cannot persist. + config_owner=$(stat -c '%u' "$config_parent" 2>/dev/null || true) + if [ -z "$config_owner" ] || [ "$config_owner" != "$app_owner" ]; then + printf '%s\n' "[engraphis] trusted config directory must be owned by engraphis: $config_parent" >&2 + exit 1 + fi + fi + if [ -n "$config_file" ]; then + if ! reject_linked_path "$config_file" || [ ! -f "$config_file" ] \ + || [ "$(stat -c '%h' "$config_file" 2>/dev/null)" != "1" ]; then + printf '%s\n' "[engraphis] refusing changed trusted config file: $config_file" >&2 + exit 1 + fi + if ! chown engraphis:engraphis "$config_file"; then + printf '%s\n' "[engraphis] unable to own trusted config file" >&2 + exit 1 + fi + fi exec gosu engraphis "$@" fi +# Explicit trusted settings are required by the configuration loader. A rootless +# container with a writable volume must provision them before the app imports it. +state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" +config_file="${ENGRAPHIS_ENV_FILE:-}" +if ! reject_linked_path "$state_dir"; then + printf '%s\n' "[engraphis] refusing linked or unnormalized state path: $state_dir" >&2 + exit 1 +fi +if ! mkdir -p "$state_dir" || ! reject_linked_path "$state_dir" || [ ! -d "$state_dir" ] || ! chmod 700 "$state_dir"; then + printf '%s\n' "[engraphis] unable to initialize private state directory: $state_dir" >&2 + exit 1 +fi +if [ -n "$config_file" ]; then + if ! reject_linked_path "$config_file"; then + printf '%s\n' "[engraphis] refusing linked or unnormalized trusted config path: $config_file" >&2 + exit 1 + fi + config_parent=$(dirname "$config_file") + if ! mkdir -p "$config_parent" || ! reject_linked_path "$config_parent" || [ ! -d "$config_parent" ]; then + printf '%s\n' "[engraphis] unable to initialize trusted config directory: $config_parent" >&2 + exit 1 + fi + config_owner=$(stat -c '%u' "$config_parent" 2>/dev/null || true) + if [ "$config_owner" != "$(id -u)" ]; then + printf '%s\n' "[engraphis] trusted config directory must belong to the runtime user: $config_parent" >&2 + exit 1 + fi + if [ ! -e "$config_file" ] && ! : > "$config_file"; then + printf '%s\n' "[engraphis] unable to create trusted config file: $config_file" >&2 + exit 1 + fi + if ! reject_linked_path "$config_file" || [ ! -f "$config_file" ] \ + || [ "$(stat -c '%h' "$config_file" 2>/dev/null)" != "1" ] || ! chmod 600 "$config_file"; then + printf '%s\n' "[engraphis] unable to restrict trusted config file: $config_file" >&2 + exit 1 + fi +fi + exec "$@" diff --git a/docs/DOCKER.md b/docs/DOCKER.md index cd664eb5..37b7a964 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -25,6 +25,20 @@ ENGRAPHIS_COMPOSE_PORT=8787 Then open `http://127.0.0.1:8787`. License issuance, trials, leases, and revocations remain on the private control plane. +The root entrypoint repairs ownership only for the managed `/data` volume. When a custom +container configuration places `ENGRAPHIS_STATE_DIR` outside `/data`, create that directory +with the container's `engraphis` UID as owner and provision its contents for that UID before +startup; external descendants are never recursively repaired. Existing external config parent +directories must also belong to that UID; startup rejects other owners before changing files. +The repair marker lives at `/data/.volume-ownership`, so reusing external state cannot skip +the repair of a replaced data volume. Restarts scan for ownership drift before trusting an +existing marker, repairing restored files when needed without following external symlinks. +Correctly owned volumes avoid recursive ownership writes, but still require a metadata scan. +State directories use mode `0700`; trusted settings must have a single hard link and use +mode `0600`. A container started with +`--user` or `runAsUser` initializes a missing settings file when its state volume is writable +by that runtime UID; it preserves the file on restart. + > Port precedence: the dashboard binds `$PORT` when the platform injects one, falling back > to `ENGRAPHIS_PORT` (then `8700`). Compose sets both from `ENGRAPHIS_COMPOSE_PORT` so the > published host port and the in-container bind stay in sync; a stray desktop `ENGRAPHIS_PORT` diff --git a/docs/HOSTING_RAILWAY.md b/docs/HOSTING_RAILWAY.md index df588076..4cc6c04b 100644 --- a/docs/HOSTING_RAILWAY.md +++ b/docs/HOSTING_RAILWAY.md @@ -13,13 +13,20 @@ Use the `Dockerfile`, mount a private persistent volume at `/data`, and configur ```dotenv ENGRAPHIS_SERVICE_MODE=customer +ENGRAPHIS_HOST=0.0.0.0 ENGRAPHIS_DB_PATH=/data/engraphis.db ENGRAPHIS_STATE_DIR=/data/.engraphis +ENGRAPHIS_ENV_FILE=/data/.engraphis/config.env ENGRAPHIS_API_TOKEN= ENGRAPHIS_JSON_LOGS=1 ENGRAPHIS_FORWARDED_ALLOW_IPS=* ``` +Keep the image entrypoint and default command (`engraphis-dashboard --no-open`) in place. A +Railway service-level Start Command override can bypass `docker-entrypoint.sh`, which is +responsible for volume ownership repair, and can leave the app bound only to loopback. Clear +old Start Command overrides before deploying this image. + Set `ENGRAPHIS_FORWARDED_ALLOW_IPS=*` only when the container is reachable exclusively through Railway's trusted proxy. Set the dashboard's public URL where the runtime supports it, terminate TLS at the platform edge, and keep the volume private. diff --git a/docs/RAILWAY_TEMPLATE.md b/docs/RAILWAY_TEMPLATE.md index e7324751..6e3d36a8 100644 --- a/docs/RAILWAY_TEMPLATE.md +++ b/docs/RAILWAY_TEMPLATE.md @@ -8,7 +8,9 @@ issuer, relay, managed compute, Auto Dreaming, Auto Consolidation, or Team ident - Source: `Coding-Dev-Tools/engraphis`, branch `main`, `Dockerfile` build. - Service mode: `customer`. +- Bind host: `0.0.0.0` so Railway's injected `PORT` and public health probes reach the process. - Persistent volume: `/data`. +- Trusted runtime settings: `/data/.engraphis/config.env` on the persistent volume. - Health check: `/api/ready`. - `ENGRAPHIS_DASHBOARD_URL` derived from Railway's generated public domain (override it with the canonical HTTPS custom domain once one is active so public MCP origin checks remain strict). diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py new file mode 100644 index 00000000..4b69dfc3 --- /dev/null +++ b/tests/test_container_entrypoint.py @@ -0,0 +1,170 @@ +"""Exercise the actual POSIX path validator without running privileged startup.""" +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + + +pytestmark = pytest.mark.skipif(os.name == "nt" or not shutil.which("sh"), + reason="POSIX path and symlink semantics required") + + +def _validate(path: str) -> int: + entrypoint = (Path(__file__).resolve().parents[1] / "docker-entrypoint.sh").read_text() + body = entrypoint.split("reject_linked_path() {", 1)[1].split("\n}", 1)[0] + script = 'reject_linked_path() {' + body + '\n}\nreject_linked_path "$1"\n' + return subprocess.run(["sh", "-c", script, "validator", path], check=False).returncode + + +@pytest.mark.parametrize("path", ["relative/config.env", "../config.env", "/tmp/../etc/config.env"]) +def test_root_path_validation_rejects_relative_and_parent_traversal(path): + assert _validate(path) != 0 + + +def test_root_path_validation_checks_intermediate_symlinks_before_dot_segments(tmp_path): + target = tmp_path / "target" + target.mkdir() + (target / "nested").mkdir() + link = tmp_path / "link" + link.symlink_to(target, target_is_directory=True) + assert _validate(str(link / "nested" / "config.env")) != 0 + assert _validate(str(link) + "/../config.env") != 0 + assert _validate(str(target / "nested" / "config.env")) == 0 + assert _validate(str(tmp_path / "new" / "config.env")) == 0 + + +def test_external_state_requires_an_existing_app_owned_directory(tmp_path): + entrypoint = (Path(__file__).resolve().parents[1] / "docker-entrypoint.sh").read_text() + body = entrypoint.split("state_directory_is_owned() {", 1)[1].split("\n}", 1)[0] + script = 'state_directory_is_owned() {' + body + '\n}\nstate_directory_is_owned "$1" "$2"\n' + owner = tmp_path.stat().st_uid + + def check(path, uid): + return subprocess.run(["sh", "-c", script, "validator", str(path), str(uid)], + check=False).returncode + + assert check(tmp_path, owner) == 0 + assert check(tmp_path, owner + 1) != 0 + assert check(tmp_path / "missing", owner) != 0 + assert check("/data/new-state", owner) == 0 + + +def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path): + managed = tmp_path / "data" + external = tmp_path / "external-state" + binaries = tmp_path / "bin" + for directory in (managed, external, binaries): + directory.mkdir() + legacy_marker = external / ".volume-ownership" + legacy_marker.write_text("older external volume") + log = tmp_path / "chown.log" + shims = { + "id": 'case "$*" in "-u engraphis") printf "%s\\n" "$APP_UID";; *) echo 0;; esac\n', + "chown": 'printf "%s\\n" "$*" >> "$CHOWN_LOG"\n', + "gosu": 'shift\nexec "$@"\n', + } + for name, body in shims.items(): + executable = binaries / name + executable.write_text("#!/bin/sh\n" + body) + executable.chmod(0o755) + entrypoint = (Path(__file__).resolve().parents[1] / "docker-entrypoint.sh").read_text() + # Remap only the managed volume in this unprivileged startup exercise. + entrypoint = entrypoint.replace('ownership_marker="/data/.volume-ownership"', + 'ownership_marker="$MANAGED_VOLUME/.volume-ownership"') + entrypoint = entrypoint.replace('chown -R -h engraphis:engraphis /data', + 'chown -R -h engraphis:engraphis "$MANAGED_VOLUME"') + entrypoint = entrypoint.replace('repair_volume_descendants /data', + 'repair_volume_descendants "$MANAGED_VOLUME"') + entrypoint = entrypoint.replace('chown engraphis:engraphis /data', + 'chown engraphis:engraphis "$MANAGED_VOLUME"') + script = tmp_path / "entrypoint.sh" + script.write_text(entrypoint) + env = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ["PATH"], + "APP_UID": str(external.stat().st_uid), "CHOWN_LOG": str(log), + "MANAGED_VOLUME": str(managed), "ENGRAPHIS_STATE_DIR": str(external), + "ENGRAPHIS_ENV_FILE": str(external / "new" / "deep" / "config.env")} + subprocess.run(["sh", str(script), "true"], env=env, check=True) + assert f"-R -h engraphis:engraphis {managed}" in log.read_text().splitlines() + for directory in (external / "new", external / "new" / "deep"): + assert f"engraphis:engraphis {directory}" in log.read_text().splitlines() + assert (managed / ".volume-ownership").is_file() + assert legacy_marker.read_text() == "older external volume" + assert external.stat().st_mode & 0o777 == 0o700 + + log.write_text("") + subprocess.run(["sh", str(script), "true"], env=env, check=True) + assert not any(line.startswith("-R ") for line in log.read_text().splitlines()) + + marker_alias = managed / "marker-alias" + os.link(managed / ".volume-ownership", marker_alias) + log.write_text("") + rejected = subprocess.run(["sh", str(script), "true"], env=env, check=False) + assert rejected.returncode != 0 + assert not log.read_text() + + config = Path(env["ENGRAPHIS_ENV_FILE"]) + alias = external / "config-alias.env" + os.link(config, alias) + config.chmod(0o640) + rejected = subprocess.run(["sh", str(script), "true"], env=env, check=False) + assert rejected.returncode != 0 + assert alias.stat().st_mode & 0o777 == 0o640 + + +def test_volume_scan_repairs_mismatched_ownership_without_rewriting_owned_files(tmp_path): + volume = tmp_path / "data" + volume.mkdir() + database = volume / "engraphis.db" + database.write_text("retained database") + binaries = tmp_path / "bin" + binaries.mkdir() + chown = binaries / "chown" + chown.write_text('#!/bin/sh\nprintf "%s\\n" "$*"\n') + chown.chmod(0o755) + entrypoint = (Path(__file__).resolve().parents[1] / "docker-entrypoint.sh").read_text() + body = entrypoint.split("repair_volume_descendants() {", 1)[1].split("\n}", 1)[0] + script = 'repair_volume_descendants() {' + body + '\n}\nrepair_volume_descendants "$1" "$2"\n' + env = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ["PATH"]} + owner = volume.stat().st_uid + for uid, expected in ((owner, ""), (owner + 1, f"-R -h engraphis:engraphis {volume}\n")): + result = subprocess.run(["sh", "-c", script, "validator", str(volume), str(uid)], + env=env, text=True, capture_output=True, check=True) + assert result.stdout == expected + assert database.read_text() == "retained database" + + +def test_non_root_first_boot_initializes_private_state_and_config(tmp_path): + binaries = tmp_path / "bin" + binaries.mkdir() + identity = binaries / "id" + # Use the actual fixture owner, while selecting the rootless startup branch + # even when this regression runs under a privileged container test runner. + identity.write_text('#!/bin/sh\nprintf "%s\\n" "$APP_UID"\n') + identity.chmod(0o755) + entrypoint = (Path(__file__).resolve().parents[1] / "docker-entrypoint.sh").read_text() + entrypoint = entrypoint.replace('if [ "$(id -u)" = "0" ]; then', 'if false; then', 1) + script = tmp_path / "entrypoint.sh" + script.write_text(entrypoint) + state = tmp_path / "fresh-state" + config = state / "config.env" + env = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ["PATH"], + "APP_UID": str(tmp_path.stat().st_uid), "ENGRAPHIS_STATE_DIR": str(state), + "ENGRAPHIS_ENV_FILE": str(config)} + + subprocess.run(["sh", str(script), "true"], env=env, check=True) + + assert state.stat().st_mode & 0o777 == 0o700 + assert config.stat().st_mode & 0o777 == 0o600 + config.write_text("preserved=true\n") + subprocess.run(["sh", str(script), "true"], env=env, check=True) + assert config.read_text() == "preserved=true\n" + + alias = state / "config-alias.env" + os.link(config, alias) + config.chmod(0o640) + rejected = subprocess.run(["sh", str(script), "true"], env=env, check=False) + assert rejected.returncode != 0 + assert alias.stat().st_mode & 0o777 == 0o640 + assert alias.read_text() == "preserved=true\n" diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 1cf839a6..5f69a0de 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -50,12 +50,37 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): assert "useradd --create-home --uid 10001 engraphis" in dockerfile assert "HF_HOME=/data/.cache/huggingface" in dockerfile assert "ENGRAPHIS_STATE_DIR=/data/.engraphis" in dockerfile + assert "ENGRAPHIS_ENV_FILE=/data/.engraphis/config.env" in dockerfile + assert "COPY deploy ./deploy" in dockerfile assert 'if [ -z "${ENGRAPHIS_HOST:-}" ]; then' in entrypoint assert '[ -n "${RAILWAY_SERVICE_NAME:-}" ]' in entrypoint assert "ENGRAPHIS_HOST=\"::\"" in entrypoint assert "ENGRAPHIS_HOST=\"0.0.0.0\"" in entrypoint - assert "chown -R engraphis:engraphis /data" in entrypoint + assert "chown -R -h engraphis:engraphis /data" in entrypoint + assert ".volume-ownership" in entrypoint + assert "reject_linked_path()" in entrypoint + assert 'if ! reject_linked_path "$state_dir"; then' in entrypoint + assert "refusing linked or unnormalized state path" in entrypoint + assert 'if ! reject_linked_path "$config_file"; then' in entrypoint + assert "refusing linked or unnormalized trusted config path" in entrypoint + assert 'if [ -L "$state_dir" ]; then' in entrypoint + assert "refusing symlinked state directory" in entrypoint + assert 'elif [ -e "$state_dir" ] && [ ! -d "$state_dir" ]; then' in entrypoint + assert "refusing non-directory state path" in entrypoint + assert 'if [ -L "$config_parent" ]; then' in entrypoint + assert "refusing non-directory trusted config parent" in entrypoint + assert "config_owner=$(stat -c '%u' \"$config_parent\"" in entrypoint + assert "trusted config directory must be owned by engraphis" in entrypoint + assert '[ -L "$ownership_marker" ]' in entrypoint + assert "refusing symlinked volume ownership marker" in entrypoint + assert 'if [ ! -e "$ownership_marker" ]; then' in entrypoint + assert 'elif [ ! -f "$ownership_marker" ]; then' in entrypoint + assert "refusing non-regular volume ownership marker" in entrypoint + assert 'config_file="${ENGRAPHIS_ENV_FILE:-}"' in entrypoint + assert "refusing symlinked trusted config file" in entrypoint + assert 'chmod 600 "$config_file"' in entrypoint + assert "chown -R engraphis:engraphis /data 2>/dev/null || true" not in entrypoint assert 'exec gosu engraphis "$@"' in entrypoint diff --git a/tests/test_release_infrastructure.py b/tests/test_release_infrastructure.py index 8cf31679..da8ce80e 100644 --- a/tests/test_release_infrastructure.py +++ b/tests/test_release_infrastructure.py @@ -22,6 +22,11 @@ def test_published_image_and_railway_template_fail_safe_to_customer_mode(): assert railway["$schema"] == "https://railway.com/railway.schema.json" assert template["format"] == "engraphis-railway-template-composer-source/v1" assert template["variables"]["ENGRAPHIS_SERVICE_MODE"]["value"] == "customer" + assert template["variables"]["ENGRAPHIS_HOST"]["value"] == "0.0.0.0" + assert ( + template["variables"]["ENGRAPHIS_ENV_FILE"]["value"] + == "/data/.engraphis/config.env" + ) assert template["service"]["healthcheck"] == "/api/ready" assert template["service"]["volume"]["mount_path"] == "/data" local_api = template["variables"]["ENGRAPHIS_API_TOKEN"]