From 341cfbcea802dae03954a472eab9a7e4269fc934 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 15 Oct 2026 12:45:33 -0700 Subject: [PATCH] fix(docker): remap the node user without walking the app home (#16475) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The container image sets the node user's home to the app home, `/paperclip`, which is the data volume in container deployments > - When `USER_UID`/`USER_GID` differ from the image, the entrypoint runs `usermod -u` and `usermod -g` before the app starts > - `usermod` also re-owns every file under the user's home, so every boot walks the whole data volume first > - On a large volume this delayed each boot by about six minutes, and platforms that stop the old container before starting the new one serve errors for that whole time > - This pull request runs the remap while the home points at a path that does not exist, then restores it > - The benefit is that a UID/GID remap takes milliseconds, and deploys no longer wait for a full volume walk ## Linked Issues or Issue Description Related: #15992 (skips the full-tree ownership probe on routine boots) and #10530 (ownership before the privilege drop). This removes the remaining per-boot full-tree walk. **What happened?** A deployment sets `USER_UID=1001`/`USER_GID=1001` on an image built with the default 1000. Every boot logs `Updating node UID to 1001`, and the next log line (`Updating node GID to 1001`) appeared six minutes later. During that time the app was not running. The volume holds many agent worktrees and dependency trees. No file had the old owner, so the walk changed nothing. **Expected behavior** Remapping the node account should not depend on the size of the data volume. Ownership repair belongs to the existing tiered ownership probe, which records a marker and skips the full walk on routine boots. **Steps to reproduce** 1. Build the image with the default `USER_UID=1000`. 2. Run it as root with `USER_UID=1001`, `USER_GID=1001` and a large app home. 3. Every boot spends the time of a full home walk in `usermod -o -u` before the app starts. Measured in the runtime image (Debian trixie) with 200,000 files under a user's home: `usermod -u` took 1,204 ms and re-owned every file. With the home moved away first, the three `usermod` calls took 124 ms and changed no file. ## What Changed - `scripts/docker-entrypoint.sh`: when a UID or GID remap is needed, read the node user's home with `getent passwd node` and point it at `/nonexistent/paperclip-uid-remap`. Then run the same UID and GID commands as before, and restore the home. Without a passwd entry, the remap runs as before. The ownership probe and repair logic are unchanged. After a real ID change, the home directory has the old owner, so the shallow probe detects the mismatch on the same boot and repairs the tree once. - `server/src/__tests__/docker-entrypoint.test.ts`: stubs `getent` and adds tests for the call order (UID+GID remap, GID-only remap), for repair after the remap, and for the fallback without a passwd entry. ## Verification - `pnpm exec vitest run src/__tests__/docker-entrypoint.test.ts` (in `server/`): 22 passed. - `sh -n scripts/docker-entrypoint.sh`: passed. - Manual check in the runtime image with a throwaway user and 200,000 files (numbers above). The UID changed, file owners were not touched, and the passwd home was restored. ## Risks - `usermod -d` without `-m` only edits the passwd entry. The home is restored right after the ID change. If the entrypoint fails between the two calls, `set -e` stops the container before the privilege drop, and the next boot sets the home again, so a partial state does not persist past a restart. - Ownership after a real remap now depends on the existing probe instead of `usermod`. The probe's tests already cover the remap, fresh-mount, deep-mismatch and marker cases. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code with tool use (shell, file edits, test runs, live measurement in a running container). ## 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 - [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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- scripts/docker-entrypoint.sh | 45 +++++++++- .../src/__tests__/docker-entrypoint.test.ts | 85 +++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 1e723f849dc..2fc32ebc6b0 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -27,18 +27,57 @@ if [ "$(id -u)" -ne 0 ]; then exec "$@" fi -# Adjust the node user's UID/GID if they differ from the runtime request -if [ "$(id -u node)" -ne "$PUID" ]; then +# Adjust the node user's UID/GID if they differ from the runtime request. +# +# `usermod -u` and `usermod -g` also re-own every file under the user's home +# directory. When that home is the app home (the data volume), the walk can +# take minutes on a large home, and the app cannot start until it ends. In +# that case the remap runs while the home points at a placeholder path that +# does not exist, so usermod changes only the account, and the ownership probe +# below repairs the app home when the IDs really changed (the home directory +# itself then has the old owner, which the shallow probe detects on the same +# boot). A passwd home outside the app home is not covered by that probe, so +# it keeps the plain usermod behavior, which re-owns it. +# +# The placeholder embeds the original home, so a boot that stops between the +# two usermod -d calls is repaired on the next start of the same container. +remap_home_prefix="/nonexistent/paperclip-uid-remap" +node_home="$(getent passwd node 2>/dev/null | cut -d: -f6 || true)" +case "$node_home" in + "$remap_home_prefix"/*) + node_home="${node_home#"$remap_home_prefix"}" + echo "Restoring node home to $node_home after an interrupted remap" + usermod -d "$node_home" node + ;; +esac + +remap_uid=0 +remap_gid=0 +[ "$(id -u node)" -ne "$PUID" ] && remap_uid=1 +[ "$(id -g node)" -ne "$PGID" ] && remap_gid=1 +app_home="${PAPERCLIP_HOME:-/paperclip}" +move_home=0 +if { [ "$remap_uid" -eq 1 ] || [ "$remap_gid" -eq 1 ]; } \ + && [ -n "$node_home" ] && [ "${node_home%/}" = "${app_home%/}" ]; then + move_home=1 + usermod -d "$remap_home_prefix$node_home" node +fi + +if [ "$remap_uid" -eq 1 ]; then echo "Updating node UID to $PUID" usermod -o -u "$PUID" node fi -if [ "$(id -g node)" -ne "$PGID" ]; then +if [ "$remap_gid" -eq 1 ]; then echo "Updating node GID to $PGID" groupmod -o -g "$PGID" node usermod -g "$PGID" node fi +if [ "$move_home" -eq 1 ]; then + usermod -d "$node_home" node +fi + # Ensure the app home is owned by the runtime user BEFORE dropping # privileges -- not only after a UID/GID remap. A freshly mounted volume # (Docker named volume, Railway volume, Kubernetes PV) arrives root-owned diff --git a/server/src/__tests__/docker-entrypoint.test.ts b/server/src/__tests__/docker-entrypoint.test.ts index f71586f8094..1f3c3c8a445 100644 --- a/server/src/__tests__/docker-entrypoint.test.ts +++ b/server/src/__tests__/docker-entrypoint.test.ts @@ -43,6 +43,8 @@ type StubOptions = { deepMismatch?: boolean; /** The full-tree find exits non-zero without printing a mismatch (e.g. an I/O error). */ fullProbeFails?: boolean; + /** The node user's passwd home; null models a host without a node passwd entry. */ + nodeHome?: string | null; }; function installStubs(ids: StubOptions) { @@ -75,6 +77,13 @@ function installStubs(ids: StubOptions) { ].join("\n"), ); writeStub("gosu", `echo "gosu $*" >> "${logFile}"\nshift\nexec "$@"`); + const nodeHome = ids.nodeHome === undefined ? "/paperclip" : ids.nodeHome; + writeStub( + "getent", + nodeHome === null + ? "exit 2" + : `if [ "$1" = "passwd" ] && [ "$2" = "node" ]; then echo "node:x:${ids.nodeUid ?? 1000}:${ids.nodeGid ?? 1000}::${nodeHome}:/bin/bash"; else exit 2; fi`, + ); } async function runEntrypoint(env: Record = {}) { @@ -120,6 +129,82 @@ describe("docker-entrypoint.sh", () => { expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); }); + it("moves the app home away during the remap so usermod does not walk it", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: true, nodeHome: stubDir }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + const lines = calls.trim().split("\n"); + // usermod re-owns files only under the current home, so every ID change + // runs while the home points at a placeholder that does not exist. + expect(lines.filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d /nonexistent/paperclip-uid-remap${stubDir} node`, + "usermod -o -u 1001 node", + "usermod -g 1001 node", + `usermod -d ${stubDir} node`, + ]); + // The ownership probe still repairs the app home after a real ID change. + expect(lines.indexOf(`chown -R node:node ${stubDir}`)).toBeGreaterThan(lines.indexOf(`usermod -d ${stubDir} node`)); + }); + + it("moves the app home away for a GID-only remap too", async () => { + installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1000, nodeHome: stubDir }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls.trim().split("\n").filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d /nonexistent/paperclip-uid-remap${stubDir} node`, + "usermod -g 1001 node", + `usermod -d ${stubDir} node`, + ]); + }); + + it("keeps the plain remap when the passwd home is outside the app home", async () => { + // The ownership probe covers only the app home, so usermod must still + // re-own a separate passwd home. + installStubs({ uid: 0, gid: 0, nodeHome: "/paperclip" }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls).toContain("usermod -o -u 1001 node"); + expect(calls).not.toContain("usermod -d"); + }); + + it("remaps without moving the home when the node passwd entry is unavailable", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: true, nodeHome: null }); + + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls).toContain("usermod -o -u 1001 node"); + expect(calls).not.toContain("usermod -d"); + }); + + it("restores the home left by an interrupted remap when the IDs already match", async () => { + installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, nodeHome: `/nonexistent/paperclip-uid-remap${stubDir}` }); + + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls.trim().split("\n").filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d ${stubDir} node`, + ]); + }); + + it("restores the original home, not the placeholder, when an interrupted remap is retried", async () => { + installStubs({ uid: 0, gid: 0, nodeHome: `/nonexistent/paperclip-uid-remap${stubDir}` }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls.trim().split("\n").filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d ${stubDir} node`, + `usermod -d /nonexistent/paperclip-uid-remap${stubDir} node`, + "usermod -o -u 1001 node", + "usermod -g 1001 node", + `usermod -d ${stubDir} node`, + ]); + }); + it.each([false, true])("skips remapping a cloud identity while preserving volume repair (mismatch: %s)", async (homeMismatch) => { installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, homeMismatch });