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:
committed by
GitHub
parent
05bcd3ce84
commit
c21f70ef1c
@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user