From c2a248131372ceefe90fa4cc6dd26bab6d42d40a Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 12:44:47 +0200 Subject: [PATCH 01/10] docs(paperclip): design spec for running Paperclip in the cluster Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-25-paperclip-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-25-paperclip-design.md diff --git a/docs/superpowers/specs/2026-09-25-paperclip-design.md b/docs/superpowers/specs/2026-09-25-paperclip-design.md new file mode 100644 index 00000000..cdd7f06e --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-paperclip-design.md @@ -0,0 +1,169 @@ +# Paperclip in the homelab cluster — design + +Date: 2026-09-25 +Status: draft, awaiting review + +## Goal + +Run [Paperclip](https://github.com/paperclipai/paperclip) (MIT, agent-orchestration +server + UI) in the homelab cluster for real use: its agents do actual work against +homelab systems. This spec covers the **platform** only. Bot identities and their +credentials are out of scope (see "Deferred"). + +## Decisions + +| Topic | Decision | +|---|---| +| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. | +| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. | +| Claude auth | **One shared subscription OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN`, from `claude setup-token`) for all agents. | +| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. | +| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. | + +### Known trade-off of B (accepted for the trial) + +Upstream `runChildProcess()` spawns every local adapter with +`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only +`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`, +`CLAUDE_CODE_OAUTH_TOKEN`, the shared filesystem (including every other agent's +workspace) and every agent-scoped secret it can read from disk. Under B, no bot may hold +a credential we would not tolerate any other bot using. Per-bot isolation is C's job. + +## Architecture + +``` +LAN client ─▶ Traefik ─▶ ExternalName svc ─▶ ak-outpost-paperclip-gate (authentik ns) + │ forward-auth OK + ▼ + paperclip Deployment (ns paperclip, 1 replica) + ├─ server :3100 (+ agent CLIs as child processes) + ├─ PVC /paperclip (ceph-block) + └─▶ pclp-pg-rw (CNPG, PG17, 2 instances) ─▶ barman ▶ Hetzner S3 +``` + +## Components + +All modeled on `kubernetes/apps/paperless/` unless stated. + +### Namespace and baseline policy +- `Namespace paperclip` in `kubernetes/infrastructure/namespaces/namespaces.yaml`. +- Baseline default-deny CNP `kubernetes/infrastructure/network-policies/paperclip-ns.yaml` + (copy of `paperless-ns.yaml`, renamed). +- Add `paperclip` to the `openbao` ClusterSecretStore namespace allowlist. + +### Flux +- `kubernetes/clusters/homelab/paperclip.yaml`: Flux `Kustomization`, + `path: ./kubernetes/apps/paperclip`, `prune: true`, `wait: true`, same `dependsOn` and + CNPG `healthCheckExprs` as `paperless.yaml`. +- `kubernetes/apps/paperclip/kustomization.yaml`: plain resource list. + +### Server Deployment (`paperclip.yaml`) +- Image `ghcr.io/paperclipai/paperclip:nightly@sha256:` with the Flux + image-automation marker for digest updates. +- `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC). +- Runs as uid/gid 1000 (image default `node` user); `fsGroup: 1000`. +- Port 3100; `Service paperclip` ClusterIP :3100. +- Probes: `startupProbe`, `readinessProbe`, `livenessProbe` on `GET /api/health`. +- Env: + - `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key). + - `HOST=0.0.0.0`, `PORT=3100` + - `PAPERCLIP_DEPLOYMENT_MODE=authenticated` + - `PAPERCLIP_DEPLOYMENT_EXPOSURE=private` + - `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno` + - `PAPERCLIP_AUTH_DISABLE_SIGN_UP=true` (the first admin is created via the + `bootstrap-ceo` invite flow, which does not depend on open sign-up; the plan + verifies this before the first rollout) + - `envFrom: secretRef paperclip-secrets` +- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`. + +### Config file (telemetry) +Telemetry has no env switch; only `telemetry.enabled` in +`/paperclip/instances/default/config.json` (`PAPERCLIP_CONFIG`). Deliver +`{"telemetry":{"enabled":false}}` merged into that file from a ConfigMap. The plan +must first verify whether the server rewrites `config.json` at runtime; if it does, the +ConfigMap is copied in by an initContainer (merge into the existing file) instead of +mounted read-only via `subPath`. + +### Storage +- `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME` + and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime` + dirs. + +### Postgres +- CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`, + `ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`, + barman-cloud plugin as WAL archiver. +- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/paperclip-pg`, creds via the + existing `hetzner-s3-creds` ExternalSecret pattern. +- `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`. +- `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup. +- PG17 deviates from the cluster-wide PG16 convention because upstream's external + Postgres target is `postgres:17` (`docker/docker-compose.yml`) and its embedded default + is PG18. + +### Secrets +One ExternalSecret `paperclip-secrets` ← OpenBao `apps/paperclip` (whole-object +extract): +- `BETTER_AUTH_SECRET` +- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` +- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so + encrypted agent secrets in Postgres survive PVC loss. +- `CLAUDE_CODE_OAUTH_TOKEN` (shared subscription token) + +### Ingress and SSO +- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer: + letsencrypt-cluster`, **no** `dns.renner.uno/public` label (LAN-only). +- Authentik gate, per the paperless pattern: + - blueprint entries in `kubernetes/apps/authentik/blueprints-configmap.yaml`: + proxy provider `paperclip-gate` (`internal_host: + http://paperclip.paperclip.svc.cluster.local:3100`, `external_host: + https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding; + - outpost `ak-outpost-paperclip-gate`; + - `kubernetes/apps/authentik/paperclip-gate-ingress.yaml`, + `paperclip-gate-egress.yaml`; + - `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress + targets it on :9000. +- Paperclip's own email/password login stays on (no header/OIDC login upstream). + +### Network policy (`kubernetes/apps/paperclip/network-policy.yaml`) +Covers the server **and every agent** (same pod under B). +- Ingress to the app pod: only from the `paperclip-gate` outpost (by label) on 3100. +- Egress from the app pod: + - `pclp-pg` pods :5432 + - kube-dns :53 + - `ingress` namespace (Traefik) :443 — reaches Gitea, Woodpecker, HA, Loki by their + `*.renner.uno` names + - `kube-apiserver` entity :6443 + - `world` TCP :443 (Anthropic API, GitHub, npm) +- No SSH egress; git over HTTPS only. + +### Image automation +`kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository` +(`ghcr.io/paperclipai/paperclip`) + `ImagePolicy` filtering tag `^nightly$` with +`digestReflectionPolicy: Always` (image-reflector-controller v1.2.5 in cluster; field +available since v0.35.0). First use of digest reflection in this repo. + +## Guardrails +- No bot receives cluster-admin or any binding in `flux-system`, `authentik`, `openbao`. +- Git work goes through PRs; bots get no merge rights on protected branches. +- Nothing is granted under B that we would not tolerate any agent misusing. + +## Bootstrap (one-time) +1. Place `apps/paperclip` values in OpenBao (generated secrets + shared OAuth token). +2. Merge; Flux deploys. +3. Claim the admin account via the `bootstrap-ceo` invite, add yourself to + "Paperclip Users" in Authentik. + +## Verification +- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup + completes. +- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after + Authentik login; unauthenticated request redirects to `auth.renner.uno`. +- Direct pod access from another namespace is denied (CNP). +- A test agent (claude-local) completes a trivial task using the shared OAuth token. +- Flux ImagePolicy reports a `nightly@sha256:` digest. + +## Deferred +- **Option C** (sandboxed runs) — required before any per-bot credential isolation. +- **AIR-Manager** role and all bot identities/credentials. +- Public exposure. -- 2.54.0 From c3c8662ca77ee5bdee1e7afda2dffb0e9772c368 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 12:59:16 +0200 Subject: [PATCH 02/10] docs(paperclip): implementation plan Co-Authored-By: Claude Opus 5.5 --- .../superpowers/plans/2026-09-25-paperclip.md | 976 ++++++++++++++++++ .../specs/2026-09-25-paperclip-design.md | 4 +- 2 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-25-paperclip.md diff --git a/docs/superpowers/plans/2026-09-25-paperclip.md b/docs/superpowers/plans/2026-09-25-paperclip.md new file mode 100644 index 00000000..7ec989e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-paperclip.md @@ -0,0 +1,976 @@ +# Paperclip Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deploy Paperclip (server-local agent runs, option B) into the homelab cluster behind an Authentik gate, with CNPG Postgres and OpenBao-sourced secrets. + +**Architecture:** One Flux `Kustomization` (`paperclip`) applies `kubernetes/apps/paperclip/`: a single-replica Deployment on a ceph-block PVC, CNPG `pclp-pg` (PG17) with barman backups to Hetzner, and a Traefik Ingress that backends only the `paperclip-gate` Authentik outpost. Namespace, baseline CNP, ClusterSecretStore allowlist and image automation live in the existing `infrastructure` tree. + +**Tech Stack:** Flux v2 (kustomize-controller, image-reflector v1.2.5), CNPG + barman-cloud plugin, External Secrets → OpenBao, Cilium CNP, Traefik, Authentik blueprints. + +**Spec:** `docs/superpowers/specs/2026-09-25-paperclip-design.md` + +## Global Constraints + +- Worktree: `/home/krenner/source/homelab-worktrees/paperclip`, branch `feat/paperclip`. Never edit the main checkout. +- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`, message ends with `Co-Authored-By: Claude Opus 5.5 `. +- Push: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push …`. PR via `tea pr create --login claude-bot`. Never self-merge. +- One Bash command per call: no `&&`, `;`, `|` chaining, no `2>/dev/null`, no `python -c`. +- Namespace `paperclip`; hostname `paperclip.renner.uno`; LAN-only (no `dns.renner.uno/public` label). +- Image `ghcr.io/paperclipai/paperclip`, tag `nightly`, digest-pinned, `digestReflectionPolicy: Always`. +- Postgres: CNPG `pclp-pg`, `ghcr.io/cloudnative-pg/postgresql:17`, 2 instances, `ceph-block`. +- PVC `paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip`. +- OpenBao key `apps/paperclip` with fields `BETTER_AUTH_SECRET`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex), `CLAUDE_CODE_OAUTH_TOKEN`. +- Env: `PAPERCLIP_DEPLOYMENT_MODE=authenticated`, `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`, `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno`, `HOST=0.0.0.0`, `PORT=3100`. +- Manifest comments: one terse factual line; rationale goes in commit messages/PR. +- No `kubectl apply`/`exec`/`rollout restart` against the live cluster; Flux applies after merge. Server-side `--dry-run=server` is allowed. + +## Review Focus + +1. **Pod replaced mid agent run** (image bump, secret rotation via Reloader, node drain) — `Recreate` kills in-flight runs; expected: Paperclip marks them failed/recoverable on restart, not wedged. Covered in Task 9 Step 6. +2. **Shared OAuth token expired or missing** — expected: agent runs fail with a visible auth error in Paperclip, server stays healthy. Covered in Task 9 Step 7. +3. **Direct access bypassing the gate** from another namespace pod — expected: connection refused/dropped by CNP. Covered in Task 9 Step 4. +4. **Unauthenticated LAN request** to `paperclip.renner.uno` — expected: redirect to Authentik, never Paperclip's UI. Covered in Task 9 Step 3. +5. **Nightly image with a breaking DB migration** — expected: a base backup exists before the first bump lands. Covered in Task 9 Step 5. + +--- + +### Task 1: Upstream facts that decide two manifest values + +No repo changes; produces two decisions consumed by Task 6. + +**Interfaces:** +- Produces: `SIGNUP_DISABLED` (`"true"` or `"false"`), `CONFIG_PARTIAL_OK` (yes/no), `ENTRYPOINT_NEEDS_ROOT` (yes/no). + +- [ ] **Step 1: Sign-up vs instance claim.** Read upstream `server/src/auth/better-auth.ts`, `server/src/routes/auth.ts`, and the claim flow for `authenticated`+`private` (search `claim` under `server/src`): + +Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/auth.ts -H "Accept: application/vnd.github.raw"` + +Decide: if the first-admin claim creates the account through a path that ignores `disableSignUp` (e.g. invite/claim token handler calling the adapter directly), `SIGNUP_DISABLED="true"`. Otherwise `SIGNUP_DISABLED="false"` — the Authentik gate is the access control, and a follow-up PR flips it to `"true"` after the admin is claimed. + +- [ ] **Step 2: Partial config.json.** Read `server/src/config.ts` and the config-file loader it calls (search `PAPERCLIP_CONFIG` / `readConfigFile`). + +Run: `gh api repos/paperclipai/paperclip/contents/server/src/config.ts -H "Accept: application/vnd.github.raw"` + +Decide `CONFIG_PARTIAL_OK=yes` if a file containing only `{"telemetry":{"enabled":false}}` parses (every other section optional/defaulted). If not, stop and report to the user — do not invent config fields. + +- [ ] **Step 3: Entrypoint privilege.** + +Run: `gh api repos/paperclipai/paperclip/contents/docker-entrypoint.sh -H "Accept: application/vnd.github.raw"` + +`ENTRYPOINT_NEEDS_ROOT=yes` if it calls `usermod`/`groupmod`/`chown`/`gosu`. Record the result for Task 6. + +- [ ] **Step 4: Anonymous health response.** + +Run: `gh api repos/paperclipai/paperclip/contents/server/src/routes/health.ts -H "Accept: application/vnd.github.raw"` + +Confirm `GET /api/health` returns HTTP 200 to an anonymous caller when healthy (probes use it). If it returns 401 anonymously, use a `tcpSocket` probe on 3100 in Task 6 instead. + +--- + +### Task 2: Namespace, baseline CNP, ClusterSecretStore allowlist + +**Files:** +- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append) +- Create: `kubernetes/infrastructure/network-policies/paperclip-ns.yaml` +- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml` +- Modify: `kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` (sorted list) + +**Interfaces:** +- Produces: namespace `paperclip`; ESO may reference `openbao` from it. + +- [ ] **Step 1: Append the namespace** to the end of `namespaces.yaml`: + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: paperclip +``` + +- [ ] **Step 2: Create `paperclip-ns.yaml`** (copy of `paperless-ns.yaml`'s `default-deny-ingress`, ns renamed, paperless-only CNPs dropped): + +```yaml +# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: default-deny-ingress + namespace: paperclip +spec: + endpointSelector: {} + ingress: + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": database + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": monitoring + - fromEntities: + - host + - remote-node + - fromEndpoints: + - matchExpressions: + - key: io.kubernetes.pod.namespace + operator: Exists + icmps: + - fields: + - { type: 0, family: IPv4 } + - { type: 3, family: IPv4 } + - { type: 8, family: IPv4 } + - { type: 11, family: IPv4 } + - { type: 12, family: IPv4 } + - { type: 1, family: IPv6 } + - { type: 2, family: IPv6 } + - { type: 3, family: IPv6 } + - { type: 4, family: IPv6 } + - { type: 128, family: IPv6 } + - { type: 129, family: IPv6 } + - { type: 133, family: IPv6 } + - { type: 134, family: IPv6 } + - { type: 135, family: IPv6 } + - { type: 136, family: IPv6 } + - { type: 143, family: IPv6 } + egress: + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": authentik + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": ingress + app.kubernetes.io/name: traefik + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": rook-ceph + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + - toEntities: + - host + - remote-node + - world + - toEndpoints: + - matchExpressions: + - key: io.kubernetes.pod.namespace + operator: Exists + icmps: + - fields: + - { type: 0, family: IPv4 } + - { type: 3, family: IPv4 } + - { type: 8, family: IPv4 } + - { type: 11, family: IPv4 } + - { type: 12, family: IPv4 } + - { type: 1, family: IPv6 } + - { type: 2, family: IPv6 } + - { type: 3, family: IPv6 } + - { type: 4, family: IPv6 } + - { type: 128, family: IPv6 } + - { type: 129, family: IPv6 } + - { type: 133, family: IPv6 } + - { type: 134, family: IPv6 } + - { type: 135, family: IPv6 } + - { type: 136, family: IPv6 } + - { type: 143, family: IPv6 } +``` + +- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - paperclip-ns.yaml` directly after ` - paperless-ns.yaml`. + +- [ ] **Step 4: Allowlist the namespace** — in `openbao-clustersecretstore.yaml` insert ` - paperclip` between ` - monitoring` and ` - paperless` (list is sorted). + +- [ ] **Step 5: Render check.** + +Run: `kubectl kustomize kubernetes/infrastructure/network-policies` +Expected: output contains `namespace: paperclip`, exit 0. + +Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` +Expected: `clustersecretstore.external-secrets.io/openbao configured (server dry run)`. + +- [ ] **Step 6: Commit** `feat(paperclip): namespace, baseline CNP and OpenBao allowlist`. + +--- + +### Task 3: OpenBao secret `apps/paperclip` + +Out-of-repo; must exist before merge or the ExternalSecret never becomes Ready. + +**Interfaces:** +- Produces: OpenBao KV `apps/paperclip` with the four fields in Global Constraints. + +- [ ] **Step 1:** Ask wiki-synth for the claude-bot OpenBao write procedure (note `claude-bot-openbao-write-only-identity`): login method, KV mount, exact `bao kv put`/`patch` syntax for an `apps/` key. Use exactly what it returns. + +- [ ] **Step 2: Write the generated fields** as claude-bot (write-only identity — never read values back). Values generated inline by the command: + +`BETTER_AUTH_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` = `openssl rand -hex 32`, `PAPERCLIP_SECRETS_MASTER_KEY` = `openssl rand -hex 32` (64 hex chars = 32 bytes, accepted format). + +Generate each with a separate `openssl rand -hex 32` call and pass it directly into the `bao kv put` from Step 1; do not echo or log them. + +- [ ] **Step 3: Ask the user** to add the shared token themselves (never handle it): "Run `claude setup-token`, then `bao kv patch /apps/paperclip CLAUDE_CODE_OAUTH_TOKEN=`." Wait for confirmation. + +- [ ] **Step 4: Verify field names only** (no values): `bao kv metadata get /apps/paperclip` shows a current version ≥2. If claude-bot cannot read metadata, ask the user to confirm the four keys exist. + +--- + +### Task 4: Image automation + initial digest + +**Files:** +- Create: `kubernetes/infrastructure/image-automation/paperclip.yaml` +- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml` + +**Interfaces:** +- Produces: ImagePolicy `flux-system:paperclip`; `INITIAL_DIGEST` (`sha256:…`) for Task 6. + +- [ ] **Step 1: Create `paperclip.yaml`:** + +```yaml +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImageRepository +metadata: + name: paperclip + namespace: flux-system +spec: + image: ghcr.io/paperclipai/paperclip + interval: 1h +--- +# Upstream publishes no semver tags; track the nightly tag by digest. +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImagePolicy +metadata: + name: paperclip + namespace: flux-system +spec: + imageRepositoryRef: + name: paperclip + digestReflectionPolicy: Always + filterTags: + pattern: '^nightly$' + policy: + alphabetical: + order: asc +``` + +- [ ] **Step 2: Register** — add ` - paperclip.yaml` after ` - paperless-ngx.yaml` in `image-automation/kustomization.yaml`. + +- [ ] **Step 3: Server dry-run.** + +Run: `kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/paperclip.yaml` +Expected: both objects `created (server dry run)`; no `unknown field "digestReflectionPolicy"` error. + +- [ ] **Step 4: Resolve the current nightly digest.** + +The workstation has `docker` only (no skopeo/crane/buildx, checked 2026-09-25); `docker manifest inspect` lists per-platform digests but not the index digest, so pull once: + +Run: `docker pull ghcr.io/paperclipai/paperclip:nightly` +Run: `docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/paperclipai/paperclip:nightly` +Expected: `ghcr.io/paperclipai/paperclip@sha256:<64 hex>`. Record the `sha256:…` part as `INITIAL_DIGEST`. +Run: `docker image rm ghcr.io/paperclipai/paperclip:nightly` + +- [ ] **Step 5: Commit** `feat(paperclip): Flux image automation tracks the nightly digest`. + +--- + +### Task 5: Postgres (CNPG) and secrets in the app directory + +**Files:** +- Create: `kubernetes/apps/paperclip/cluster.yaml`, `objectstore.yaml`, `scheduled-backup.yaml`, `hetzner-creds-externalsecret.yaml`, `externalsecret.yaml` + +**Interfaces:** +- Produces: Secret `pclp-pg-app` (CNPG, key `uri`), Service `pclp-pg-rw`, Secret `paperclip-secrets`. + +- [ ] **Step 1: `cluster.yaml`:** + +```yaml +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: pclp-pg + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # data guard +spec: + instances: 2 + smartShutdownTimeout: 10 + stopDelay: 60 + # PG17: upstream's external Postgres target (docker/docker-compose.yml). + imageName: ghcr.io/cloudnative-pg/postgresql:17 + storage: + storageClass: ceph-block + size: 10Gi + plugins: + - name: barman-cloud.cloudnative-pg.io + isWALArchiver: true + parameters: + barmanObjectName: pclp-pg-store + bootstrap: + initdb: + database: paperclip + owner: paperclip + postgresql: + parameters: + shared_buffers: "256MB" + max_connections: "200" + checkpoint_timeout: "15min" + max_wal_size: "2GB" + wal_compression: "zstd" + archive_timeout: "30min" + synchronous: + method: any + number: 1 + dataDurability: preferred + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1Gi +``` + +- [ ] **Step 2: `objectstore.yaml`:** + +```yaml +apiVersion: barmancloud.cnpg.io/v1 +kind: ObjectStore +metadata: + name: pclp-pg-store + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # backup target +spec: + configuration: + destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg + endpointURL: https://fsn1.your-objectstorage.com + s3Credentials: + accessKeyId: + name: hetzner-s3-creds + key: ACCESS_KEY_ID + secretAccessKey: + name: hetzner-s3-creds + key: ACCESS_SECRET_KEY + region: + name: hetzner-s3-creds + key: REGION + wal: + compression: gzip + data: + compression: gzip + retentionPolicy: "30d" +``` + +- [ ] **Step 3: `scheduled-backup.yaml`:** + +```yaml +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: pclp-pg-daily + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule +spec: + schedule: "0 45 2 * * *" + immediate: true + backupOwnerReference: self + cluster: + name: pclp-pg + method: plugin + pluginConfiguration: + name: barman-cloud.cloudnative-pg.io +``` + +- [ ] **Step 4: `hetzner-creds-externalsecret.yaml`:** + +```yaml +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: hetzner-s3-creds + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: hetzner-s3-creds + dataFrom: + - extract: + key: infrastructure/hetzner-s3 +``` + +- [ ] **Step 5: `externalsecret.yaml`:** + +```yaml +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: paperclip-secrets + namespace: paperclip +spec: + refreshInterval: 10m + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: paperclip-secrets + dataFrom: + - extract: + key: apps/paperclip +``` + +- [ ] **Step 6: Commit** `feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets`. (Rendered together with Task 6's kustomization.) + +--- + +### Task 6: Server workload, storage, ingress, network policy + +**Files:** +- Create: `kubernetes/apps/paperclip/pvc.yaml`, `paperclip.yaml`, `gate-service.yaml`, `ingress.yaml`, `network-policy.yaml`, `kustomization.yaml` + +**Interfaces:** +- Consumes: `pclp-pg-app` (`uri`), `paperclip-secrets`, ImagePolicy `flux-system:paperclip`, `INITIAL_DIGEST` (Task 4), `SIGNUP_DISABLED` / `ENTRYPOINT_NEEDS_ROOT` / probe choice (Task 1). +- Produces: pods labelled `app.kubernetes.io/name: paperclip` listening on 3100; Service `paperclip:3100`; ExternalName `ak-outpost-paperclip-gate:9000`. + +- [ ] **Step 1: `pvc.yaml`:** + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: paperclip-data + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # data guard +spec: + accessModes: [ReadWriteOnce] + storageClassName: ceph-block + resources: + requests: + storage: 20Gi +``` + +- [ ] **Step 2: `paperclip.yaml`** — replace `` with the Task 4 value and `` with the Task 1 value. If Task 1 found `ENTRYPOINT_NEEDS_ROOT=no`, replace the container `securityContext` with `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `allowPrivilegeEscalation: false`, `capabilities: {drop: ["ALL"]}`. If Task 1 chose `tcpSocket`, replace each `httpGet` block with `tcpSocket: {port: http}`. + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paperclip + namespace: paperclip + labels: + app.kubernetes.io/name: paperclip + annotations: + secret.reloader.stakater.com/reload: paperclip-secrets +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: paperclip + template: + metadata: + labels: + app.kubernetes.io/name: paperclip + spec: + enableServiceLinks: false + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + initContainers: + # Telemetry is only configurable in config.json. + - name: disable-telemetry + image: ghcr.io/paperclipai/paperclip:nightly@ # {"$imagepolicy": "flux-system:paperclip"} + command: + - sh + - -c + - | + set -eu + f=/paperclip/instances/default/config.json + mkdir -p "$(dirname "$f")" + if [ -s "$f" ]; then + jq '.telemetry.enabled = false' "$f" > "$f.tmp" + else + printf '%s\n' '{"telemetry":{"enabled":false}}' > "$f.tmp" + fi + mv "$f.tmp" "$f" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumeMounts: + - name: data + mountPath: /paperclip + containers: + - name: paperclip + image: ghcr.io/paperclipai/paperclip:nightly@ # {"$imagepolicy": "flux-system:paperclip"} + ports: + - name: http + containerPort: 3100 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: pclp-pg-app + key: uri + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "3100" + - name: PAPERCLIP_DEPLOYMENT_MODE + value: authenticated + - name: PAPERCLIP_DEPLOYMENT_EXPOSURE + value: private + - name: PAPERCLIP_PUBLIC_URL + value: https://paperclip.renner.uno + - name: PAPERCLIP_AUTH_DISABLE_SIGN_UP + value: "" + envFrom: + - secretRef: + name: paperclip-secrets + volumeMounts: + - name: data + mountPath: /paperclip + startupProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 30 + timeoutSeconds: 10 + readinessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + memory: 4Gi + securityContext: + runAsNonRoot: false + allowPrivilegeEscalation: true + capabilities: + drop: ["ALL"] + add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"] + volumes: + - name: data + persistentVolumeClaim: + claimName: paperclip-data +--- +apiVersion: v1 +kind: Service +metadata: + name: paperclip + namespace: paperclip +spec: + selector: + app.kubernetes.io/name: paperclip + ports: + - name: http + port: 3100 + targetPort: http +``` + +- [ ] **Step 3: `gate-service.yaml`:** + +```yaml +# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace. +apiVersion: v1 +kind: Service +metadata: + name: ak-outpost-paperclip-gate + namespace: paperclip +spec: + type: ExternalName + externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local + ports: + - port: 9000 + targetPort: 9000 +``` + +- [ ] **Step 4: `ingress.yaml`:** + +```yaml +# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: paperclip + namespace: paperclip + annotations: + cert-manager.io/cluster-issuer: letsencrypt-cluster +spec: + ingressClassName: traefik + tls: + - hosts: + - paperclip.renner.uno + secretName: paperclip-tls + rules: + - host: paperclip.renner.uno + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ak-outpost-paperclip-gate + port: + number: 9000 +``` + +- [ ] **Step 5: `network-policy.yaml`** (Traefik pod-side port is 8443, verified via `kubectl get pods -n ingress -l app.kubernetes.io/name=traefik`): + +```yaml +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paperclip-gate-ingress + namespace: paperclip +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: paperclip + ingress: + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": authentik + goauthentik.io/outpost-name: paperclip-gate + toPorts: + - ports: + - port: "3100" + protocol: TCP +--- +# Covers the server and every agent it spawns (same pod). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paperclip-egress + namespace: paperclip +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: paperclip + egress: + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + cnpg.io/cluster: pclp-pg + toPorts: + - ports: + - port: "5432" + protocol: TCP + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + - port: "53" + protocol: TCP + # Gitea, Woodpecker, HA, Loki via their *.renner.uno names. + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": ingress + app.kubernetes.io/name: traefik + toPorts: + - ports: + - port: "8443" + protocol: TCP + - toEntities: + - kube-apiserver + toPorts: + - ports: + - port: "6443" + protocol: TCP + - toEntities: + - world + toPorts: + - ports: + - port: "443" + protocol: TCP +``` + +- [ ] **Step 6: `kustomization.yaml`:** + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - externalsecret.yaml + - hetzner-creds-externalsecret.yaml + - cluster.yaml + - objectstore.yaml + - scheduled-backup.yaml + - pvc.yaml + - paperclip.yaml + - gate-service.yaml + - ingress.yaml + - network-policy.yaml +``` + +- [ ] **Step 7: Render check.** + +Run: `kubectl kustomize kubernetes/apps/paperclip` +Expected: exit 0; output contains `kind: Cluster`, `kind: Deployment`, `claimName: paperclip-data`, and no literal `` or ``. + +Run: `grep -rn "\|" kubernetes/apps/paperclip` +Expected: no output, exit 1. + +- [ ] **Step 8: Commit** `feat(paperclip): server Deployment, PVC, gated Ingress and CNPs`. + +--- + +### Task 7: Authentik gate + +**Files:** +- Modify: `kubernetes/apps/authentik/blueprints-configmap.yaml` (insert before the line ` # navidrome-gate: the Navidrome web UI.`) +- Create: `kubernetes/apps/authentik/paperclip-gate-egress.yaml` +- Modify: `kubernetes/apps/authentik/kustomization.yaml` + +**Interfaces:** +- Produces: outpost pods labelled `goauthentik.io/outpost-name: paperclip-gate`, Service `ak-outpost-paperclip-gate.authentik:9000`. + +- [ ] **Step 1: Insert blueprint entries** (same indentation as the neighbouring `wiki-gate` block): + +```yaml + # paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it. + - id: paperclip-users-group + model: authentik_core.group + identifiers: + name: Paperclip Users + state: present + attrs: + is_superuser: false + users: + - !Find [authentik_core.user, [username, k.renner]] + + - id: paperclip-gate-provider + model: authentik_providers_proxy.proxyprovider + identifiers: + name: paperclip-gate + state: present + attrs: + authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]] + authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + mode: proxy + internal_host: http://paperclip.paperclip.svc.cluster.local:3100 + internal_host_ssl_validation: false + external_host: https://paperclip.renner.uno + cookie_domain: renner.uno + access_token_validity: hours=24 + refresh_token_validity: days=30 + intercept_header_auth: true + + - model: authentik_core.application + identifiers: + slug: paperclip-gate + state: present + attrs: + name: Paperclip + slug: paperclip-gate + provider: !KeyOf paperclip-gate-provider + meta_launch_url: https://paperclip.renner.uno + meta_description: "Paperclip agent orchestration" + + - model: authentik_policies.policybinding + identifiers: + target: !Find [authentik_core.application, [slug, paperclip-gate]] + group: !KeyOf paperclip-users-group + order: 0 + state: present + attrs: + enabled: true + negate: false + + - model: authentik_outposts.outpost + identifiers: + name: paperclip-gate + state: present + attrs: + type: proxy + service_connection: !KeyOf k8s-conn + providers: + - !KeyOf paperclip-gate-provider + config: + log_level: info + authentik_host: "http://authentik-server.authentik.svc.cluster.local" + authentik_host_insecure: false + authentik_host_browser: "https://auth.renner.uno/" + refresh_interval: "minutes=5" + container_image: null + object_naming_template: "ak-outpost-%(name)s" + kubernetes_namespace: authentik + kubernetes_replicas: 1 + kubernetes_service_type: ClusterIP + kubernetes_disabled_components: ["ingress"] + kubernetes_image_pull_secrets: [] + kubernetes_ingress_annotations: {} + kubernetes_ingress_class_name: null + kubernetes_ingress_secret_name: "authentik-outpost-tls" + kubernetes_json_patches: null + +``` + +- [ ] **Step 2: `paperclip-gate-egress.yaml`:** + +```yaml +# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods. +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paperclip-gate-egress + namespace: authentik +spec: + endpointSelector: + matchLabels: + goauthentik.io/outpost-name: paperclip-gate + egress: + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + app.kubernetes.io/name: paperclip + toPorts: + - ports: + - port: "3100" + protocol: TCP +``` + +- [ ] **Step 3: Register** — add ` - paperclip-gate-egress.yaml` after ` - paperless-gate-egress.yaml` in `apps/authentik/kustomization.yaml`. + +- [ ] **Step 4: Render + dry-run.** + +Run: `kubectl kustomize kubernetes/apps/authentik` +Expected: exit 0 (valid YAML incl. the blueprint ConfigMap). + +Run: `kubectl apply --dry-run=server -f kubernetes/apps/authentik/paperclip-gate-egress.yaml` +Expected: `ciliumnetworkpolicy.cilium.io/paperclip-gate-egress created (server dry run)`. + +- [ ] **Step 5: Commit** `feat(authentik): paperclip-gate outpost and Paperclip Users group`. + +--- + +### Task 8: Flux Kustomization + +**Files:** +- Create: `kubernetes/clusters/homelab/paperclip.yaml` + +- [ ] **Step 1:** + +```yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: paperclip + namespace: flux-system +spec: + interval: 10m + retryInterval: 1m + timeout: 20m + sourceRef: + kind: GitRepository + name: flux-system + path: ./kubernetes/apps/paperclip + prune: true + wait: true + dependsOn: + - name: infrastructure + - name: infrastructure-crds + - name: infrastructure-ceph-cluster + - name: infrastructure-cnpg-barman + healthCheckExprs: + - apiVersion: postgresql.cnpg.io/v1 + kind: Cluster + failed: "has(status.phase) && status.phase.startsWith('Failed')" + current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)" +``` + +- [ ] **Step 2: Server dry-run.** + +Run: `kubectl apply --dry-run=server -f kubernetes/clusters/homelab/paperclip.yaml` +Expected: `kustomization.kustomize.toolkit.fluxcd.io/paperclip created (server dry run)`. + +- [ ] **Step 3: Commit** `feat(paperclip): Flux Kustomization`. + +--- + +### Task 9: PR, merge by user, live verification + +- [ ] **Step 1: Push and open the PR** (Task 3 must be complete). + +Run: `git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/paperclip` +Run: `tea pr create --login claude-bot --base master --head feat/paperclip --title "feat(paperclip): deploy Paperclip (server-local runs, trial)" --description "\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"` +Then wait for the user to merge. Do not merge. + +- [ ] **Step 2: Reconciliation.** After merge: + +Run: `flux get kustomizations paperclip` +Expected: `Ready True`. If False, read `kubectl describe kustomization paperclip -n flux-system` and the failing object's events; diagnose, don't poke. + +Run: `kubectl get cluster -n paperclip pclp-pg` +Expected: `Cluster in healthy state`, 2 ready instances. + +Run: `kubectl get pods -n paperclip -l app.kubernetes.io/name=paperclip` +Expected: `1/1 Running`. + +- [ ] **Step 3: Gate enforced (Review Focus 4).** + +Run: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://paperclip.renner.uno/` +Expected: `302` redirecting to `https://paperclip.renner.uno/outpost.goauthentik.io/…` or `auth.renner.uno`, never `200`. + +- [ ] **Step 4: Gate not bypassable (Review Focus 3).** + +`default` has no CNPs (verified 2026-09-25), so its egress is open and only paperclip's ingress policy can block the probe. + +Run: `kubectl get ciliumnetworkpolicies -n default` +Expected: `No resources found` (else pick another CNP-free namespace). + +Run: `kubectl run cnp-probe -n default --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://paperclip.paperclip.svc.cluster.local:3100/api/health` +Expected: timeout (exit 28), no HTTP response. + +- [ ] **Step 5: First backup (Review Focus 5).** + +Run: `kubectl get backup -n paperclip` +Expected: one `pclp-pg-daily-…` with phase `completed`. + +- [ ] **Step 6: Image policy.** + +Run: `kubectl get imagepolicy -n flux-system paperclip -o jsonpath='{.status.latestRef}'` +Expected: `nightly` tag with a `sha256:` digest. + +- [ ] **Step 7: Hand-over to the user (browser steps, theirs):** + 1. Open `https://paperclip.renner.uno`, pass Authentik, claim the instance as admin. + 2. Create a test agent with the `claude-local` adapter and a trivial task ("reply with OK"); it must complete with the shared token (Review Focus 2: if it fails, report the run's error text — an expired token must show as an auth error, not a hang). + 3. If `SIGNUP_DISABLED` was `"false"`, open the follow-up PR flipping `PAPERCLIP_AUTH_DISABLE_SIGN_UP` to `"true"` once the admin is claimed. + 4. Review Focus 1 is observed on the first nightly bump: check the interrupted-run status in the UI. + +- [ ] **Step 8: Wiki.** Route the verified Paperclip facts (execution topology, env vars, image tags, digest-reflection usage) through wiki-ingest → wiki-distill once the deployment has proven out. diff --git a/docs/superpowers/specs/2026-09-25-paperclip-design.md b/docs/superpowers/specs/2026-09-25-paperclip-design.md index cdd7f06e..c77f76af 100644 --- a/docs/superpowers/specs/2026-09-25-paperclip-design.md +++ b/docs/superpowers/specs/2026-09-25-paperclip-design.md @@ -93,7 +93,7 @@ mounted read-only via `subPath`. - CNPG `Cluster pclp-pg`: 2 instances, `imageName: ghcr.io/cloudnative-pg/postgresql:17`, `ceph-block` 10Gi, `bootstrap.initdb {database: paperclip, owner: paperclip}`, barman-cloud plugin as WAL archiver. -- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/paperclip-pg`, creds via the +- `ObjectStore pclp-pg-store` → `s3://renner-uno-k8s-cnpg/pclp-pg`, creds via the existing `hetzner-s3-creds` ExternalSecret pattern. - `ScheduledBackup` nightly, `method: plugin`, `backupOwnerReference: self`. - `kustomize.toolkit.fluxcd.io/prune: disabled` on Cluster, ObjectStore, ScheduledBackup. @@ -131,7 +131,7 @@ Covers the server **and every agent** (same pod under B). - Egress from the app pod: - `pclp-pg` pods :5432 - kube-dns :53 - - `ingress` namespace (Traefik) :443 — reaches Gitea, Woodpecker, HA, Loki by their + - `ingress` namespace (Traefik) :8443 (pod-side websecure port) — reaches Gitea, Woodpecker, HA, Loki by their `*.renner.uno` names - `kube-apiserver` entity :6443 - `world` TCP :443 (Anthropic API, GitHub, npm) -- 2.54.0 From 6fa4c340b8eec42be262fdf7e0d8b043b49e2314 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:04:29 +0200 Subject: [PATCH 03/10] feat(paperclip): namespace, baseline CNP and OpenBao allowlist Co-Authored-By: Claude Opus 5.5 --- .../infrastructure/namespaces/namespaces.yaml | 5 + .../network-policies/kustomization.yaml | 1 + .../network-policies/paperclip-ns.yaml | 91 +++++++++++++++++++ .../post-crds/openbao-clustersecretstore.yaml | 1 + 4 files changed, 98 insertions(+) create mode 100644 kubernetes/infrastructure/network-policies/paperclip-ns.yaml diff --git a/kubernetes/infrastructure/namespaces/namespaces.yaml b/kubernetes/infrastructure/namespaces/namespaces.yaml index 595ebd60..658e5379 100644 --- a/kubernetes/infrastructure/namespaces/namespaces.yaml +++ b/kubernetes/infrastructure/namespaces/namespaces.yaml @@ -239,3 +239,8 @@ metadata: labels: app.kubernetes.io/name: woodpecker pod-security.kubernetes.io/enforce: privileged +--- +apiVersion: v1 +kind: Namespace +metadata: + name: paperclip diff --git a/kubernetes/infrastructure/network-policies/kustomization.yaml b/kubernetes/infrastructure/network-policies/kustomization.yaml index 949f2c38..f6589bc2 100644 --- a/kubernetes/infrastructure/network-policies/kustomization.yaml +++ b/kubernetes/infrastructure/network-policies/kustomization.yaml @@ -6,6 +6,7 @@ resources: - ingress-isolation.yaml - authentik-ns.yaml - paperless-ns.yaml + - paperclip-ns.yaml - audiobookshelf-ns.yaml - home-assistant-ns.yaml - spegel-ns.yaml diff --git a/kubernetes/infrastructure/network-policies/paperclip-ns.yaml b/kubernetes/infrastructure/network-policies/paperclip-ns.yaml new file mode 100644 index 00000000..23e7ba99 --- /dev/null +++ b/kubernetes/infrastructure/network-policies/paperclip-ns.yaml @@ -0,0 +1,91 @@ +# Traefik is not admitted: the only entry is the paperclip-gate outpost (apps/paperclip/network-policy.yaml). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: default-deny-ingress + namespace: paperclip +spec: + endpointSelector: {} + ingress: + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": database + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": monitoring + - fromEntities: + - host + - remote-node + - fromEndpoints: + - matchExpressions: + - key: io.kubernetes.pod.namespace + operator: Exists + icmps: + - fields: + - { type: 0, family: IPv4 } + - { type: 3, family: IPv4 } + - { type: 8, family: IPv4 } + - { type: 11, family: IPv4 } + - { type: 12, family: IPv4 } + - { type: 1, family: IPv6 } + - { type: 2, family: IPv6 } + - { type: 3, family: IPv6 } + - { type: 4, family: IPv6 } + - { type: 128, family: IPv6 } + - { type: 129, family: IPv6 } + - { type: 133, family: IPv6 } + - { type: 134, family: IPv6 } + - { type: 135, family: IPv6 } + - { type: 136, family: IPv6 } + - { type: 143, family: IPv6 } + egress: + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": authentik + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": ingress + app.kubernetes.io/name: traefik + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": rook-ceph + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + - toEntities: + - host + - remote-node + - world + - toEndpoints: + - matchExpressions: + - key: io.kubernetes.pod.namespace + operator: Exists + icmps: + - fields: + - { type: 0, family: IPv4 } + - { type: 3, family: IPv4 } + - { type: 8, family: IPv4 } + - { type: 11, family: IPv4 } + - { type: 12, family: IPv4 } + - { type: 1, family: IPv6 } + - { type: 2, family: IPv6 } + - { type: 3, family: IPv6 } + - { type: 4, family: IPv6 } + - { type: 128, family: IPv6 } + - { type: 129, family: IPv6 } + - { type: 133, family: IPv6 } + - { type: 134, family: IPv6 } + - { type: 135, family: IPv6 } + - { type: 136, family: IPv6 } + - { type: 143, family: IPv6 } diff --git a/kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml b/kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml index 44fc6dfb..a316908d 100644 --- a/kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml +++ b/kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml @@ -27,6 +27,7 @@ spec: - influxdb - ingress - monitoring + - paperclip - paperless - trmnl - velero -- 2.54.0 From 2d5653458106abfe0247929e2b0a3516d1c1398d Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:05:43 +0200 Subject: [PATCH 04/10] feat(paperclip): Flux image automation tracks the nightly digest digestReflectionPolicy: Always requires spec.interval on the ImagePolicy (admission rejects it otherwise); set to 1h to match the ImageRepository. Co-Authored-By: Claude Opus 5.5 --- .../image-automation/kustomization.yaml | 1 + .../image-automation/paperclip.yaml | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 kubernetes/infrastructure/image-automation/paperclip.yaml diff --git a/kubernetes/infrastructure/image-automation/kustomization.yaml b/kubernetes/infrastructure/image-automation/kustomization.yaml index d33557d4..ec8554f8 100644 --- a/kubernetes/infrastructure/image-automation/kustomization.yaml +++ b/kubernetes/infrastructure/image-automation/kustomization.yaml @@ -15,6 +15,7 @@ resources: - navidrome.yaml - netbird-ingress.yaml - paperless-ngx.yaml + - paperclip.yaml - protonmail-bridge.yaml - mosquitto.yaml - redis.yaml diff --git a/kubernetes/infrastructure/image-automation/paperclip.yaml b/kubernetes/infrastructure/image-automation/paperclip.yaml new file mode 100644 index 00000000..9f776b25 --- /dev/null +++ b/kubernetes/infrastructure/image-automation/paperclip.yaml @@ -0,0 +1,25 @@ +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImageRepository +metadata: + name: paperclip + namespace: flux-system +spec: + image: ghcr.io/paperclipai/paperclip + interval: 1h +--- +# Upstream publishes no semver tags; track the nightly tag by digest. +apiVersion: image.toolkit.fluxcd.io/v1 +kind: ImagePolicy +metadata: + name: paperclip + namespace: flux-system +spec: + imageRepositoryRef: + name: paperclip + digestReflectionPolicy: Always + interval: 1h + filterTags: + pattern: '^nightly$' + policy: + alphabetical: + order: asc -- 2.54.0 From d3257c56ac3faf2f35e184bfe28a836fdbf69926 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:06:22 +0200 Subject: [PATCH 05/10] feat(paperclip): CNPG pclp-pg with barman backups and OpenBao secrets Co-Authored-By: Claude Opus 5.5 --- kubernetes/apps/paperclip/cluster.yaml | 43 +++++++++++++++++++ kubernetes/apps/paperclip/externalsecret.yaml | 15 +++++++ .../hetzner-creds-externalsecret.yaml | 17 ++++++++ kubernetes/apps/paperclip/objectstore.yaml | 26 +++++++++++ .../apps/paperclip/scheduled-backup.yaml | 16 +++++++ 5 files changed, 117 insertions(+) create mode 100644 kubernetes/apps/paperclip/cluster.yaml create mode 100644 kubernetes/apps/paperclip/externalsecret.yaml create mode 100644 kubernetes/apps/paperclip/hetzner-creds-externalsecret.yaml create mode 100644 kubernetes/apps/paperclip/objectstore.yaml create mode 100644 kubernetes/apps/paperclip/scheduled-backup.yaml diff --git a/kubernetes/apps/paperclip/cluster.yaml b/kubernetes/apps/paperclip/cluster.yaml new file mode 100644 index 00000000..da4f4090 --- /dev/null +++ b/kubernetes/apps/paperclip/cluster.yaml @@ -0,0 +1,43 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: pclp-pg + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # data guard +spec: + instances: 2 + smartShutdownTimeout: 10 + stopDelay: 60 + # PG17: upstream's external Postgres target (docker/docker-compose.yml). + imageName: ghcr.io/cloudnative-pg/postgresql:17 + storage: + storageClass: ceph-block + size: 10Gi + plugins: + - name: barman-cloud.cloudnative-pg.io + isWALArchiver: true + parameters: + barmanObjectName: pclp-pg-store + bootstrap: + initdb: + database: paperclip + owner: paperclip + postgresql: + parameters: + shared_buffers: "256MB" + max_connections: "200" + checkpoint_timeout: "15min" + max_wal_size: "2GB" + wal_compression: "zstd" + archive_timeout: "30min" + synchronous: + method: any + number: 1 + dataDurability: preferred + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1Gi diff --git a/kubernetes/apps/paperclip/externalsecret.yaml b/kubernetes/apps/paperclip/externalsecret.yaml new file mode 100644 index 00000000..e49231b8 --- /dev/null +++ b/kubernetes/apps/paperclip/externalsecret.yaml @@ -0,0 +1,15 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: paperclip-secrets + namespace: paperclip +spec: + refreshInterval: 10m + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: paperclip-secrets + dataFrom: + - extract: + key: apps/paperclip diff --git a/kubernetes/apps/paperclip/hetzner-creds-externalsecret.yaml b/kubernetes/apps/paperclip/hetzner-creds-externalsecret.yaml new file mode 100644 index 00000000..4ecce64d --- /dev/null +++ b/kubernetes/apps/paperclip/hetzner-creds-externalsecret.yaml @@ -0,0 +1,17 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: hetzner-s3-creds + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # barman S3 creds +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openbao + target: + name: hetzner-s3-creds + dataFrom: + - extract: + key: infrastructure/hetzner-s3 diff --git a/kubernetes/apps/paperclip/objectstore.yaml b/kubernetes/apps/paperclip/objectstore.yaml new file mode 100644 index 00000000..6f45a2f4 --- /dev/null +++ b/kubernetes/apps/paperclip/objectstore.yaml @@ -0,0 +1,26 @@ +apiVersion: barmancloud.cnpg.io/v1 +kind: ObjectStore +metadata: + name: pclp-pg-store + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # backup target +spec: + configuration: + destinationPath: s3://renner-uno-k8s-cnpg/pclp-pg + endpointURL: https://fsn1.your-objectstorage.com + s3Credentials: + accessKeyId: + name: hetzner-s3-creds + key: ACCESS_KEY_ID + secretAccessKey: + name: hetzner-s3-creds + key: ACCESS_SECRET_KEY + region: + name: hetzner-s3-creds + key: REGION + wal: + compression: gzip + data: + compression: gzip + retentionPolicy: "30d" diff --git a/kubernetes/apps/paperclip/scheduled-backup.yaml b/kubernetes/apps/paperclip/scheduled-backup.yaml new file mode 100644 index 00000000..9b2902f3 --- /dev/null +++ b/kubernetes/apps/paperclip/scheduled-backup.yaml @@ -0,0 +1,16 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: pclp-pg-daily + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # backup schedule +spec: + schedule: "0 45 2 * * *" + immediate: true + backupOwnerReference: self + cluster: + name: pclp-pg + method: plugin + pluginConfiguration: + name: barman-cloud.cloudnative-pg.io -- 2.54.0 From cb811572168e4fdce99138cbde17a0b5ee8b9882 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:07:14 +0200 Subject: [PATCH 06/10] feat(authentik): paperclip-gate outpost and Paperclip Users group Co-Authored-By: Claude Opus 5.5 --- .../apps/authentik/blueprints-configmap.yaml | 77 +++++++++++++++++++ kubernetes/apps/authentik/kustomization.yaml | 1 + .../apps/authentik/paperclip-gate-egress.yaml | 19 +++++ 3 files changed, 97 insertions(+) create mode 100644 kubernetes/apps/authentik/paperclip-gate-egress.yaml diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index 5dc1c3af..0620cb5e 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1036,6 +1036,83 @@ data: kubernetes_ingress_secret_name: "authentik-outpost-tls" kubernetes_json_patches: null + # paperclip-gate: Paperclip UI; the CNP admits only this outpost. Paperclip keeps its own login behind it. + - id: paperclip-users-group + model: authentik_core.group + identifiers: + name: Paperclip Users + state: present + attrs: + is_superuser: false + users: + - !Find [authentik_core.user, [username, k.renner]] + + - id: paperclip-gate-provider + model: authentik_providers_proxy.proxyprovider + identifiers: + name: paperclip-gate + state: present + attrs: + authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]] + authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + mode: proxy + internal_host: http://paperclip.paperclip.svc.cluster.local:3100 + internal_host_ssl_validation: false + external_host: https://paperclip.renner.uno + cookie_domain: renner.uno + access_token_validity: hours=24 + refresh_token_validity: days=30 + intercept_header_auth: true + + - model: authentik_core.application + identifiers: + slug: paperclip-gate + state: present + attrs: + name: Paperclip + slug: paperclip-gate + provider: !KeyOf paperclip-gate-provider + meta_launch_url: https://paperclip.renner.uno + meta_description: "Paperclip agent orchestration" + + - model: authentik_policies.policybinding + identifiers: + target: !Find [authentik_core.application, [slug, paperclip-gate]] + group: !KeyOf paperclip-users-group + order: 0 + state: present + attrs: + enabled: true + negate: false + + - model: authentik_outposts.outpost + identifiers: + name: paperclip-gate + state: present + attrs: + type: proxy + service_connection: !KeyOf k8s-conn + providers: + - !KeyOf paperclip-gate-provider + config: + log_level: info + authentik_host: "http://authentik-server.authentik.svc.cluster.local" + authentik_host_insecure: false + authentik_host_browser: "https://auth.renner.uno/" + refresh_interval: "minutes=5" + container_image: null + object_naming_template: "ak-outpost-%(name)s" + kubernetes_namespace: authentik + kubernetes_replicas: 1 + kubernetes_service_type: ClusterIP + kubernetes_disabled_components: ["ingress"] + kubernetes_image_pull_secrets: [] + kubernetes_ingress_annotations: {} + kubernetes_ingress_class_name: null + kubernetes_ingress_secret_name: "authentik-outpost-tls" + kubernetes_json_patches: null + # navidrome-gate: the Navidrome web UI. Navidrome auto-creates the # X-Authentik-Username user on first login; the first user is admin. # /rest and /share bypass the gate at the Traefik router. diff --git a/kubernetes/apps/authentik/kustomization.yaml b/kubernetes/apps/authentik/kustomization.yaml index e65b21ec..3e177327 100644 --- a/kubernetes/apps/authentik/kustomization.yaml +++ b/kubernetes/apps/authentik/kustomization.yaml @@ -7,6 +7,7 @@ resources: - helmrelease.yaml - paperless-gate-ingress.yaml - paperless-gate-egress.yaml + - paperclip-gate-egress.yaml - vscode-gate-egress.yaml - shelly-manager-gate-egress.yaml - esphome-gate-egress.yaml diff --git a/kubernetes/apps/authentik/paperclip-gate-egress.yaml b/kubernetes/apps/authentik/paperclip-gate-egress.yaml new file mode 100644 index 00000000..4b5fb9cd --- /dev/null +++ b/kubernetes/apps/authentik/paperclip-gate-egress.yaml @@ -0,0 +1,19 @@ +# Lets the paperclip-gate outpost reach the Paperclip server; scoped to this outpost's pods. +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paperclip-gate-egress + namespace: authentik +spec: + endpointSelector: + matchLabels: + goauthentik.io/outpost-name: paperclip-gate + egress: + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + app.kubernetes.io/name: paperclip + toPorts: + - ports: + - port: "3100" + protocol: TCP -- 2.54.0 From 5a22ea8df38397efedde5afd9001cbe134d5dda4 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:08:32 +0200 Subject: [PATCH 07/10] feat(paperclip): server Deployment, PVC, gated Ingress and CNPs - Sign-up stays enabled: upstream's first-admin claim requires an existing signed-in Better Auth account (routes/access.ts /bootstrap/claim), so disabling it would lock out the claim. A follow-up flips PAPERCLIP_AUTH_DISABLE_SIGN_UP after the claim. - Telemetry is off via PAPERCLIP_TELEMETRY_DISABLED=1, which resolveTelemetryConfig honours; config.json can't carry it alone because the schema requires a complete file. - Container starts as root: docker-entrypoint.sh remaps the node user and chowns /paperclip before dropping privileges with gosu. - Image pinned to the amd64 manifest digest of nightly; Flux digest reflection takes over from there. Co-Authored-By: Claude Opus 5.5 --- kubernetes/apps/paperclip/gate-service.yaml | 12 ++ kubernetes/apps/paperclip/ingress.yaml | 25 ++++ kubernetes/apps/paperclip/kustomization.yaml | 13 +++ kubernetes/apps/paperclip/network-policy.yaml | 69 +++++++++++ kubernetes/apps/paperclip/paperclip.yaml | 109 ++++++++++++++++++ kubernetes/apps/paperclip/pvc.yaml | 13 +++ 6 files changed, 241 insertions(+) create mode 100644 kubernetes/apps/paperclip/gate-service.yaml create mode 100644 kubernetes/apps/paperclip/ingress.yaml create mode 100644 kubernetes/apps/paperclip/kustomization.yaml create mode 100644 kubernetes/apps/paperclip/network-policy.yaml create mode 100644 kubernetes/apps/paperclip/paperclip.yaml create mode 100644 kubernetes/apps/paperclip/pvc.yaml diff --git a/kubernetes/apps/paperclip/gate-service.yaml b/kubernetes/apps/paperclip/gate-service.yaml new file mode 100644 index 00000000..2021be12 --- /dev/null +++ b/kubernetes/apps/paperclip/gate-service.yaml @@ -0,0 +1,12 @@ +# ExternalName mirror so the Ingress can backend the paperclip-gate outpost in the authentik namespace. +apiVersion: v1 +kind: Service +metadata: + name: ak-outpost-paperclip-gate + namespace: paperclip +spec: + type: ExternalName + externalName: ak-outpost-paperclip-gate.authentik.svc.cluster.local + ports: + - port: 9000 + targetPort: 9000 diff --git a/kubernetes/apps/paperclip/ingress.yaml b/kubernetes/apps/paperclip/ingress.yaml new file mode 100644 index 00000000..bd2c8d43 --- /dev/null +++ b/kubernetes/apps/paperclip/ingress.yaml @@ -0,0 +1,25 @@ +# LAN-only (no dns.renner.uno/public label); backends the gate outpost, never the Service. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: paperclip + namespace: paperclip + annotations: + cert-manager.io/cluster-issuer: letsencrypt-cluster +spec: + ingressClassName: traefik + tls: + - hosts: + - paperclip.renner.uno + secretName: paperclip-tls + rules: + - host: paperclip.renner.uno + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ak-outpost-paperclip-gate + port: + number: 9000 diff --git a/kubernetes/apps/paperclip/kustomization.yaml b/kubernetes/apps/paperclip/kustomization.yaml new file mode 100644 index 00000000..70a31d09 --- /dev/null +++ b/kubernetes/apps/paperclip/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - externalsecret.yaml + - hetzner-creds-externalsecret.yaml + - cluster.yaml + - objectstore.yaml + - scheduled-backup.yaml + - pvc.yaml + - paperclip.yaml + - gate-service.yaml + - ingress.yaml + - network-policy.yaml diff --git a/kubernetes/apps/paperclip/network-policy.yaml b/kubernetes/apps/paperclip/network-policy.yaml new file mode 100644 index 00000000..66ae668e --- /dev/null +++ b/kubernetes/apps/paperclip/network-policy.yaml @@ -0,0 +1,69 @@ +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paperclip-gate-ingress + namespace: paperclip +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: paperclip + ingress: + - fromEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": authentik + goauthentik.io/outpost-name: paperclip-gate + toPorts: + - ports: + - port: "3100" + protocol: TCP +--- +# Covers the server and every agent it spawns (same pod). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: paperclip-egress + namespace: paperclip +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: paperclip + egress: + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": paperclip + cnpg.io/cluster: pclp-pg + toPorts: + - ports: + - port: "5432" + protocol: TCP + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": kube-system + k8s-app: kube-dns + toPorts: + - ports: + - port: "53" + protocol: UDP + - port: "53" + protocol: TCP + # Gitea, Woodpecker, HA, Loki via their *.renner.uno names. + - toEndpoints: + - matchLabels: + "k8s:io.kubernetes.pod.namespace": ingress + app.kubernetes.io/name: traefik + toPorts: + - ports: + - port: "8443" + protocol: TCP + - toEntities: + - kube-apiserver + toPorts: + - ports: + - port: "6443" + protocol: TCP + - toEntities: + - world + toPorts: + - ports: + - port: "443" + protocol: TCP diff --git a/kubernetes/apps/paperclip/paperclip.yaml b/kubernetes/apps/paperclip/paperclip.yaml new file mode 100644 index 00000000..3a879525 --- /dev/null +++ b/kubernetes/apps/paperclip/paperclip.yaml @@ -0,0 +1,109 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paperclip + namespace: paperclip + labels: + app.kubernetes.io/name: paperclip + annotations: + secret.reloader.stakater.com/reload: paperclip-secrets +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: paperclip + template: + metadata: + labels: + app.kubernetes.io/name: paperclip + spec: + enableServiceLinks: false + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: paperclip + image: ghcr.io/paperclipai/paperclip:nightly@sha256:5766cc727c7855359cf86d0d4fc422464f12ccb7b555b0432fe5918d6267a14b # {"$imagepolicy": "flux-system:paperclip"} + ports: + - name: http + containerPort: 3100 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: pclp-pg-app + key: uri + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "3100" + - name: PAPERCLIP_DEPLOYMENT_MODE + value: authenticated + - name: PAPERCLIP_DEPLOYMENT_EXPOSURE + value: private + - name: PAPERCLIP_PUBLIC_URL + value: https://paperclip.renner.uno + # Sign-up stays open until the first admin is claimed; the gate restricts who reaches it. + - name: PAPERCLIP_AUTH_DISABLE_SIGN_UP + value: "false" + - name: PAPERCLIP_TELEMETRY_DISABLED + value: "1" + envFrom: + - secretRef: + name: paperclip-secrets + volumeMounts: + - name: data + mountPath: /paperclip + startupProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 60 + livenessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 30 + timeoutSeconds: 10 + readinessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + memory: 4Gi + # Entrypoint remaps the node user and chowns /paperclip as root, then drops to it via gosu. + securityContext: + runAsNonRoot: false + allowPrivilegeEscalation: true + capabilities: + drop: ["ALL"] + add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"] + volumes: + - name: data + persistentVolumeClaim: + claimName: paperclip-data +--- +apiVersion: v1 +kind: Service +metadata: + name: paperclip + namespace: paperclip +spec: + selector: + app.kubernetes.io/name: paperclip + ports: + - name: http + port: 3100 + targetPort: http diff --git a/kubernetes/apps/paperclip/pvc.yaml b/kubernetes/apps/paperclip/pvc.yaml new file mode 100644 index 00000000..a45adba8 --- /dev/null +++ b/kubernetes/apps/paperclip/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: paperclip-data + namespace: paperclip + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled # data guard +spec: + accessModes: [ReadWriteOnce] + storageClassName: ceph-block + resources: + requests: + storage: 20Gi -- 2.54.0 From 78cac1236727098d84948f4fc68573c0d8efddf1 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:08:47 +0200 Subject: [PATCH 08/10] feat(paperclip): Flux Kustomization Co-Authored-By: Claude Opus 5.5 --- kubernetes/clusters/homelab/paperclip.yaml | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 kubernetes/clusters/homelab/paperclip.yaml diff --git a/kubernetes/clusters/homelab/paperclip.yaml b/kubernetes/clusters/homelab/paperclip.yaml new file mode 100644 index 00000000..fbd95f6a --- /dev/null +++ b/kubernetes/clusters/homelab/paperclip.yaml @@ -0,0 +1,25 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: paperclip + namespace: flux-system +spec: + interval: 10m + retryInterval: 1m + timeout: 20m + sourceRef: + kind: GitRepository + name: flux-system + path: ./kubernetes/apps/paperclip + prune: true + wait: true + dependsOn: + - name: infrastructure + - name: infrastructure-crds + - name: infrastructure-ceph-cluster + - name: infrastructure-cnpg-barman + healthCheckExprs: + - apiVersion: postgresql.cnpg.io/v1 + kind: Cluster + failed: "has(status.phase) && status.phase.startsWith('Failed')" + current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)" -- 2.54.0 From bb3a923c430ac9b85ca0eb9ed695db975dc01414 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:22:17 +0200 Subject: [PATCH 09/10] fix(paperclip): probes pass the hostname guard; tighten egress and SA token - Paperclip's private-hostname guard (authenticated+private) returns 403 for any Host but loopback and the public URL. Kubelet probes send the pod IP, so the pod could never become Ready. Probes now send Host: localhost. - Liveness is a TCP check: /api/health runs SELECT 1, so a Postgres outage would have restarted the server and killed in-flight runs. - Baseline CNP allowed world on every port; Cilium unions allows across CNPs, so the app-level :443 restriction was not enforced. world is now :443-only in the baseline as well. - automountServiceAccountToken: false. Every agent shares the pod filesystem; bots bring their own cluster credentials. Co-Authored-By: Claude Opus 5.5 --- kubernetes/apps/paperclip/paperclip.yaml | 13 +++++++++++-- .../network-policies/paperclip-ns.yaml | 6 ++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/kubernetes/apps/paperclip/paperclip.yaml b/kubernetes/apps/paperclip/paperclip.yaml index 3a879525..924651d7 100644 --- a/kubernetes/apps/paperclip/paperclip.yaml +++ b/kubernetes/apps/paperclip/paperclip.yaml @@ -20,6 +20,8 @@ spec: app.kubernetes.io/name: paperclip spec: enableServiceLinks: false + # Every agent shares this filesystem; bots bring their own cluster credentials. + automountServiceAccountToken: false securityContext: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch @@ -58,16 +60,20 @@ spec: volumeMounts: - name: data mountPath: /paperclip + # The private-hostname guard 403s any Host but loopback and the public URL; the kubelet sends the pod IP. startupProbe: httpGet: path: /api/health port: http + httpHeaders: + - name: Host + value: localhost periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 60 + # /api/health runs SELECT 1; liveness stays DB-independent so a Postgres outage doesn't restart the server. livenessProbe: - httpGet: - path: /api/health + tcpSocket: port: http periodSeconds: 30 timeoutSeconds: 10 @@ -75,6 +81,9 @@ spec: httpGet: path: /api/health port: http + httpHeaders: + - name: Host + value: localhost periodSeconds: 10 timeoutSeconds: 5 resources: diff --git a/kubernetes/infrastructure/network-policies/paperclip-ns.yaml b/kubernetes/infrastructure/network-policies/paperclip-ns.yaml index 23e7ba99..27b2ebd4 100644 --- a/kubernetes/infrastructure/network-policies/paperclip-ns.yaml +++ b/kubernetes/infrastructure/network-policies/paperclip-ns.yaml @@ -66,7 +66,13 @@ spec: - toEntities: - host - remote-node + # Cilium unions allows across CNPs, so world stays :443-only here too. + - toEntities: - world + toPorts: + - ports: + - port: "443" + protocol: TCP - toEndpoints: - matchExpressions: - key: io.kubernetes.pod.namespace -- 2.54.0 From 50cf3e977a1b2a58f65ac5611f9c388ddae2905c Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 13:22:43 +0200 Subject: [PATCH 10/10] docs(paperclip): align spec with implementation and review fixes Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-25-paperclip-design.md | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-paperclip-design.md b/docs/superpowers/specs/2026-09-25-paperclip-design.md index c77f76af..4d0bf181 100644 --- a/docs/superpowers/specs/2026-09-25-paperclip-design.md +++ b/docs/superpowers/specs/2026-09-25-paperclip-design.md @@ -61,29 +61,27 @@ All modeled on `kubernetes/apps/paperless/` unless stated. - Image `ghcr.io/paperclipai/paperclip:nightly@sha256:` with the Flux image-automation marker for digest updates. - `replicas: 1`, `strategy: Recreate` (single writer on an RWO PVC). -- Runs as uid/gid 1000 (image default `node` user); `fsGroup: 1000`. +- Starts as root: upstream `docker-entrypoint.sh` remaps the `node` user, chowns + `/paperclip`, then drops to uid 1000 via `gosu`. Caps limited to CHOWN, + DAC_OVERRIDE, FOWNER, SETGID, SETUID; `fsGroup: 1000`. +- `automountServiceAccountToken: false` (all agents share the pod filesystem). - Port 3100; `Service paperclip` ClusterIP :3100. -- Probes: `startupProbe`, `readinessProbe`, `livenessProbe` on `GET /api/health`. +- Probes: startup and readiness on `GET /api/health` with `Host: localhost` (the + private-hostname guard rejects the pod IP); liveness is a TCP check, since + `/api/health` runs `SELECT 1` and a DB outage must not restart the server. - Env: - `DATABASE_URL` assembled from CNPG `pclp-pg-app` Secret (`uri` key). - `HOST=0.0.0.0`, `PORT=3100` - `PAPERCLIP_DEPLOYMENT_MODE=authenticated` - `PAPERCLIP_DEPLOYMENT_EXPOSURE=private` - `PAPERCLIP_PUBLIC_URL=https://paperclip.renner.uno` - - `PAPERCLIP_AUTH_DISABLE_SIGN_UP=true` (the first admin is created via the - `bootstrap-ceo` invite flow, which does not depend on open sign-up; the plan - verifies this before the first rollout) + - `PAPERCLIP_AUTH_DISABLE_SIGN_UP=false` until the first admin is claimed: the + claim (`/bootstrap/claim`) requires an already signed-in Better Auth account. + A follow-up flips it to `true`. + - `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`). - `envFrom: secretRef paperclip-secrets` - Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`. -### Config file (telemetry) -Telemetry has no env switch; only `telemetry.enabled` in -`/paperclip/instances/default/config.json` (`PAPERCLIP_CONFIG`). Deliver -`{"telemetry":{"enabled":false}}` merged into that file from a ConfigMap. The plan -must first verify whether the server rewrites `config.json` at runtime; if it does, the -ConfigMap is copied in by an initContainer (merge into the existing file) instead of -mounted read-only via `subPath`. - ### Storage - `PVC paperclip-data`, `ceph-block`, 20Gi, mounted at `/paperclip` (`PAPERCLIP_HOME` and `HOME`): instance config, workspaces, repo checkouts, per-agent `claude-runtime` @@ -119,8 +117,9 @@ extract): http://paperclip.paperclip.svc.cluster.local:3100`, `external_host: https://paperclip.renner.uno`), application, group "Paperclip Users", policy binding; - outpost `ak-outpost-paperclip-gate`; - - `kubernetes/apps/authentik/paperclip-gate-ingress.yaml`, - `paperclip-gate-egress.yaml`; + - `kubernetes/apps/authentik/paperclip-gate-egress.yaml` (no `auth.renner.uno` + outpost Ingress: like wiki/shelly gates, the outpost serves its own callback on + `paperclip.renner.uno`); - `ExternalName` Service in `paperclip` ns pointing at the outpost; the app Ingress targets it on :9000. - Paperclip's own email/password login stays on (no header/OIDC login upstream). @@ -135,7 +134,11 @@ Covers the server **and every agent** (same pod under B). `*.renner.uno` names - `kube-apiserver` entity :6443 - `world` TCP :443 (Anthropic API, GitHub, npm) -- No SSH egress; git over HTTPS only. +- No SSH egress; git over HTTPS only. The namespace baseline + (`paperclip-ns.yaml`) also restricts `world` to TCP 443 — Cilium unions allows + across CNPs, so an unrestricted baseline would override this list. +- Known gap (house baseline): pods in `monitoring`/`database` and other pods in + `paperclip` can reach :3100 without the gate; Paperclip's own login still applies. ### Image automation `kubernetes/infrastructure/image-automation/paperclip.yaml`: `ImageRepository` -- 2.54.0