Files
PaperClipAI/doc/DOCKER.md
T
Devin Foley dbf052577d Follow the current onboarding arc in the release smoke (#12423)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A release is gated by the release smoke: it installs the published
`paperclipai` artifact into a Docker container and drives the sign-in →
onboarding → first-agent path with Playwright
> - That suite runs only from the release pipeline, never on a pull
request, so it sees the UI only after the UI has already changed
> - The onboarding wizard was rebuilt into the agent arc. The "Name your
organization" step, the "Start Onboarding" launcher, and the agent role
picker are all gone
> - The spec still waited for those, so it failed on its first assertion
and blocked every nightly and beta release
> - The failure was also hard to read. The workflow uploaded no
container logs, because it learned the container's name only after the
harness succeeded, and the harness ran the container with `--rm` and
deleted it before anything read it
> - This pull request rewrites the spec to follow the current arc, and
repairs the log capture at both ends
> - The benefit is that nightly and beta releases are unblocked, and the
next failure arrives with the logs attached

## Linked Issues or Issue Description

No existing issue. Describing it inline, following
`.github/ISSUE_TEMPLATE/bug_report.yml`.

Refs #12274 (removed the company-naming step from the wizard).
Refs #12135 (the previous alignment of this spec, before #12274).
Refs #12316 (open; also edits `scripts/docker-onboard-smoke.sh`, in the
bootstrap helpers rather than the container lifecycle, so the two
changes do
not overlap. Whichever lands second should rebase and re-run).

**What happened?**

The release smoke fails.
`tests/release-smoke/docker-auth-onboarding.spec.ts`
never gets past its first wait:

```
✘ tests/release-smoke/docker-auth-onboarding.spec.ts:43:3 › Docker authenticated onboarding smoke › logs in, completes onboarding, and hires the lead agent
Error: expect(locator).toBeVisible() failed — element(s) not found (timeout 20000ms)
> 33 | await expect(wizardHeading.or(startButton)).toBeVisible({ timeout: 20_000 });
```

The spec waits for an `h3` reading "Name your organization" or a
"Start Onboarding" button. Neither exists. #12274 removed the
company-naming
step; the string now survives only in a code comment and in
`ui/src/components/OnboardingWizard.step.test.tsx`, which asserts it is
*absent*. The steps after the first wait are stale too: the CTA on step
1 is
"Continue" and not "Next", the organization input's placeholder changed,
and
the agent step's `#onboarding-agent-role` picker is gone, so every
onboarding
hire is filed under the neutral `general` role.

The suite runs only from the release pipeline, so nothing on a pull
request
saw the drift. Both `smoke_nightly` and `smoke_beta` call the same
reusable
workflow, so every nightly and every beta was blocked.

The failure also arrived without diagnostics. The job's "Capture Docker
logs"
step is `if: always()`, but it is guarded on `SMOKE_CONTAINER_NAME`,
which the
"Launch Docker smoke harness" step writes to `$GITHUB_ENV` only *after*
the
harness returns. On any failure before that the guard is false, the step
does
nothing, and the upload reports "No files were found". Below that,
`scripts/docker-onboard-smoke.sh` starts the container with
`docker run -d --rm`, so the `docker stop` in its EXIT trap deletes the
container and its logs together — and a container that crashes on its
own is
removed the instant its process exits.

**Expected behavior**

The spec walks the onboarding arc the app actually presents, and proves
the
company is created, the lead agent is hired, and the first task is
seeded and
dispatched. When the smoke fails, the run's artifact carries the
container's
logs.

**Steps to reproduce**

1. Run the Release Smoke workflow against a published artifact that
carries
   #12274, or run it locally:
`PAPERCLIPAI_VERSION=2026.828.0-canary.3 SMOKE_DETACH=true
./scripts/docker-onboard-smoke.sh`
2. Run `pnpm run test:release-smoke` against that container.
3. The single spec fails at `openOnboarding()` after 20 seconds.
4. In CI, open the run's `release-smoke` artifact. It has no
   `docker-onboard-smoke.log`.

**Paperclip version or commit**

`2026.828.0-canary.3` (commit 8316ceb0b).

**Deployment mode**

Docker.

**Installation method**

npm / pnpm global install (the container runs `npx
paperclipai@<version>`).

**Node.js version**

v24.20.0 inside the container.

**Relevant logs or output**

```
Running 1 test using 1 worker
  ✓  1 [chromium] › tests/release-smoke/docker-auth-onboarding.spec.ts:76:3 › Docker authenticated onboarding smoke › logs in, completes onboarding, and hires the lead agent (7.0s)
  1 passed (8.7s)
```

That is the result after this change. Before it, the same command failed
at
the first wait, as quoted above.

## What Changed

- `tests/release-smoke/docker-auth-onboarding.spec.ts` now follows the
current
arc. It signs in, opens `/onboarding`, names the organization and
presses
"Continue" (which creates the company and routes straight to the agent
step,
because onboarding no longer asks for a mission), names the lead and
presses
  "Next", presses "Connect" on the default adapter to hire, then presses
  "Get started" to launch.
- The spec addresses controls by role and accessible name, or by id
where one
exists (`#onboarding-agent-name`). Step 1's field has no id and no
associated
  label, so it is found as the wizard's only text box rather than by its
  placeholder copy.
- The spec asserts the hired agent's role is `general`, which is what
the arc
files every onboarding hire under. Every other API assertion is
unchanged.
- The spec navigates to `/onboarding` explicitly and drops any saved
onboarding
draft first, so it can run twice against one instance. The suite retries
once
  in CI. It still asserts that a company-less board routes sign-in into
  onboarding, guarded on the board actually being empty.
- `scripts/docker-onboard-smoke.sh` accepts `SMOKE_CONTAINER_NAME`,
drops
  `--rm`, removes the container itself, and dumps `docker logs` to
  `SMOKE_LOG_FILE` before the teardown.
- `.github/workflows/release-smoke.yml` pins the container name in the
job's
`env`, so every `always()` step has it before anything runs. The capture
step
  refreshes the log from a live container when there is one, keeps the
harness's dump when there is not, and writes a one-line explanation when
  there is neither. The upload's paths are literals, and
`if-no-files-found: error` makes a broken diagnostics path fail rather
than
  warn.
- `scripts/docker-onboard-smoke.test.mjs` pins that wiring. It is added
to
  `test:release-registry`, which runs on every pull request.
- `doc/DOCKER.md` documents `SMOKE_CONTAINER_NAME` and `SMOKE_LOG_FILE`.

## Verification

The spec was run against a real container built from the published
`2026.828.0-canary.3` artifact, exactly as the workflow runs it.

```sh
SMOKE_CONTAINER_NAME=release-smoke-onboard \
HOST_PORT=3232 DATA_DIR=<tmp>/smoke-data \
PAPERCLIPAI_VERSION=2026.828.0-canary.3 \
SMOKE_READY_TIMEOUT_SECONDS=420 SMOKE_DETACH=true \
SMOKE_METADATA_FILE=<tmp>/release-smoke.env \
SMOKE_LOG_FILE=<tmp>/docker-onboard-smoke.log \
  ./scripts/docker-onboard-smoke.sh

PAPERCLIP_RELEASE_SMOKE_BASE_URL=http://localhost:3232 \
PAPERCLIP_RELEASE_SMOKE_EMAIL=smoke-admin@paperclip.local \
PAPERCLIP_RELEASE_SMOKE_PASSWORD=paperclip-smoke-password \
PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome \
  pnpm run test:release-smoke
```

```
Running 1 test using 1 worker
  ✓  1 [chromium] › tests/release-smoke/docker-auth-onboarding.spec.ts:76:3 › Docker authenticated onboarding smoke › logs in, completes onboarding, and hires the lead agent (7.0s)
  1 passed (8.7s)
```

The same command was run a second time against the same, now non-empty,
instance. That covers the retry path, and it also passes.

The log capture was verified by making the container die during startup:

```sh
PAPERCLIPAI_VERSION=0.0.0-no-such-version \
SMOKE_CONTAINER_NAME=release-smoke-onboard SMOKE_LOG_FILE=<tmp>/fail.log \
  ./scripts/docker-onboard-smoke.sh
```

`<tmp>/fail.log` was written and carried the cause:

```
npm error code ETARGET
npm error notarget No matching version found for paperclipai@0.0.0-no-such-version.
```

The container was removed afterwards. On `master` this file is never
written,
because `--rm` deletes the container the moment its process exits.

The workflow's capture step was run by hand against three states: a live
container (258 lines), a removed container with the harness's dump
already on
disk (258 lines kept), and neither (a one-line explanation).

Unit coverage:

```sh
pnpm run test:release-registry   # 93 tests, 93 pass
```

Nothing under `ui/` changed, so `pnpm --filter @paperclipai/ui
typecheck` was
not required. `tests/release-smoke` is outside the TypeScript project
references; Playwright compiles it at run time, which the runs above did
three
times.

## Risks

Low risk. Nothing ships to users. The change touches one Playwright
spec, one
smoke script, and one workflow.

Points worth a reviewer's attention:

- **This suite gates every nightly and beta, and it runs only
post-merge.**
`smoke_nightly` and `smoke_beta` both call `release-smoke.yml`, and no
pull
  request runs it. Drift between the wizard and this spec is therefore
invisible until a release is already blocked, which is how this bug
reached
a release train. I think the arc deserves an earlier check. The cheapest
version is the one added here: `scripts/docker-onboard-smoke.test.mjs`
runs
on every pull request and pins the harness wiring. The full container
smoke
is too slow for the pull request path, but a UI-level test of the arc's
step
  sequence would catch exactly this class of drift, and
`ui/src/components/OnboardingWizard.step.test.tsx` is already the right
  home for it. I did not add it here, to keep this change to the repair.
- **Dropping `--rm`.** The container is now removed by the script's
cleanup
instead of by Docker. The script already ran `docker rm -f` before
starting,
  and the workflow's final step removes it too, so a leaked container is
cleaned up on the next run either way. A developer who kills the script
with
`SIGKILL` will leave a stopped container behind, where previously they
would
  not.
- **`if-no-files-found: error` on the upload.** The capture step now
always
writes the log file, so the upload always has at least one path to
match. If
  that ever stops being true, the job fails instead of warning. That is
  deliberate.
- **The spec drops the saved onboarding draft before it walks.** A stale
draft
makes step 1 skip company creation and hire into the previous run's
company.
That state only exists when the spec runs twice against one instance. A
fresh
  release-smoke container never has it.

## Model Used

Claude (Anthropic), Claude Opus, 1M context, extended thinking, agentic
tool
use via Claude Code. The container, the Playwright runs, and the failure
injection were driven as real commands on a local Docker host.

## 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
- [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
2026-08-28 07:21:08 -07:00

9.9 KiB

Docker Quickstart

Run Paperclip in Docker without installing Node or pnpm locally.

All commands below assume you are in the project root (the directory containing package.json), not inside docker/.

Building the image

docker build -t paperclip-local .

The Dockerfile installs common agent tools (git, gh, curl, wget, ripgrep, python3) and the Claude, Codex, and OpenCode CLIs.

Build arguments:

Arg Default Purpose
USER_UID 1000 UID for the container node user (match your host UID to avoid permission issues on bind mounts)
USER_GID 1000 GID for the container node group
docker build -t paperclip-local \
  --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .

One-liner (build + run)

docker build -t paperclip-local . && \
docker run --name paperclip \
  -p 3100:3100 \
  -e HOST=0.0.0.0 \
  -e PAPERCLIP_HOME=/paperclip \
  -e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
  -e PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \
  -v "$(pwd)/data/docker-paperclip:/paperclip" \
  paperclip-local

Open: http://localhost:3100

Data persistence:

  • Embedded PostgreSQL data
  • uploaded assets
  • local secrets key
  • local agent workspace data

All persisted under your bind mount (./data/docker-paperclip in the example above).

Docker Compose

Quickstart (embedded SQLite)

Single container, no external database. Data persists via a bind mount.

BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32) \
  docker compose -f docker/docker-compose.quickstart.yml up --build

Defaults:

  • host port: 3100
  • persistent data dir: ./data/docker-paperclip

Optional overrides:

PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
  docker compose -f docker/docker-compose.quickstart.yml up --build

Note: PAPERCLIP_DATA_DIR is resolved relative to the compose file (docker/), so ../data/pc maps to data/pc in the project root.

If you change host port or use a non-local domain, set PAPERCLIP_PUBLIC_URL to the external URL you will use in browser/auth flows.

Pass OPENAI_API_KEY and/or ANTHROPIC_API_KEY to enable local adapter runs.

Full stack (with PostgreSQL)

Paperclip server + PostgreSQL 17. The database is health-checked before the server starts.

BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
  docker compose -f docker/docker-compose.yml up --build

PostgreSQL data persists in a named Docker volume (pgdata). Paperclip data persists in paperclip-data.

Untrusted PR review

Isolated container for reviewing untrusted pull requests with Codex or Claude, without exposing your host machine. See doc/UNTRUSTED-PR-REVIEW.md for the full workflow.

docker compose -f docker/docker-compose.untrusted-review.yml build
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review

Authenticated Compose (Single Public URL)

For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:

services:
  paperclip:
    environment:
      PAPERCLIP_DEPLOYMENT_MODE: authenticated
      PAPERCLIP_DEPLOYMENT_EXPOSURE: private
      PAPERCLIP_PUBLIC_URL: https://desk.koker.net

PAPERCLIP_PUBLIC_URL is used as the primary source for:

  • auth public base URL
  • Better Auth base URL defaults
  • bootstrap invite URL defaults
  • hostname allowlist defaults (hostname extracted from URL)

For fresh authenticated/private Docker or appliance-style installs, the first admin can now be claimed entirely from the browser after sign-in. Open the Paperclip URL, sign in or create an account, then choose Claim this instance on the setup screen. This browser claim is disabled for authenticated/public; public deployments should run the high-entropy CLI invite fallback instead:

pnpm paperclipai auth bootstrap-ceo

Granular overrides remain available if needed (PAPERCLIP_AUTH_PUBLIC_BASE_URL, BETTER_AUTH_URL, BETTER_AUTH_TRUSTED_ORIGINS, PAPERCLIP_ALLOWED_HOSTNAMES).

Set PAPERCLIP_ALLOWED_HOSTNAMES explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).

Claude + Codex Local Adapters in Docker

The image pre-installs:

  • claude (Anthropic Claude Code CLI)
  • codex (OpenAI Codex CLI)

If you want local adapter runs inside the container, pass API keys when starting the container:

docker run --name paperclip \
  -p 3100:3100 \
  -e HOST=0.0.0.0 \
  -e PAPERCLIP_HOME=/paperclip \
  -e OPENAI_API_KEY=... \
  -e ANTHROPIC_API_KEY=... \
  -v "$(pwd)/data/docker-paperclip:/paperclip" \
  paperclip-local

Notes:

  • Without API keys, the app still runs normally.
  • Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.

Podman Quadlet (systemd)

The docker/quadlet/ directory contains unit files to run Paperclip + PostgreSQL as systemd services via Podman Quadlet.

File Purpose
docker/quadlet/paperclip.pod Pod definition — groups containers into a shared network namespace
docker/quadlet/paperclip.container Paperclip server — joins the pod, connects to Postgres at 127.0.0.1
docker/quadlet/paperclip-db.container PostgreSQL 17 — joins the pod, health-checked

Setup

  1. Build the image (see above).

  2. Copy quadlet files to your systemd directory:

    # Rootless (recommended)
    cp docker/quadlet/*.pod docker/quadlet/*.container \
      ~/.config/containers/systemd/
    
    # Or rootful
    sudo cp docker/quadlet/*.pod docker/quadlet/*.container \
      /etc/containers/systemd/
    
  3. Create a secrets env file (keep out of version control):

    cat > ~/.config/containers/systemd/paperclip.env <<EOL
    BETTER_AUTH_SECRET=$(openssl rand -hex 32)
    PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=$(openssl rand -hex 32)
    POSTGRES_USER=paperclip
    POSTGRES_PASSWORD=paperclip
    POSTGRES_DB=paperclip
    DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:5432/paperclip
    # OPENAI_API_KEY=sk-...
    # ANTHROPIC_API_KEY=sk-...
    EOL
    
  4. Create the data directory and start:

    mkdir -p ~/.local/share/paperclip
    systemctl --user daemon-reload
    systemctl --user start paperclip-pod
    

Quadlet management

journalctl --user -u paperclip -f        # App logs
journalctl --user -u paperclip-db -f     # DB logs
systemctl --user status paperclip-pod    # Pod status
systemctl --user restart paperclip-pod   # Restart all
systemctl --user stop paperclip-pod      # Stop all

Quadlet notes

  • First boot: Unlike Docker Compose's condition: service_healthy, Quadlet's After= only waits for the DB unit to start, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in journalctl --user -u paperclip while PostgreSQL initialises — this is expected and resolves automatically via Restart=on-failure.
  • Containers in a pod share localhost, so Paperclip reaches Postgres at 127.0.0.1:5432.
  • PostgreSQL data persists in the paperclip-pgdata named volume.
  • Paperclip data persists at ~/.local/share/paperclip.
  • For rootful quadlet deployment, remove %h prefixes and use absolute paths.

Onboard Smoke Test (Ubuntu + npm only)

Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:

  • npx paperclipai onboard --yes completes
  • the server binds to 0.0.0.0:3100 so host access works
  • onboard/run banners and startup logs are visible in your terminal

Build + run:

./scripts/docker-onboard-smoke.sh

Open: http://localhost:3131 (default smoke host port)

Useful overrides:

HOST_PORT=3200 PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_DEPLOYMENT_EXPOSURE=private ./scripts/docker-onboard-smoke.sh
SMOKE_DETACH=true SMOKE_METADATA_FILE=/tmp/paperclip-smoke.env PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh

Notes:

  • Persistent data is mounted at ./data/docker-onboard-smoke by default.
  • Container runtime user id defaults to your local id -u so the mounted data dir stays writable while avoiding root runtime.
  • Smoke script defaults to authenticated/private mode so HOST=0.0.0.0 can be exposed to the host.
  • Smoke script defaults host port to 3131 to avoid conflicts with local Paperclip on 3100.
  • Smoke script also defaults PAPERCLIP_PUBLIC_URL to http://localhost:<HOST_PORT> so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal 3100.
  • In authenticated mode, the smoke script defaults SMOKE_AUTO_BOOTSTRAP=true and drives the real bootstrap path automatically: it signs up a real user, runs paperclipai auth bootstrap-ceo inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
  • Run the script in the foreground to watch the onboarding flow; stop with Ctrl+C after validation.
  • Set SMOKE_DETACH=true to leave the container running for automation and optionally write shell-ready metadata to SMOKE_METADATA_FILE.
  • Set SMOKE_CONTAINER_NAME to fix the container's name up front. Automation that has to collect diagnostics when the script fails needs a name it already knows, rather than one it can only read back out of a successful run. Defaults to the image name.
  • The container's logs are dumped to SMOKE_LOG_FILE (default $TMPDIR/<container name>.log) before the script tears the container down, so a run that never became ready still leaves its logs behind.
  • The image definition is in docker/Dockerfile.onboard-smoke.

General Notes

  • The docker-entrypoint.sh adjusts the container node user UID/GID at startup to match the values passed via USER_UID/USER_GID, avoiding permission issues on bind-mounted volumes.
  • Paperclip data persists via Docker volumes/bind mounts (compose) or at ~/.local/share/paperclip (quadlet).