mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Docker image keeps all instance state under `PAPERCLIP_HOME`,
and deployments mount a volume there
> - Before it drops privileges, the entrypoint makes sure that the
`node` user owns the home (#10530)
> - The ownership probe is a first-mismatch `find` over the whole tree.
When ownership is correct, `-quit` never fires, so every boot stats
every file on the volume
> - On homes with many agent worktrees and `node_modules` trees, this
walk takes minutes before Node starts. That is close to common 300s
healthcheck windows
> - This pull request keeps a cheap shallow probe on every boot. It runs
the full-tree probe only once per volume and node UID/GID, and records
the result in a marker file
> - The benefit is that routine boots no longer scale with the file
count, and the original root-owned-volume fix stays in place
## Linked Issues or Issue Description
Refs #10431 (the open follow-up about the remaining cost of the
ownership walk). Related: #10530 (the change that added the full-tree
probe).
**What happened?**
On a hosted multi-instance deployment, we measured the time from
container start to the first app log line. Instances with many files on
the volume (agent worktrees with `node_modules`) took about 190-235s.
Small instances took 5-22s. One 30 GB volume with few files took 24s, so
the cost follows the file count, not the volume size. Almost all of this
time is the `find "$home_dir" \( ! -user node -o ! -group node \) -print
-quit` walk in `scripts/docker-entrypoint.sh`. When ownership is already
correct, `-quit` never fires, so the walk stats every inode on every
boot.
**Expected behavior**
When ownership is already correct, the entrypoint pre-start phase is
short and does not depend on the number of files in `PAPERCLIP_HOME`. A
fresh root-owned volume, a UID/GID remap, and an upgrade from an older
image are still repaired before the privilege drop.
**Steps to reproduce**
1. Run the image with a volume on `/paperclip` that holds a few hundred
thousand files, all owned by `node:node`.
2. Restart the container.
3. Compare the container start time with the first server log line. The
gap grows with the file count, even though no `chown` runs.
**Paperclip version or commit**
`master` at a9830414c7
**Deployment mode**
Docker production image (any platform that mounts a volume on
`PAPERCLIP_HOME`)
## What Changed
- `scripts/docker-entrypoint.sh`: the ownership probe is now tiered.
- Every boot runs a shallow probe (`find -maxdepth 3`) over the home and
its first three levels. A fresh root-owned mount and a UID/GID remap
both change the owner of the home directory, so this probe catches them.
- The full-tree probe runs only when
`PAPERCLIP_HOME/.paperclip-ownership-verified` is missing or records a
different node UID/GID (format `v1 <uid>:<gid>`). This catches deep
mismatches, including the first boot after an upgrade from an older
image.
- Any mismatch repairs the whole tree with `chown -R node:node`, as
before. After a clean probe or a repair, the entrypoint writes the
marker as the `node` user (through `gosu`), so the marker never trips
the shallow probe. A failed marker write is ignored: the only cost is a
full probe on the next boot. If `find` exits non-zero without a mismatch
(for example, an I/O error), the walk is incomplete, so the entrypoint
does not write the marker and the next boot probes again.
- `scripts/docker-entrypoint.sh`: timestamped log lines at entrypoint
start, around the full probe and the repair (with durations), and right
before the `exec` of the server. You can now measure the pre-start phase
from the container log.
- `server/src/__tests__/docker-entrypoint.test.ts`: the `find` stub now
tells the shallow probe from the full probe. New tests cover each path
(see Verification).
- `doc/DOCKER.md`: documents the marker, how to force a full check, and
the new log lines.
## Verification
- `cd server && npx vitest run src/__tests__/docker-entrypoint.test.ts
src/__tests__/container-init-reaping.test.ts
src/__tests__/docker-build-stamp.test.ts`: 40 passed. New cases:
- correct ownership + current marker → shallow probe only, no full walk,
no `chown`
- current marker + deep-only mismatch → full walk skipped (this is the
documented trade-off)
- no marker (upgrade) → one full walk, no `chown`, marker written as `v1
1000:1000`
- no marker + deep mismatch → full walk finds it, `chown -R`, marker
written
- root-owned top level with a current marker → `chown -R` without a full
walk
- full walk fails without a mismatch → no `chown`, no marker, retry
logged
- marker for another UID/GID → full walk runs again, marker rewritten as
`v1 1001:1001`
- timestamped `starting`, `ownership repair finished in Ns`, and
`starting app as node` lines, in order, before the command runs
- The new tests fail against the old script. The 11 existing tests pass
unchanged.
- `cd server && npx tsc --noEmit`: passes.
- `shellcheck -s sh scripts/docker-entrypoint.sh`: clean. `dash -n`:
syntax OK.
- Manual run with the real `find` (BSD find, script run under `dash`)
against a 200k-file tree. Only `id`/`usermod`/`groupmod`/`chown`/`gosu`
were stubbed, and `node` was mapped to the local user and group:
- boot 1, no marker, one deep group mismatch → full probe, `chown -R`,
marker written
- boot 2, marker present → no full probe, no `chown`
- boot 3, no marker, clean tree → full probe, no `chown`, marker written
- boot 4, marker present, clean → no full probe, no `chown`
- boot 5, top-level group mismatch with marker → `chown -R`
- I did not build the image or measure boot time in a container. Docker
was not available on the machine I used. On a warm local disk the full
walk is sub-second, so the local timings say nothing about cold network
block storage.
## Risks
- Once the marker is current, a mismatch deeper than three levels is not
detected automatically. Examples: files written as root by `docker exec`
or a restore run as root, after the first verified boot. Before this
change, those files were repaired on the next boot. The workaround is in
`doc/DOCKER.md`: delete the marker and restart. A remap, a fresh volume,
and a root-owned top level are still detected on every boot.
- On the first boot after an upgrade, each existing volume pays one full
walk (the same cost as today). Later boots skip it.
- The marker is a new dotfile in `PAPERCLIP_HOME`. Backups and copies
that include the home carry it along. This is safe, because the marker
records the UID/GID that it verified, and a different UID/GID forces a
new full probe.
- The non-root path (restricted PodSecurity, OpenShift) does not change,
apart from two new stdout log lines.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`, 1M context) via Claude Code, with
tool use (shell, file editing). Default reasoning mode.
## 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
- [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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
302 lines
12 KiB
TypeScript
302 lines
12 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 });
|
|
}
|
|
|
|
type StubOptions = {
|
|
uid: number;
|
|
gid: number;
|
|
nodeUid?: number;
|
|
nodeGid?: number;
|
|
/** Mismatch anywhere: both the shallow and the full-tree probe find it. */
|
|
homeMismatch?: boolean;
|
|
/** Mismatch only below the shallow probe depth: just the full-tree probe finds it. */
|
|
deepMismatch?: boolean;
|
|
/** The full-tree find exits non-zero without printing a mismatch (e.g. an I/O error). */
|
|
fullProbeFails?: boolean;
|
|
};
|
|
|
|
function installStubs(ids: StubOptions) {
|
|
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 probes are first-mismatch finds over the app
|
|
// home: a shallow one bounded by -maxdepth, and a full-tree one. An empty
|
|
// result models a node-owned tree (image-baked dir, healthy volume); a
|
|
// path models a uid OR gid mismatch (fresh root-owned mount, root-owned
|
|
// descendant, stale group after a GID-only remap). Each call is logged as
|
|
// "find-shallow" or "find-full" so tests can assert which walks ran.
|
|
writeStub(
|
|
"find",
|
|
[
|
|
`case " $* " in`,
|
|
` *" -maxdepth "*) echo "find-shallow $1" >> "${logFile}"; ${ids.homeMismatch ? `echo "$1/mismatched-entry"` : "true"} ;;`,
|
|
` *) echo "find-full $1" >> "${logFile}"; ${ids.homeMismatch || ids.deepMismatch ? `echo "$1/deep/mismatched-entry"` : ids.fullProbeFails ? "exit 1" : "true"} ;;`,
|
|
`esac`,
|
|
].join("\n"),
|
|
);
|
|
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}`);
|
|
});
|
|
|
|
describe("ownership probe cost", () => {
|
|
const marker = () => join(stubDir, ".paperclip-ownership-verified");
|
|
|
|
it("skips the full-tree walk when ownership is correct and the marker is current", async () => {
|
|
installStubs({ uid: 0, gid: 0 });
|
|
writeFileSync(marker(), "v1 1000:1000\n");
|
|
|
|
const { stdout, calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).toContain(`find-shallow ${stubDir}`);
|
|
expect(calls).not.toContain("find-full");
|
|
expect(calls).not.toContain("chown");
|
|
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
|
|
});
|
|
|
|
it("skips the full-tree walk when the marker is current, even with a deep mismatch", async () => {
|
|
// The marker is the trade-off that keeps boots O(1): once a tree has
|
|
// been verified, only the shallow levels are re-checked.
|
|
installStubs({ uid: 0, gid: 0, deepMismatch: true });
|
|
writeFileSync(marker(), "v1 1000:1000\n");
|
|
|
|
const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).not.toContain("find-full");
|
|
expect(calls).not.toContain("chown");
|
|
});
|
|
|
|
it("runs one full-tree walk without a marker, then records the marker", async () => {
|
|
// First boot after upgrading from an image without the marker.
|
|
installStubs({ uid: 0, gid: 0 });
|
|
|
|
const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).toContain(`find-shallow ${stubDir}`);
|
|
expect(calls).toContain(`find-full ${stubDir}`);
|
|
expect(calls).not.toContain("chown");
|
|
expect(readFileSync(marker(), "utf8")).toBe("v1 1000:1000\n");
|
|
});
|
|
|
|
it("does not record the marker when the full-tree walk fails without finding a mismatch", async () => {
|
|
installStubs({ uid: 0, gid: 0, fullProbeFails: true });
|
|
|
|
const { stdout, calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).toContain(`find-full ${stubDir}`);
|
|
expect(calls).not.toContain("chown");
|
|
expect(existsSync(marker())).toBe(false);
|
|
expect(stdout).toContain("failed; it will run again on the next boot");
|
|
expect(stdout).toContain("ENTRYPOINT-CMD-RAN");
|
|
});
|
|
|
|
it("repairs a deep mismatch found by the full-tree walk when there is no marker", async () => {
|
|
installStubs({ uid: 0, gid: 0, deepMismatch: true });
|
|
|
|
const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).toContain(`find-full ${stubDir}`);
|
|
expect(calls).toContain(`chown -R node:node ${stubDir}`);
|
|
expect(readFileSync(marker(), "utf8")).toBe("v1 1000:1000\n");
|
|
});
|
|
|
|
it("repairs a root-owned top level without a full-tree probe, even with a current marker", async () => {
|
|
installStubs({ uid: 0, gid: 0, homeMismatch: true });
|
|
writeFileSync(marker(), "v1 1000:1000\n");
|
|
|
|
const { calls } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).toContain(`find-shallow ${stubDir}`);
|
|
expect(calls).not.toContain("find-full");
|
|
expect(calls).toContain(`chown -R node:node ${stubDir}`);
|
|
});
|
|
|
|
it("re-runs the full-tree walk when the marker was written for another UID/GID", async () => {
|
|
installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, deepMismatch: true });
|
|
writeFileSync(marker(), "v1 1000:1000\n");
|
|
|
|
const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir });
|
|
|
|
expect(calls).toContain(`find-full ${stubDir}`);
|
|
expect(calls).toContain(`chown -R node:node ${stubDir}`);
|
|
expect(readFileSync(marker(), "utf8")).toBe("v1 1001:1001\n");
|
|
});
|
|
|
|
it("logs timestamped start, repair duration, and pre-exec lines", async () => {
|
|
installStubs({ uid: 0, gid: 0, homeMismatch: true });
|
|
|
|
const { stdout } = await runEntrypoint({ PAPERCLIP_HOME: stubDir });
|
|
const stamp = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z docker-entrypoint\\.sh: ";
|
|
|
|
expect(stdout).toMatch(new RegExp(`^${stamp}starting$`, "m"));
|
|
expect(stdout).toMatch(new RegExp(`^${stamp}ownership repair finished in \\d+s$`, "m"));
|
|
expect(stdout).toMatch(new RegExp(`^${stamp}starting app as node$`, "m"));
|
|
expect(stdout.indexOf("starting app as node")).toBeLessThan(stdout.indexOf("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("");
|
|
});
|
|
});
|