Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow first boot when the container starts non-root

When the image is run with --user 10001 or a Kubernetes runAsUser and /data/.engraphis/config.env has not already been provisioned, the root-only entrypoint block never creates this newly explicit config file. Because config._load_trusted_dotenv() treats an explicit ENGRAPHIS_ENV_FILE as required (allow_missing=False), importing the configuration raises FileNotFoundError and the dashboard exits before serving; ensure the non-root path creates the file when writable or tolerates its initial absence.

Useful? React with 👍 / 👎.


WORKDIR /app

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions deploy/railway-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
172 changes: 163 additions & 9 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 `::`
Expand All @@ -27,11 +28,164 @@ 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
# 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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the ownership marker on the managed /data volume

When the supported ENGRAPHIS_STATE_DIR override points to a separately persistent external directory, this marker tracks that directory rather than the /data volume whose recursive ownership repair it controls. If the external state is reused while /data is replaced or restored with root-owned database/cache files, the stale marker skips chown -R; the later non-recursive chown changes only /data itself, so the app user can fail to open the existing files after gosu. Store the marker on /data or otherwise bind it to the actual data volume being repaired.

Useful? React with 👍 / 👎.

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
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create the state directory with private permissions

On a fresh /data volume under the image's normal 022 umask, this creates /data/.engraphis as mode 0755, and the later chown -R changes only ownership. The directory holds the trusted config and cloud-session files, so another UID sharing the volume can enumerate their names and metadata until a later settings/session write happens to harden the directory; create or chmod it as 0700 before dropping privileges.

AGENTS.md reference: AGENTS.md:L48-L48

Useful? React with 👍 / 👎.

printf '%s\n' "[engraphis] unable to create 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
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
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 ! 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Transfer ownership of every newly created config ancestor

When an allowed absolute config path has multiple missing directories under an app-owned ancestor (for example, /config/new/deep/config.env), the 077 umask makes every component created by mkdir -p root-owned and mode 0700, but this line transfers only deep; after gosu, engraphis cannot traverse new, so loading the required explicit config fails and the container exits. Fresh evidence in the current tree is that the new final-parent chown still leaves intermediate components created by the same mkdir -p untouched; transfer each newly created ancestor safely or reject paths requiring multiple new components.

AGENTS.md reference: AGENTS.md:L48-L48

Useful? React with 👍 / 👎.

printf '%s\n' "[engraphis] unable to own trusted config directory" >&2
exit 1
fi
fi
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" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject hard-linked trusted config files before mutating them

When a restored or externally provisioned config.env has multiple hard links, this regular-file check accepts it, so the root entrypoint applies chmod and later chown to the shared inode and therefore every alias. After privileges are dropped, read_private_text() rejects the same file because its link count is not one, causing startup to fail after unrelated aliases have already been modified; validate the link count before either privileged mutation.

Useful? React with 👍 / 👎.

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
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 engraphis:engraphis /data; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck descendants before trusting the ownership marker

When /data is restored from an archive or snapshot that includes .volume-ownership but recreates files as root, this branch skips the recursive repair and only chowns /data, the state directory, and the marker. Consequently, a root-owned /data/engraphis.db or cache subtree remains inaccessible after gosu, preventing the restored service from starting; moving the marker onto /data fixes replacement volumes without a marker, but does not make the documented restored-volume case safe.

Useful? React with 👍 / 👎.

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 ! chown engraphis:engraphis /data "$state_dir" "$ownership_marker"; then
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse chowning arbitrary existing state directories

When ENGRAPHIS_STATE_DIR names an existing root-owned directory outside /data (for example, /usr/local/bin), the first boot accepts it and creates/chowns only .volume-ownership; on the next boot this branch transfers ownership of the directory itself to engraphis. A compromised runtime can then replace /usr/local/bin/docker-entrypoint.sh, which root executes on the following restart. The current tree's new symlink/non-directory checks are fresh evidence that this remains reachable because a regular root-owned directory passes them; constrain the state directory to /data or require an external directory to already be app-owned instead of chowning it.

Useful? React with 👍 / 👎.

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" ]; 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

Expand Down
7 changes: 7 additions & 0 deletions docs/HOSTING_RAILWAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<strong-random-secret>
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.
Expand Down
2 changes: 2 additions & 0 deletions docs/RAILWAY_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
35 changes: 35 additions & 0 deletions tests/test_container_entrypoint.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/test_railway_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,40 @@ 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 "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 '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
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


Expand Down
5 changes: 5 additions & 0 deletions tests/test_release_infrastructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading