From 4e085f312cf51ff335c8b31673673ea8a2fa4ee5 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 16 Sep 2026 01:50:00 -0400 Subject: [PATCH 01/13] fix: harden Railway startup persistence and readiness --- Dockerfile | 6 ++- deploy/railway-template.json | 9 ++++ docker-entrypoint.sh | 63 ++++++++++++++++++++++++---- docs/HOSTING_RAILWAY.md | 7 ++++ docs/RAILWAY_TEMPLATE.md | 2 + tests/test_railway_runtime.py | 8 ++++ tests/test_release_infrastructure.py | 5 +++ 7 files changed, 90 insertions(+), 10 deletions(-) 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..0f68142a 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,10 +6,11 @@ # 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 recursively walking a large Hugging Face cache on every restart. +# When not running as root (e.g. a local `docker run` that already dropped privileges) this is +# a no-op passthrough. set -e # Default bind host, decided at runtime (not baked into the image). Uvicorn's `::` @@ -27,11 +28,55 @@ if [ -z "${ENGRAPHIS_HOST:-}" ]; then 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; later restarts verify the mount and state roots without walking the cache. + state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" + ownership_marker="${state_dir}/.volume-ownership" + config_file="${ENGRAPHIS_ENV_FILE:-}" + if ! mkdir -p "$state_dir"; then + printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2 + exit 1 + fi + if [ -n "$config_file" ]; then + config_parent=$(dirname "$config_file") + if ! mkdir -p "$config_parent"; then + printf '%s\n' "[engraphis] unable to create config directory: $config_parent" >&2 + exit 1 + fi + if [ -L "$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 ! chmod 600 "$config_file"; then + printf '%s\n' "[engraphis] unable to restrict trusted config file: $config_file" >&2 + exit 1 + fi + fi + if [ ! -e "$ownership_marker" ]; then + if ! chown -R 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 ! 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" ] && ! chown engraphis:engraphis "$config_file"; then + printf '%s\n' "[engraphis] unable to own trusted config file" >&2 + exit 1 + fi exec gosu engraphis "$@" fi 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_railway_runtime.py b/tests/test_railway_runtime.py index 1cf839a6..ef19b5c7 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -50,12 +50,20 @@ 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 ".volume-ownership" in entrypoint + assert 'if [ ! -e "$ownership_marker" ]; then' 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"] From aff0a37fd180ecd235bc94cb34d5b10559304d92 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 16 Sep 2026 03:40:53 -0400 Subject: [PATCH 02/13] fix: reject unsafe Railway ownership markers --- docker-entrypoint.sh | 11 +++++++++++ tests/test_railway_runtime.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0f68142a..3144a761 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -56,6 +56,14 @@ if [ "$(id -u)" = "0" ]; then 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" ]; then + printf '%s\n' "[engraphis] refusing symlinked volume ownership marker: $ownership_marker" >&2 + exit 1 + fi if [ ! -e "$ownership_marker" ]; then if ! chown -R engraphis:engraphis /data; then printf '%s\n' "[engraphis] unable to repair /data ownership" >&2 @@ -69,6 +77,9 @@ if [ "$(id -u)" = "0" ]; 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 ! chown engraphis:engraphis /data "$state_dir" "$ownership_marker"; then printf '%s\n' "[engraphis] unable to verify /data ownership" >&2 exit 1 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index ef19b5c7..0093cdf5 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -59,7 +59,11 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): assert "ENGRAPHIS_HOST=\"0.0.0.0\"" in entrypoint assert "chown -R engraphis:engraphis /data" in entrypoint assert ".volume-ownership" in entrypoint + assert 'if [ -L "$ownership_marker" ]; then' 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 From 2e52158582d118ac8c4c4636b408d0d660d037d7 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 16 Sep 2026 03:48:01 -0400 Subject: [PATCH 03/13] fix: reject unsafe Railway state directories --- docker-entrypoint.sh | 9 +++++++++ tests/test_railway_runtime.py | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3144a761..08ed9f83 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -33,6 +33,15 @@ if [ "$(id -u)" = "0" ]; then state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" ownership_marker="${state_dir}/.volume-ownership" config_file="${ENGRAPHIS_ENV_FILE:-}" + # 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 ! mkdir -p "$state_dir"; then printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2 exit 1 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 0093cdf5..60e80872 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -59,6 +59,10 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): assert "ENGRAPHIS_HOST=\"0.0.0.0\"" in entrypoint assert "chown -R engraphis:engraphis /data" in entrypoint assert ".volume-ownership" 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 "$ownership_marker" ]; then' in entrypoint assert "refusing symlinked volume ownership marker" in entrypoint assert 'if [ ! -e "$ownership_marker" ]; then' in entrypoint From 5bc085a56cc0cdcbe3226d503d80f2d67d51b23a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 16 Sep 2026 04:01:51 -0400 Subject: [PATCH 04/13] fix: own newly created Railway config directories --- docker-entrypoint.sh | 21 +++++++++++++++++++++ tests/test_railway_runtime.py | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 08ed9f83..13461d1f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -48,10 +48,31 @@ if [ "$(id -u)" = "0" ]; then fi if [ -n "$config_file" ]; then 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 + config_parent_created=0 + if [ ! -e "$config_parent" ]; then + config_parent_created=1 + elif [ ! -d "$config_parent" ]; then + printf '%s\n' "[engraphis] refusing non-directory trusted config parent: $config_parent" >&2 + exit 1 + fi if ! mkdir -p "$config_parent"; then printf '%s\n' "[engraphis] unable to create config directory: $config_parent" >&2 exit 1 fi + if [ "$config_parent_created" = "1" ]; then + if [ -L "$config_parent" ] || [ ! -d "$config_parent" ]; then + printf '%s\n' "[engraphis] refusing changed trusted config directory: $config_parent" >&2 + exit 1 + fi + if ! chown engraphis:engraphis "$config_parent"; then + printf '%s\n' "[engraphis] unable to own trusted config directory" >&2 + exit 1 + fi + fi if [ -L "$config_file" ]; then printf '%s\n' "[engraphis] refusing symlinked trusted config file: $config_file" >&2 exit 1 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 60e80872..3ed83d1f 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -63,6 +63,11 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): 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 'config_parent_created=0' in entrypoint + assert "refusing non-directory trusted config parent" in entrypoint + assert 'if [ "$config_parent_created" = "1" ]; then' in entrypoint + assert 'chown engraphis:engraphis "$config_parent"' in entrypoint assert 'if [ -L "$ownership_marker" ]; then' in entrypoint assert "refusing symlinked volume ownership marker" in entrypoint assert 'if [ ! -e "$ownership_marker" ]; then' in entrypoint From f0c77f5599c2ce0195e957e247a864e2b26c8bf5 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 16 Sep 2026 04:13:31 -0400 Subject: [PATCH 05/13] fix: reject linked Railway config ancestors --- docker-entrypoint.sh | 47 +++++++++++++++++++++++++++++++++++ tests/test_railway_runtime.py | 5 ++++ 2 files changed, 52 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 13461d1f..188d0e07 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -28,11 +28,54 @@ if [ -z "${ENGRAPHIS_HOST:-}" ]; then fi if [ "$(id -u)" = "0" ]; then + # 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 + } + # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on # first boot; later restarts verify the mount and state roots without walking the cache. state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" ownership_marker="${state_dir}/.volume-ownership" 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 # 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 @@ -47,6 +90,10 @@ if [ "$(id -u)" = "0" ]; then 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 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 3ed83d1f..a23f9eb2 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -59,6 +59,11 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): assert "ENGRAPHIS_HOST=\"0.0.0.0\"" in entrypoint assert "chown -R 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 From 6ec3b4adb98a689b7be6369a25a34e4f0f4b2e17 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 16 Sep 2026 04:26:21 -0400 Subject: [PATCH 06/13] fix: fail closed for unusable Railway config mounts --- docker-entrypoint.sh | 11 +++++++++++ tests/test_railway_runtime.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 188d0e07..c2854635 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -161,6 +161,17 @@ if [ "$(id -u)" = "0" ]; 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) + app_owner=$(id -u engraphis) + 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" ] && ! chown engraphis:engraphis "$config_file"; then printf '%s\n' "[engraphis] unable to own trusted config file" >&2 exit 1 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index a23f9eb2..86e36985 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -73,6 +73,8 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): assert "refusing non-directory trusted config parent" in entrypoint assert 'if [ "$config_parent_created" = "1" ]; then' in entrypoint assert 'chown engraphis:engraphis "$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 'if [ -L "$ownership_marker" ]; then' in entrypoint assert "refusing symlinked volume ownership marker" in entrypoint assert 'if [ ! -e "$ownership_marker" ]; then' in entrypoint From 1a32f1d005ed5440dc20aa83d7aff211bf7898b3 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 07:50:44 -0400 Subject: [PATCH 07/13] fix: revalidate trusted container paths before ownership changes --- docker-entrypoint.sh | 22 ++++++++++++++----- tests/test_container_entrypoint.py | 35 ++++++++++++++++++++++++++++++ tests/test_railway_runtime.py | 2 +- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 tests/test_container_entrypoint.py diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c2854635..16c3f30b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -111,7 +111,7 @@ if [ "$(id -u)" = "0" ]; then exit 1 fi if [ "$config_parent_created" = "1" ]; then - if [ -L "$config_parent" ] || [ ! -d "$config_parent" ]; then + if ! reject_linked_path "$config_parent" || [ ! -d "$config_parent" ]; then printf '%s\n' "[engraphis] refusing changed trusted config directory: $config_parent" >&2 exit 1 fi @@ -120,7 +120,7 @@ if [ "$(id -u)" = "0" ]; then exit 1 fi fi - if [ -L "$config_file" ]; then + if ! reject_linked_path "$config_file"; then printf '%s\n' "[engraphis] refusing symlinked trusted config file: $config_file" >&2 exit 1 fi @@ -128,6 +128,10 @@ if [ "$(id -u)" = "0" ]; 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" ]; then + printf '%s\n' "[engraphis] refusing changed 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 @@ -137,7 +141,7 @@ if [ "$(id -u)" = "0" ]; then # 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" ]; then + 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 @@ -172,9 +176,15 @@ if [ "$(id -u)" = "0" ]; then exit 1 fi fi - if [ -n "$config_file" ] && ! chown engraphis:engraphis "$config_file"; then - printf '%s\n' "[engraphis] unable to own trusted config file" >&2 - exit 1 + if [ -n "$config_file" ]; then + if ! reject_linked_path "$config_file" || [ ! -f "$config_file" ]; 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 diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py new file mode 100644 index 00000000..1ea97a09 --- /dev/null +++ b/tests/test_container_entrypoint.py @@ -0,0 +1,35 @@ +"""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 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 86e36985..06d0108e 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -75,7 +75,7 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): assert 'chown engraphis:engraphis "$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 'if [ -L "$ownership_marker" ]; then' 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 From 85390881a6a597a099ee215ee649b52fc3faf880 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 08:03:04 -0400 Subject: [PATCH 08/13] fix: preserve ownership of external container state directories --- docker-entrypoint.sh | 27 ++++++++++++++++++++++++++- docs/DOCKER.md | 5 +++++ tests/test_container_entrypoint.py | 16 ++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 16c3f30b..9b0b9701 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -67,11 +67,21 @@ if [ "$(id -u)" = "0" ]; then 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" ] + } + # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on # first boot; later restarts verify the mount and state roots without walking the cache. state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" ownership_marker="${state_dir}/.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 @@ -85,6 +95,10 @@ if [ "$(id -u)" = "0" ]; 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 ! mkdir -p "$state_dir"; then printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2 exit 1 @@ -120,6 +134,18 @@ if [ "$(id -u)" = "0" ]; then exit 1 fi 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 @@ -170,7 +196,6 @@ if [ "$(id -u)" = "0" ]; then # 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) - app_owner=$(id -u engraphis) 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 diff --git a/docs/DOCKER.md b/docs/DOCKER.md index cd664eb5..c947efab 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -25,6 +25,11 @@ 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 before startup. Existing external config parent +directories must also belong to that UID; startup rejects other owners before changing files. + > 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/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index 1ea97a09..334af2f8 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -33,3 +33,19 @@ def test_root_path_validation_checks_intermediate_symlinks_before_dot_segments(t 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 From f65fee2c512c2cc957c355740b8e62b044a154ed Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 08:26:08 -0400 Subject: [PATCH 09/13] fix: bind startup ownership marker to the managed data volume --- docker-entrypoint.sh | 4 ++- docs/DOCKER.md | 2 ++ tests/test_container_entrypoint.py | 42 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9b0b9701..fc243b7c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -79,7 +79,9 @@ if [ "$(id -u)" = "0" ]; then # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on # first boot; later restarts verify the mount and state roots without walking the cache. state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" - ownership_marker="${state_dir}/.volume-ownership" + # 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 diff --git a/docs/DOCKER.md b/docs/DOCKER.md index c947efab..ffc560ec 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -29,6 +29,8 @@ The root entrypoint repairs ownership only for the managed `/data` volume. When container configuration places `ENGRAPHIS_STATE_DIR` outside `/data`, create that directory with the container's `engraphis` UID as owner before startup. 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 or restored data volume. > 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 diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index 334af2f8..c669a9e6 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -49,3 +49,45 @@ def check(path, uid): 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 engraphis:engraphis /data', + 'chown -R engraphis:engraphis "$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": ""} + subprocess.run(["sh", str(script), "true"], env=env, check=True) + assert f"-R engraphis:engraphis {managed}" in log.read_text().splitlines() + assert (managed / ".volume-ownership").is_file() + assert legacy_marker.read_text() == "older external volume" + + 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()) From e8bd797e5940df9e1947f21d0bbc01e212ba1e6e Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 08:42:46 -0400 Subject: [PATCH 10/13] fix: initialize private state for rootless container startup --- docker-entrypoint.sh | 137 +++++++++++++++++++---------- docs/DOCKER.md | 3 + tests/test_container_entrypoint.py | 32 ++++++- 3 files changed, 122 insertions(+), 50 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index fc243b7c..4a143c30 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -9,9 +9,9 @@ # 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 recursively walking a large Hugging Face cache on every restart. -# When not running as root (e.g. a local `docker run` that already dropped privileges) this is -# a no-op passthrough. +# 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 @@ -27,55 +27,55 @@ if [ -z "${ENGRAPHIS_HOST:-}" ]; then export ENGRAPHIS_HOST fi -if [ "$(id -u)" = "0" ]; then - # 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 ;; +# 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 - 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 ;; + case "$component" in + ""|.) continue ;; + ..) return 1 ;; esac - [ -d "$1" ] && [ "$(stat -c '%u' "$1" 2>/dev/null)" = "$2" ] - } + 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" ] +} + +if [ "$(id -u)" = "0" ]; then # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on # first boot; later restarts verify the mount and state roots without walking the cache. state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" @@ -105,6 +105,10 @@ if [ "$(id -u)" = "0" ]; 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 @@ -216,4 +220,41 @@ if [ "$(id -u)" = "0" ]; then 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" ] || ! 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 ffc560ec..ff2595bc 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -31,6 +31,9 @@ with the container's `engraphis` UID as owner before startup. Existing external 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 or restored data volume. +State directories use mode `0700` and trusted settings use `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 diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index c669a9e6..da7021b1 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -13,7 +13,7 @@ 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] + 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 @@ -37,7 +37,7 @@ def test_root_path_validation_checks_intermediate_symlinks_before_dot_segments(t 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] + 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 @@ -87,7 +87,35 @@ def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path) assert f"-R engraphis:engraphis {managed}" 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()) + + +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" From 07cf507a1d3196d9abce5d3e9b0a6652d55e1fd8 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 09:00:07 -0400 Subject: [PATCH 11/13] fix(container): own newly created private directory ancestors --- docker-entrypoint.sh | 52 +++++++++++++++++++++--------- tests/test_container_entrypoint.py | 4 ++- tests/test_railway_runtime.py | 3 -- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 4a143c30..502e6bf4 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -75,6 +75,39 @@ state_directory_is_owned() { [ -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 +} + if [ "$(id -u)" = "0" ]; then # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on # first boot; later restarts verify the mount and state roots without walking the cache. @@ -101,7 +134,7 @@ if [ "$(id -u)" = "0" ]; then printf '%s\n' "[engraphis] external state directory must already be owned by engraphis: $state_dir" >&2 exit 1 fi - if ! mkdir -p "$state_dir"; then + if ! create_private_runtime_directory "$state_dir"; then printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2 exit 1 fi @@ -119,27 +152,14 @@ if [ "$(id -u)" = "0" ]; then printf '%s\n' "[engraphis] refusing symlinked trusted config directory: $config_parent" >&2 exit 1 fi - config_parent_created=0 - if [ ! -e "$config_parent" ]; then - config_parent_created=1 - elif [ ! -d "$config_parent" ]; then + 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 ! mkdir -p "$config_parent"; then + if ! create_private_runtime_directory "$config_parent"; then printf '%s\n' "[engraphis] unable to create config directory: $config_parent" >&2 exit 1 fi - if [ "$config_parent_created" = "1" ]; then - if ! reject_linked_path "$config_parent" || [ ! -d "$config_parent" ]; then - printf '%s\n' "[engraphis] refusing changed trusted config directory: $config_parent" >&2 - exit 1 - fi - if ! chown engraphis:engraphis "$config_parent"; then - printf '%s\n' "[engraphis] unable to own trusted config directory" >&2 - exit 1 - fi - 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 diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index da7021b1..9feb979b 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -82,9 +82,11 @@ def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path) 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": ""} + "ENGRAPHIS_ENV_FILE": str(external / "new" / "deep" / "config.env")} subprocess.run(["sh", str(script), "true"], env=env, check=True) assert f"-R 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 diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 06d0108e..0133e6b3 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -69,10 +69,7 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): 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 'config_parent_created=0' in entrypoint assert "refusing non-directory trusted config parent" in entrypoint - assert 'if [ "$config_parent_created" = "1" ]; then' in entrypoint - assert 'chown engraphis:engraphis "$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 From a6debb317c7e26ee9446408ec46ccb56278b3668 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 09:08:04 -0400 Subject: [PATCH 12/13] fix(container): repair restored descendants despite ownership marker --- docker-entrypoint.sh | 18 ++++++++++++++---- docs/DOCKER.md | 4 +++- tests/test_container_entrypoint.py | 30 +++++++++++++++++++++++++++--- tests/test_railway_runtime.py | 2 +- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 502e6bf4..cac6ea5e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,7 +8,7 @@ # # 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 recursively walking a large Hugging Face cache on every restart. +# 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 @@ -108,9 +108,18 @@ create_private_runtime_directory() { 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. Repair the complete volume only on - # first boot; later restarts verify the mount and state roots without walking the cache. + # 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. @@ -198,7 +207,7 @@ if [ "$(id -u)" = "0" ]; then exit 1 fi if [ ! -e "$ownership_marker" ]; then - if ! chown -R engraphis:engraphis /data; then + if ! chown -R -h engraphis:engraphis /data; then printf '%s\n' "[engraphis] unable to repair /data ownership" >&2 exit 1 fi @@ -213,7 +222,8 @@ if [ "$(id -u)" = "0" ]; then elif [ ! -f "$ownership_marker" ]; then printf '%s\n' "[engraphis] refusing non-regular volume ownership marker: $ownership_marker" >&2 exit 1 - elif ! chown engraphis:engraphis /data "$state_dir" "$ownership_marker"; then + 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 diff --git a/docs/DOCKER.md b/docs/DOCKER.md index ff2595bc..337ceaef 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -30,7 +30,9 @@ container configuration places `ENGRAPHIS_STATE_DIR` outside `/data`, create tha with the container's `engraphis` UID as owner before startup. 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 or restored data volume. +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` and trusted settings use `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. diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index 9feb979b..3dad81d5 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -73,8 +73,10 @@ def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path) # 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 engraphis:engraphis /data', - 'chown -R engraphis:engraphis "$MANAGED_VOLUME"') + 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" @@ -84,7 +86,7 @@ def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path) "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 engraphis:engraphis {managed}" in log.read_text().splitlines() + 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() @@ -96,6 +98,28 @@ def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path) assert not any(line.startswith("-R ") for line in log.read_text().splitlines()) +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() diff --git a/tests/test_railway_runtime.py b/tests/test_railway_runtime.py index 0133e6b3..5f69a0de 100644 --- a/tests/test_railway_runtime.py +++ b/tests/test_railway_runtime.py @@ -57,7 +57,7 @@ def test_container_runtime_matches_the_railway_persistence_and_port_contract(): 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 From c830f9e7e5100f595dc049838f2863435884259a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sat, 19 Sep 2026 09:38:22 -0400 Subject: [PATCH 13/13] fix(container): reject hard-linked privileged startup inputs --- docker-entrypoint.sh | 14 ++++++++++---- docs/DOCKER.md | 6 ++++-- tests/test_container_entrypoint.py | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index cac6ea5e..52265dd9 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -189,8 +189,9 @@ if [ "$(id -u)" = "0" ]; 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" ]; then - printf '%s\n' "[engraphis] refusing changed or non-regular trusted config file: $config_file" >&2 + 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 @@ -222,6 +223,9 @@ if [ "$(id -u)" = "0" ]; then 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 @@ -238,7 +242,8 @@ if [ "$(id -u)" = "0" ]; then fi fi if [ -n "$config_file" ]; then - if ! reject_linked_path "$config_file" || [ ! -f "$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 @@ -281,7 +286,8 @@ if [ -n "$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" ] || ! chmod 600 "$config_file"; then + 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 diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 337ceaef..37b7a964 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -27,13 +27,15 @@ Then open `http://127.0.0.1:8787`. License issuance, trials, leases, and revocat 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 before startup. Existing external config parent +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` and trusted settings use `0600`. A container started with +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. diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index 3dad81d5..4b69dfc3 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -97,6 +97,21 @@ def test_external_state_marker_cannot_skip_repair_of_a_replaced_volume(tmp_path) 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" @@ -145,3 +160,11 @@ def test_non_root_first_boot_initializes_private_state_and_config(tmp_path): 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"