## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Docker image persists all instance state (project checkouts,
worktrees, run logs, uploads) under `PAPERCLIP_HOME`, and deployments
mount a volume there for durability
> - The entrypoint starts as root and drops privileges to the `node`
user, but it fixes `PAPERCLIP_HOME` ownership only when it remaps the
user's UID/GID
> - A freshly mounted volume arrives root-owned and shadows the image's
build-time `chown`, so a default-UID boot drops privileges onto an
unwritable home and the server crashes on its first `mkdir`
> - This pull request makes the entrypoint probe the home's ownership
and chown whenever it does not match the runtime user, before the
privilege drop
> - The benefit is that the image works out of the box on any
platform-managed volume, with the common already-correct boot staying
chown-free
## Linked Issues or Issue Description
No public issue exists — describing the bug inline (per the bug report
template).
**What happened?**
Running the image with a freshly created volume mounted at `/paperclip`
(a Docker named volume, a Kubernetes PV, or any platform-managed volume)
and the default `USER_UID`/`USER_GID` crashes on boot: `Error: EACCES:
permission denied, mkdir '/paperclip/instances/default/logs'`.
**Expected behavior**
The container boots and initializes its instance tree on the mounted
volume, exactly as it does when `/paperclip` is the image's own
(build-time chowned) directory.
**Steps to reproduce**
1. `docker volume create paperclip-data`
2. `docker run -v paperclip-data:/paperclip
ghcr.io/paperclipai/paperclip:<any current tag>`
3. Observe the EACCES crash on the first `mkdir` under `/paperclip`.
**Root cause**
`scripts/docker-entrypoint.sh` chowns `/paperclip` only inside its
UID/GID remap branch (`changed=1`). A fresh volume mount is root-owned
and shadows the image's build-time `chown node:node /paperclip`; with
the default 1000:1000 no remap happens, so no chown happens, and `gosu
node` drops onto an unwritable home.
**Paperclip version or commit:** reproduces on `master` and any
published image.
**Deployment mode:** any; observed on managed-cloud volume mounts and
reproducible with plain Docker named volumes.
**Installation method:** Docker image (`ghcr.io/paperclipai/paperclip`).
**Related PRs (dedup search):** no open or merged PR touches the
entrypoint ownership logic; the entrypoint's privilege-handling tests
were added previously and this extends them. No duplicate found.
## What Changed
- `scripts/docker-entrypoint.sh`: the remap-conditional `chown` is
replaced by an ownership probe — after any UID/GID remap, the entrypoint
stats `PAPERCLIP_HOME` (default `/paperclip`) and runs `chown -R
node:node` only when the owner does not match the runtime user, before
`exec gosu node`. Covers fresh root-owned mounts and trees written under
a previous UID mapping; the already-correct boot performs no chown. The
unprivileged (non-root start) branch is unchanged.
- `server/src/__tests__/docker-entrypoint.test.ts`: `stat` stub added to
the harness; new cases for the fresh root-owned mount with default
UID/GID and for `PAPERCLIP_HOME`-relative probing; the remap case now
models the post-remap ownership mismatch.
## Verification
- `pnpm vitest run server/src/__tests__/docker-entrypoint.test.ts` — 7
passed (5 existing behaviors unchanged, 2 new).
- Live on a managed deployment: a container that crash-looped with the
EACCES above boots cleanly once the home is chowned before the drop (the
same effect this entrypoint change produces; forced there by a UID remap
as an interim workaround).
## Risks
- Low. Behavior changes only for boots where `PAPERCLIP_HOME` exists
with mismatched ownership — exactly the boots that crash today. `chown
-R` on a large previously-mismatched tree adds one-time boot latency;
correctly-owned homes skip it entirely. Kubernetes restricted /
OpenShift non-root starts keep the existing exec-directly path
untouched.
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic; Claude Code CLI with
extended thinking and tool use; tests executed locally via Vitest).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (no duplicates; extends the existing entrypoint privilege
tests)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The reference container image must be deployable on both Docker
Compose (where it starts as root and `gosu`'s a `USER_UID`/`USER_GID`
switch) and Kubernetes (where the pod is typically constrained by
PodSecurity)
> - The Kubernetes operator (paperclipinc/paperclip-operator#45) sets
`runAsNonRoot: true`, `runAsUser: 1000`, `allowPrivilegeEscalation:
false`, and `drop: ALL` capabilities by default — the unconditional
`usermod` + `gosu` flow in the entrypoint requires root + `CAP_SETUID` /
`CAP_SETGID`, making the image undeployable on any cluster enforcing
baseline or restricted PodSecurity
> - Without root, neither the user remap nor `gosu` can ever succeed —
so the fix is a runtime branch: non-root starts `exec` the command
directly (warning if the runtime UID/GID differs from the requested
one), while root starts keep the existing `usermod`+`gosu` flow
> - This also covers platforms that assign arbitrary UIDs (OpenShift
restricted SCC), which previously crashed with a cryptic `usermod:
Permission denied`
> - The benefit is one image that works for both deployment shapes with
no operator-side workaround — pure superset, no breaking change
## Linked Issues or Issue Description
Refs paperclipinc/paperclip-operator#45 (cross-repo) — the operator's
default pod security context (`runAsNonRoot: true`, `runAsUser: 1000`,
`allowPrivilegeEscalation: false`, `drop: ALL`) is blocked by this
entrypoint behavior. The operator shipped a stopgap
(paperclipinc/paperclip-operator#46 lets the CRD override the security
context); this PR is the image-side fix that makes the secure defaults
work out of the box. Supersedes #2904 (v1 of this branch).
No in-repo issue covers this directly — problem described in-PR
following the bug-report template:
**What happened**
The entrypoint unconditionally runs `usermod`/`groupmod`/`chown` + `exec
gosu node`, which requires root plus `CAP_SETUID` / `CAP_SETGID` — any
non-root start crashes (`gosu` cannot drop privileges; a mismatched UID
dies earlier at `usermod: Permission denied`), making the reference
image undeployable on clusters enforcing baseline or restricted
PodSecurity.
**Expected behavior**
A non-root container `exec`s the command directly (with a clear warning
if its UID/GID differs from the requested `USER_UID`/`USER_GID`, since a
remap is impossible without root). The existing root + `usermod` +
`gosu` flow is preserved for Docker Compose, where the container starts
as root and switches to the requested UID/GID.
**Deployment mode**
Kubernetes with baseline/restricted PodSecurity and OpenShift-style
arbitrary-UID platforms (failing cases); Docker Compose root-start (must
keep working).
## What Changed
- **`scripts/docker-entrypoint.sh`** — branch on the runtime UID:
- **Non-root start** → `exec "$@"` directly. If the runtime UID/GID
differs from `USER_UID`/`USER_GID`, print a one-line warning to stderr
first (the remap is impossible without root; the warning keeps
volume-permission mismatches diagnosable instead of failing cryptically
inside `usermod`).
- **Root start** → unchanged: `usermod`/`groupmod` remap when requested,
`chown` of `/paperclip` when a remap happened, then `exec gosu node
"$@"`.
## Verification
**Automated:** `server/src/__tests__/docker-entrypoint.test.ts` runs the
real entrypoint with `id`/`usermod`/`groupmod`/`chown`/`gosu` stubbed
via PATH and asserts all five privilege branches (root+defaults,
root+remap, non-root match, arbitrary non-root UID, GID mismatch) — runs
in the regular server suite, no Docker needed.
**Manual (Docker):** ran the entrypoint on `node:lts-trixie-slim` (the
image's actual base) across the full matrix, with `gosu` stubbed to a
marker:
- [x] Root start, defaults → no remap, `gosu node` invoked (Docker
Compose flow unchanged)
- [x] Root start, `USER_UID=1001`/`USER_GID=1001` → `Updating node
UID/GID to 1001` + `gosu node` (remap flow unchanged)
- [x] Non-root `--user 1000:1000` (the operator's `runAsUser: 1000`
shape) → silent direct exec, command runs as 1000:1000
- [x] Non-root `--user 1234:1234` (arbitrary UID) → warning `running
unprivileged as 1234:1234; cannot remap to requested 1000:1000`, then
direct exec (previously: crash)
- [x] Non-root `--user 1000:1001` (GID mismatch) → warning, then direct
exec
- [x] Baseline check: master's entrypoint fails for any non-root start
(gosu/usermod require root)
End-to-end cluster verification under restricted PodSecurity exercises
the same branch as the `--user 1000:1000` case above; the operator
repo's deploy is the natural place for that smoke test once this ships
in an image tag.
## Risks
- **Backward-compatible.** Docker Compose / root-entrypoint path is
byte-for-byte the same flow — `usermod`/`gosu` runs whenever the
container starts as root.
- **Behavior change only for previously-broken starts.** Non-root
containers used to crash; they now run. The only observable difference
for a *working* deployment is none.
- **Mismatched non-root UID/GID warns instead of failing.** Deliberate:
the remap is impossible without root, and arbitrary-UID platforms
(OpenShift) rely on group-writable volumes; a hard fail would keep them
broken. The stderr warning preserves diagnosability.
- **No new env vars, no API surface.** Pure entrypoint behavior change
gated on the runtime UID.
- **Restricted PodSecurity ready.** The non-root branch needs no Linux
capabilities — works under `drop: ALL`.
## Model Used
Claude Opus 4.6; rebase, non-root generalization, and verification
matrix by Claude Fable 5 (1M context).
## Checklist
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — not in conflict with planned core work
(agent-runtime sandbox images use `tini`, no gosu — unaffected)
- [x] Tests run locally and pass (new `docker-entrypoint.test.ts`
covering all five privilege branches, plus a manual Docker matrix on the
real base image; see Verification)
- [x] No UI changes
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge
Unblocks the default (non-overridden) security context of
paperclipinc/paperclip-operator#45 / #46.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add wget, ripgrep, python3, and GitHub CLI (gh) to base image
- Add OPENCODE_ALLOW_ALL_MODELS=true to production ENV
- Move compose files, onboard-smoke Dockerfile to docker/
- Move entrypoint script to scripts/docker-entrypoint.sh
- Add Podman Quadlet unit files (pod, app, db containers)
- Add docker/README.md with build, compose, and quadlet docs
- Add scripts/docker-build-test.sh for local build validation
- Update all doc references for new file locations
- Keep main Dockerfile at project root (no .dockerignore changes needed)
Co-Authored-By: Paperclip <noreply@paperclip.ing>