Files
PaperClipAI/server/src/__tests__/docker-entrypoint.test.ts
T
Devin Foley dd1a7f5290 Ensure app-home ownership before the privilege drop, not only on remap (#10530)
## 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
2026-07-30 21:34:45 -07:00

172 lines
6.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Behavioral tests for scripts/docker-entrypoint.sh privilege handling.
*
* The entrypoint must support two deployment shapes with one image:
* - Docker Compose: container starts as root, remaps the node user to
* USER_UID/USER_GID and drops privileges via gosu.
* - Kubernetes restricted PodSecurity / OpenShift arbitrary UIDs: the
* container starts non-root, where neither the remap nor gosu can work,
* so the command must be exec'd directly (with a warning on mismatch).
*
* The system commands (id, usermod, groupmod, chown, gosu) are stubbed via
* PATH so the branching logic runs unmodified on any host.
*/
const ENTRYPOINT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "docker-entrypoint.sh");
let stubDir: string;
let logFile: string;
function writeStub(name: string, body: string) {
const path = join(stubDir, name);
writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
}
function installStubs(ids: { uid: number; gid: number; nodeUid?: number; nodeGid?: number; homeMismatch?: boolean }) {
writeStub(
"id",
[
`if [ "$1" = "-u" ] && [ "$2" = "node" ]; then echo ${ids.nodeUid ?? 1000};`,
`elif [ "$1" = "-g" ] && [ "$2" = "node" ]; then echo ${ids.nodeGid ?? 1000};`,
`elif [ "$1" = "-u" ]; then echo ${ids.uid};`,
`elif [ "$1" = "-g" ]; then echo ${ids.gid};`,
`else echo 0; fi`,
].join("\n"),
);
for (const cmd of ["usermod", "groupmod", "chown"]) {
writeStub(cmd, `echo "${cmd} $*" >> "${logFile}"`);
}
// The entrypoint's ownership probe is a first-mismatch find over the app
// home. An empty result models a fully node-owned tree (image-baked dir,
// healthy volume); a path models any uid OR gid mismatch anywhere in the
// tree (fresh root-owned mount, root-owned descendant, stale group after
// a GID-only remap).
writeStub("find", ids.homeMismatch ? `echo "$1/mismatched-entry"` : `true`);
writeStub("gosu", `echo "gosu $*" >> "${logFile}"\nshift\nexec "$@"`);
}
async function runEntrypoint(env: Record<string, string> = {}) {
const result = await execFileAsync("sh", [ENTRYPOINT, "echo", "ENTRYPOINT-CMD-RAN"], {
env: { PATH: `${stubDir}:${process.env.PATH}`, ...env },
});
const calls = existsSync(logFile) ? readFileSync(logFile, "utf8") : "";
return { stdout: result.stdout, stderr: result.stderr, calls };
}
beforeEach(() => {
stubDir = mkdtempSync(join(tmpdir(), "entrypoint-stubs-"));
logFile = join(stubDir, "calls.log");
});
afterEach(() => {
rmSync(stubDir, { recursive: true, force: true });
});
describe("docker-entrypoint.sh", () => {
it("keeps the root-start gosu flow with default UID/GID (Docker Compose)", async () => {
installStubs({ uid: 0, gid: 0 });
const { stdout, calls } = await runEntrypoint();
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
expect(calls).not.toContain("usermod");
expect(calls).not.toContain("chown");
});
it("remaps the node user and chowns /paperclip before gosu when root requests a different UID/GID", async () => {
// The stubbed node uid stays 1000 while the stat probe reports the old
// ownership, modelling the post-remap mismatch that must trigger chown.
installStubs({ uid: 0, gid: 0, homeMismatch: true });
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).toContain("groupmod -o -g 1001 node");
expect(calls).toContain(`chown -R node:node ${stubDir}`);
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
});
it("chowns a root-owned home before gosu even with the default UID/GID (fresh volume mount)", async () => {
// A freshly mounted volume arrives root-owned and shadows the image's
// build-time chown; with no remap requested the old entrypoint dropped
// privileges onto an unwritable home and the server crashed on its
// first mkdir.
installStubs({ uid: 0, gid: 0, homeMismatch: true });
const { stdout, calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(calls).toContain(`chown -R node:node ${stubDir}`);
expect(calls).not.toContain("usermod");
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
});
it("repairs ownership on a GID-only remap (stale group on persisted descendants)", async () => {
installStubs({ uid: 0, gid: 0, homeMismatch: true });
const { calls } = await runEntrypoint({ USER_GID: "1001", PAPERCLIP_HOME: stubDir });
expect(calls).toContain("groupmod -o -g 1001 node");
expect(calls).toContain(`chown -R node:node ${stubDir}`);
});
it("keeps a fully node-owned tree chown-free (no per-boot recursive chown)", async () => {
installStubs({ uid: 0, gid: 0, homeMismatch: false });
const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
expect(calls).not.toContain("chown");
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
});
it("honours PAPERCLIP_HOME for the ownership probe", async () => {
installStubs({ uid: 0, gid: 0, homeMismatch: true });
const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
expect(calls).toContain(`chown -R node:node ${stubDir}`);
});
it("execs directly and silently when already running as the requested user (restricted PodSecurity)", async () => {
installStubs({ uid: 1000, gid: 1000 });
const { stdout, stderr, calls } = await runEntrypoint();
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(stderr).toBe("");
expect(calls).toBe("");
});
it("execs directly with a warning for an arbitrary non-root UID (OpenShift-style)", async () => {
installStubs({ uid: 1234, gid: 1234 });
const { stdout, stderr, calls } = await runEntrypoint();
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(stderr).toContain("running unprivileged as 1234:1234; cannot remap to requested 1000:1000");
expect(calls).toBe("");
});
it("execs directly with a warning on a non-root GID mismatch", async () => {
installStubs({ uid: 1000, gid: 1001 });
const { stdout, stderr, calls } = await runEntrypoint();
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(stderr).toContain("running unprivileged as 1000:1001; cannot remap to requested 1000:1000");
expect(calls).toBe("");
});
});