Files
PaperClipAI/server/src/__tests__/docker-entrypoint.test.ts
Devin FoleyandPaperclip 932c8bec56 fix(ci): bake the managed runtime identity into cloud images (#13210)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Managed deployments start from the image built by the Cloud
workflow.
> - The managed runtime requests user and group 1001.
> - The image currently builds the node user as 1000.
> - Startup must remap that user, which can walk a large mounted home
directory.
> - This pull request uses the existing Docker build arguments to bake
user and group 1001 into Cloud images.
> - Matching the runtime identity removes that startup work and helps
avoid health-check retries.

## Linked Issues or Issue Description

Refs #13208, #1923, and #7861. Searched open and closed PRs for the
Cloud UID change. The older #7861 addresses build context and volume
ownership repair. This change uses the existing identity arguments in
the Cloud workflow and preserves ownership repair.

**What happened?**

A measured rollout had a container log `Updating node UID to 1001` after
startup. The container stayed at this step for at least 2 minutes 55
seconds before rollback stopped it. The baked node identity was 1000,
while the managed runtime requested 1001. A health check timed out and
the target required a second deployment attempt.

**Expected behavior**

Cloud images should already have the managed runtime identity. A
matching image should skip user and group remapping. Fresh or mismatched
volumes must still receive ownership repair.

**Steps to reproduce**

1. Build the current Cloud image with its default build arguments.
2. Start it with `USER_UID=1001`, `USER_GID=1001`, and a populated home
volume.
3. Observe the startup user remap before the application starts.

**Paperclip version or commit**

`fc06f7f05f42c675be71ff0927b6334405d520ed`

**Deployment mode**

Docker on managed hosts.

## What Changed

- Pass `USER_UID=1001` and `USER_GID=1001` to the Cloud image build.
- Check the pushed digest's baked identity before the entrypoint can
repair it. Then check the normal entrypoint's effective identity and
writable home before publishing the verified full-SHA tag.
- Add a workflow regression and two entrypoint cases for a matching
Cloud identity, including a mismatched volume.
- Document the runtime identity and the first-build cache cost.

## Verification

- Focused workflow and artifact tests: 27 passed.
- Entrypoint tests: 11 passed. Actionlint passed. Full local `pnpm -r
typecheck` passed. Full local `pnpm build` passed. The manual [Cloud
image
build](https://github.com/paperclipai/paperclip/actions/runs/34575473213)
passed on the exact PR head. It checked Sentry, baked and effective
identity, writable home, orphan reaping, and full-SHA publication. The
new identity check took one second. All 30 PR checks passed; the
Storybook workflow was intentionally skipped. Greptile reviewed commit
`114d408f637a0b53e2e2b1339c263779b1e4ae54` at 5/5 with no findings or
open threads.
- The full local suite for the same application source was already run
in #13205. Its macOS general-server phase had 10,471 passes and 70
failures in seven unchanged files. Those failures included missing
Runner fixtures, filesystem errors, timeouts, a port conflict, and a
load-count mismatch. After configuring Cargo and rebuilding fixtures, 37
of 38 native tests passed; one unchanged native-resume assertion still
failed. Linux PR CI passed. This change adds entrypoint tests and does
not change application code.

## Risks

- The first build must rebuild layers that depend on the base image
identity. Later builds can reuse them.
- A future managed runtime identity change must update these build
arguments and checks together.
- The Dockerfile's self-hosted defaults remain 1000. Runtime overrides
and mounted-volume ownership repair remain supported.
- The observed startup delay supports this change, but fleet timing also
includes provider startup, image pull, canary order, and retries. No
fixed end-to-end gain is claimed before a live rollout.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code execution, and tool
use. The exact serving model ID and context-window size are not exposed
in this session.

## 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 (focused workflow tests;
full-suite limitations are listed above)
- [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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 00:58:27 -07:00

184 lines
7.5 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.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 });
const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir });
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
expect(calls).not.toContain("usermod");
expect(calls).not.toContain("groupmod");
expect(calls.includes(`chown -R node:node ${stubDir}`)).toBe(homeMismatch);
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("");
});
});