mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## 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>
184 lines
7.5 KiB
TypeScript
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("");
|
|
});
|
|
});
|