fix: skip gosu when already running as target user (#2908)

## 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>
This commit is contained in:
Jannes Stubbemann
2026-06-12 19:33:52 +02:00
committed by GitHub
parent 05bcd3ce84
commit c21f70ef1c
2 changed files with 135 additions and 0 deletions
@@ -0,0 +1,122 @@
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 }) {
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}"`);
}
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 () => {
installStubs({ uid: 0, gid: 0 });
const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001" });
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 /paperclip");
expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN");
});
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("");
});
});